前段时间在RocketMQ的ISSUE上冲浪的时候,看到一个pr。虽然是在RocketMQ网站上找到的,但是这玩意跟RocketMQ没有任何关系。Pure是JDK的一个BUG。先问大家一个问题:LinkedBlockingQueue是线程安全的吗?这些都是老套路了,不能脱口而出,就该受罚。答案是:因为有这两个锁的存在,所以是线程安全的。但是在RocketMQ的某个场景中,LinkedBlockingQueue线程不安全的情况被稳定重现。先说结论:LinkedBlockingQueue的流遍历方式在多线程下存在一定的问题,可能会出现死循环。太有意思了,本文带你一探究竟。其实我不需要做DemoDemo。上面提到的PR链接是这个:github.com/apache/rock...在这个链接里,已经有很多关于RocketMQ的讨论。不过中段有个外号areyouok的大佬一针见血,指出了问题所在。直接给出一个很简单的复现代码。并且RocketMQ的东西被完全剥离出来:俗话说,前人栽树,后人乘凉。既然看到了areyouok的代码,就直接拿来做demo进行演示。如果您不介意,为了表示我的尊重,我敢说:感谢雷老师的代码。我先贴雷老师的代码,让大家看了文章就可以实际操作了:1000);free(inti=0;i<10;i++){newthread(()->{space(true){queue.offer(newobject()));队列.删除();})。();}while(true){System.out.println("开始扫描,我还活着");queue.stream().Filter(o->o==null).FindFirst().ispResit();线程.睡眠(100);System.out.println("完成扫描,我还活着");}}}复制代码下面介绍一下上面代码的核心逻辑。首先,搞了10个线程,每个线程不停的调用offer和remove方法。需要注意的是,这个remove方法是一个无参方法,意思是移除头节点。再次强调:LinkedBlockingQueue有一个ReentrantLock锁,所以即使多个线程并发操作offer或者remove方法,也必须获得锁才能操作,所以这个一定是线程安全的。然后在主线程中创建一个死循环,对队列进行流操作,看能不能找到队列中第一个不为空的元素。这种流操作是一种掩饰的方法。真正的重点是tryAdvance方法:先关注这个方法,稍后再详细说明。按理说,这个方法运行后,应该一直输出这两句:beginscan,istillalivefinishscan,istillalive你会发现控制台只有这个东西:或者只交替输出几次就可以了走了。但是当我们不搬代码的时候,直接更换JDK版本就可以了。比如我刚好有JDK15,替换完后我再运行一下,替换效果就出来了:那么根据上面的表现,我是不是可以大胆猜到这是JDK8版本的BUG呢?既然有了可以在JDK8运行环境下稳定复现的demo,接下来就是定位BUG的原因了。是什么原因?先说说我遇到这个问题后排查问题的思路。很简单,你想想,主线程应该一直在输出但是没有输出,那它在干嘛呢?我最初的怀疑是它正在等待锁定。如何验证?小伙伴们,可爱的小相机又出现了:通过它,我可以Dump当前状态下每个线程在做什么。但是当我看到主线程的状态是RUNNABLE时,我有点疑惑了:这是什么情况?如果它正在等待锁,它不应该是RUNNABLE吗?再次来Dump验证一下:发现还是RUNNABLE,所以直接排除锁等待的嫌疑。特地反映了两次Dump线程的这个操作是有原因的。因为很多朋友在dump线程的时候拿一个dump文件,硬着头皮去分析,但是我觉得正确的操作应该是在不同的时间点dump多次,对比分析不同dump文件中的同一个线程在干什么。比如我在不同的时间点做了两次Dump,发现主线程处于RUNNABLE状态,也就是说从程序的角度来看,主线程并没有被阻塞。但是就控制台输出而言,它似乎又阻塞了。经典,我的朋友们。你觉得这是怎样的经典画面呢?不是这个,这个东西吗?线程中出现死循环:System.out.println("beginscan,istillalive");while(true){}System.out.println("完成扫描,我还活着");复制代码验证一波。从Dump文件中我们可以观察到主线程正在执行这个方法:atjava.util.concurrent.LinkedBlockingQueue$LBQSpliterator.tryAdvance(LinkedBlockingQueue.java:950)还记得我之前插入的眼睛吗?这里就是前面说的stream只是个蒙眼的东西,真正关键的地方是tryAdvance这个方法。点击查看JDK8的tryAdvance方法,果然里面有个while循环:从while条件来看,current!=null永远为true,e!=null永远为false,所以不能跳出这个循环。但是从while循环体内的逻辑来看,里面的当前节点会发生变化:current=current.next;来吧,结合目前的情况,我来细说。LinkedBlockingQueue的数据结果是一个链表。tryAdvance方法中存在死循环,说明循环条件current=null永远为真,e!=null永远为假。但是在循环体中有一个获取下一个节点的动作,current=current.next。综上所述,当前链表中有这样一个节点:只有这样才能同时满足这两个条件:current.item=nullcurrent.next=null那么这样的节点什么时候出现呢?这种情况是要从链表中移除节点,所以一定是调用移除节点相关方法的时候。看我们的Demo代码,只有这一行和removal相关的代码:queue.remove();前面说了这个remove方法是移除头节点,效果和poll是一样的。在其源码中也是直接调用了poll方法:所以我们主要看poll方法的源码:java.util.concurrent.LinkedBlockingQueue#poll()标有①的两个地方是拿锁和释放锁分别加锁,说明这个方法是线程安全的。那么重点就是标记②的地方,dequeue方法,这个方法就是去掉头节点的方法:java.util.concurrent.LinkedBlockingQueue#dequeue它是如何去掉头节点的?是我框出来的部分,指向自己,做一个孤独的节点,就完了。h.next=h就是我之前画的图:那么dequeue方法和tryAdvance方法中的while循环这个地方会发生什么神奇的事情呢?这个东西不好描述,你知道的,所以,我决定在下面给大家画一张图,这样更容易理解。screendemo现在我已经掌握了这个BUG的原理,所以为了方便自己调试,我也简化了示例代码,核心逻辑不变,还是几行代码,主要是触发tryAdvance方法:首先,根据代码,向队列中添加元素后,队列如下:上图中多剪一个方法。也就是再往上看,触发tryAdvance方法的地方叫forEachWithCancel。从源码来看,其实就是一个循环。循环结束的条件是tryAdvance方法返回false,即遍历结束。然后我特意框了lock和unlock,也就是说try方法是线程安全的,因为此时put和take的锁都已经拿到了。也就是说,当一个线程正在执行tryAdvance方法,锁被成功加锁后,如果其他线程需要对队列进行操作,则无法获取到锁,线程必须等待操作完成释放锁。但是加锁的范围不是整个遍历周期,而是每次触发tryAdvance方法。并且每个tryAdvance方法只处理链表中的一个节点。铺垫就差不多到这里了,接下来就带大家一步步分析tryAdvance方法的核心源码,也就是这部分代码:第一次触发时,当前对象为null,因此将执行初始化操作:current=q.head.next;那么当前是节点1:此时。然后执行while循环,当满足current!=null条件时,进入循环体。在循环体内,执行了两行代码。第一行是这样的,取出当前节点中的值:e=current.item;在我的演示中,e=1。第二行就是这行代码,意思是保持current为下一个节点,下次触发tryAdvance方法时直接使用:current=current.next;那么因为e!=null,break结束了循环:执行完一个tryAdvance方法后,current指向这个位置的节点:朋友们,接下来有意思的事情来了。假设第二次触发tryAdvance方法时,执行了下图中框出部分的任意一行代码,也就是在还没有获取到锁或者无法获取到锁的时候:这时候又来了一个线程,并且正在执行remove()方法不断的移除头节点。执行了三次remove()方法后,链表变成了这样:接下来,当我将两个图合并在一起时,就是见证奇迹的时候了:第三次执行remover方法时,tryAdvance方法再次成功抢到锁,开始执行。在我们大神的视角下,可以看到这一幕:我可以从Debug视图验证一下:可以看到current的下一个节点还是自己,而且都是LinkedBlockingQueue$Mode@701对象不为null.所以这个地方的死循环就是这么来的。分析完可以回想一下流程。其实这道题并没有想象中那么难。你要相信,只要给你可以稳定复现的代码,所有的bug都是可以调试的。在调试的过程中,我还想到了另外一个问题:如果我调用这个remove方法,移除指定的元素。会不会有同样的问题?不知道,但是很简单,自己试验一下就知道了。还是在tryAdvance方法下了断点,然后在tryAdvance方法第二次触发后,通过Alt+F8调出Evaluate函数,分别执行queue.remove1、2、3:然后观察当前元素,有没有指向自己的情况:为什么?源代码下没有秘密。答案写在unlink方法中:入参中的p是要移除的节点,trail是要移除的节点的前一个节点。在源码中,我只看到trail.next=p.next,也就是跳过要通过指针移除的节点。但是没有看到源码中出现过类似前面dequeue方法中出现的p.next=p,也就是将节点的下一个节点指向自身的动作。为什么?作者在评论里写的很清楚:p.next没有变,让正在遍历p的迭代器保持弱??一致性保证。p.next没有变,因为它是为了保持遍历p的迭代器的弱一致性而设计的.用人的话来说:这个东西不能指向自己,如果指向自己,如果这个节点正在被迭代器执行,那不就完蛋了吗?所以带参数的remove方法考虑到了迭代器的情况,但是不带参数的remove方法考虑的不好。你怎么修好它的?我在JDKBUG库中搜索。其实这个问题在2016年的JDKBUG列表中就出现了:bugs.openjdk.org/browse/JDK-...在JDK9版本中修复了。我本地有一份JDK15的源码,所以我和JDK8的源码对比一下:主要变化在try的代码块。JDK15的源码中调用了一个succ方法。从方法的注释也可以看出,这个bug是专门修复的:比如回到这个场景:我们来说说succ方法是如何处理当前情况的:Node
