JDK1.8 LinkedBlockingQueue 死锁Bug
JDK1.8 LinkedBlockingQueue 死锁Bug
我们直接看下面的代码是否有问题,本文没有特殊说明,默认环境是在JDK1.8环境下
private static void endlessLoopFromGithub() throws InterruptedException { |
在正式解读代码之前。我这边抛出一个问题。LinkedBlockingQueue是线程安全的吗?
答案是线程安全的,因为LinkedBlockingQueue内部有take和put锁。
接下来我们开始解析代码。
- 我们定义了LinkedBlockingQueue队列。
- 我们在for循环里边启动了10个线程,线程内部一直对queue增加元素和移除元素的操作。
- 我们在后面写了while(true),只是对队列进行了stream()迭代。
这里我们需要说明下remove()方法,移除队列的首元素,如果没有元素移除,会抛异常。核心逻辑都在poll()方法中。下面我们重点分析该方法。
代码很简单。我们最终的期望是:终端后台一直交替输出下面的内容。
begin scan, i still alive |
但是程序并没有按照我们期望的运行,输出10来条(或者1条)就停止(在JDK1.8环境下)。
如果把JDK环境变更为JDK17,我们会发现,程序的运行符合我们的预期。
到此为止,我们可以得出一个结论:JDK1.8的LinkedBlockingQueue 有BUG!!
LinkedBlockingQueue Bug 分析
接下来,进入我们Bug分析环节。当我们发现线程被hold住了。我们要怎么办呢?
这里要安利我们IDEA强大的【照相机】Dump Threads功能。把我们线程的信息快照下来。
em~你说你不知道【照相机】?
从上面截图,我们点击照相机会得到当前JVM的线程信息。我们需要运行多两次,比较多次不同的线程的状态。
这里我们从前边的【照相机】得到main线程的状态是RUNNABLE的状态,主线程并没有阻塞,但是我们通过控制台输出看,似乎又是阻塞住了?
这是不是很熟悉?是的死循环,我们大胆怀疑发生了死循环。
再进一步分析死循环之前,我们先看看java.util.concurrent.LinkedBlockingQueue#poll()
如果您之前看过我关于MemorySafeLinkedBlockingQueue的介绍,对于poll()逻辑应该再熟悉不过了。
poll()方法是移除元素,并且移除的是首位元素。
- 从红色的框1,我们可以知道整个操作需要先获取到释放锁,finally释放锁。
- 从红色的框2,是从队列移除元素的核心逻辑。
这里面是经典的移除链表的头节点,自己指向自己,并且返回移除节点自身的值。
通过画图给大家看,应该可以能够理解上面的代码的意思了。
从上面的截图我们可以知道,queue.stream()本质是调用了LinkedBlockingQueue.LBQSpliterator#tryAdvance的方法。
接下来我们认真分析下:tryAdvance的逻辑,就能搞懂为什么会有死循环了
- tryAdvance是stream每遍历一个元素都会执行的逻辑
- q.fullyLock()会尝试获取take和put的锁,之后才能保证线程安全,不是整个迭代期间都锁住
- 首次进入,判断
current==null
,肯定成立 - 然后我们可以看到while里面有个循环,循环退出条件:e != null
- 并且current的值会一直变更的,current=current.next
这是stream()遍历到第二个元素时候的运行截图,结合下面的图片。图文并茂。
在介绍完dequeue()和tryAdvance()方法之后,接下来我为你一步步揭秘死循环产生的原因。
接下来是全文的重点,前方高能请集中注意力。
假设第二次 tryAdvance 方法触发的时候,执行到下面框起来的部分的任意一行代码,也就是还没有获取锁或者获取不到锁的时候:
这时候有另外一个线程来了,它在执行remove()方法,不断移除头节点。
执行了三次remove()之后,出现如下的情况。
我们准备第二次进入while,发现current != null && e = null都成立,死循环复显实锤!current=current.next;都是同一个对象。
此时我们从上帝视角,看到tryAdvance()看到是这样的:
好了,到此我们终于把死循环的bug讲解清楚了。
我们都知道问题是出现在remove()方法上,移除对象的时候自己指向自己。在LinkedBlockingQueue还有一个带参数的remove(o),会不会也有这样的问题呢,源码之下无秘密。
又是老八股文了,双指针删除指定节点,核心逻辑在unlink()逻辑。
p是我们要删除的节点,核心逻辑很简单:trail.next = p.next;
,为什么remove(o)不采用自己指向自己的方案?
大佬在注释方面直接说了:p.next不改变,以允许遍历p的迭代器保持弱一致性保证。已经考虑到迭代器的情况。
我们重复上面的流程,调用remove(o)的方法。通过IDEA强大的evaluate。
可以看到我们可以看到此时的队列是这样的。
while循环,会遍历到item=4,然后break。完成本次的迭代。正常退出。
How to fix it
JDK官网在2016年的时候,就发现了这个bug。在JDK1.9的版本修复了。
在JDK17的时候,可以看到tryAdvance()进行了一波fix bug,主要的变更在succ逻辑中。
回到我们产生死循环的场景,succ是怎么解决的。
图中的current是p对象,我们从之前的debug可以知道,p==p=p.next是成立,所以,head.next=4条件成立。在succ外面一层直接不满足循环条件,直接退出。完美。
上面是JDK给到我们的解决方案。
我们还有没有其他方案解决呢?在不升级JDK的场景下。
- 不使用stream()触发到tryAdvance()的逻辑,直接使用迭代器。
- 使用synchronized(queue){},我们自己定义锁。
To learn more
回到我这篇文章开篇的一个问题:LinkedBlockingQueue 这个玩意是线程安全的吗?
你面试的时候遇到这个问题,你就微微一笑,答到:由于内部有读写锁的存在,这个玩意一般情况下是线程安全的。但是,在 JDK8 的场景下,当它遇到 stream 操作的时候,又有其他线程在调用无参的 remove 方法,会有一定几率出现死循环的情况。