发布于 

JDK1.8 LinkedBlockingQueue 死锁Bug

JDK1.8 LinkedBlockingQueue 死锁Bug

我们直接看下面的代码是否有问题,本文没有特殊说明,默认环境是在JDK1.8环境下

private static void endlessLoopFromGithub() throws InterruptedException {
LinkedBlockingQueue<Object> queue = new LinkedBlockingQueue<>(1000);
for (int i = 0; i < 10; i++) {
int finalI = i;
new Thread(() -> {
while (true) {
queue.offer(new Integer(finalI));
queue.remove();
}
}, "noahThread" + finalI).start();
}

while (true) {
System.out.println("begin scan, i still alive");
queue.stream()
.filter(o -> o == null)
.findFirst()
.isPresent();
Thread.sleep(100);
System.out.println("finish scan, i still alive");
}
}

在正式解读代码之前。我这边抛出一个问题。LinkedBlockingQueue是线程安全的吗?

image-20220810163626012

答案是线程安全的,因为LinkedBlockingQueue内部有take和put锁。

接下来我们开始解析代码。

  1. 我们定义了LinkedBlockingQueue队列。
  2. 我们在for循环里边启动了10个线程,线程内部一直对queue增加元素和移除元素的操作。
  3. 我们在后面写了while(true),只是对队列进行了stream()迭代。

image-20220810164756256

这里我们需要说明下remove()方法,移除队列的首元素,如果没有元素移除,会抛异常。核心逻辑都在poll()方法中。下面我们重点分析该方法。

代码很简单。我们最终的期望是:终端后台一直交替输出下面的内容。

begin scan, i still alive
finish scan, i still alive
begin scan, i still alive
finish scan, i still alive

但是程序并没有按照我们期望的运行,输出10来条(或者1条)就停止(在JDK1.8环境下)。

如果把JDK环境变更为JDK17,我们会发现,程序的运行符合我们的预期。

到此为止,我们可以得出一个结论:JDK1.8的LinkedBlockingQueue 有BUG!!

LinkedBlockingQueue Bug 分析

接下来,进入我们Bug分析环节。当我们发现线程被hold住了。我们要怎么办呢?

image-20220810170246758

这里要安利我们IDEA强大的【照相机】Dump Threads功能。把我们线程的信息快照下来。

em~你说你不知道【照相机】?

image-20220810171703305

从上面截图,我们点击照相机会得到当前JVM的线程信息。我们需要运行多两次,比较多次不同的线程的状态。

这里我们从前边的【照相机】得到main线程的状态是RUNNABLE的状态,主线程并没有阻塞,但是我们通过控制台输出看,似乎又是阻塞住了?

这是不是很熟悉?是的死循环,我们大胆怀疑发生了死循环。

再进一步分析死循环之前,我们先看看java.util.concurrent.LinkedBlockingQueue#poll()

image-20220810174133829

如果您之前看过我关于MemorySafeLinkedBlockingQueue的介绍,对于poll()逻辑应该再熟悉不过了。

poll()方法是移除元素,并且移除的是首位元素。

  1. 从红色的框1,我们可以知道整个操作需要先获取到释放锁,finally释放锁。
  2. 从红色的框2,是从队列移除元素的核心逻辑。

image-20220810191202963

这里面是经典的移除链表的头节点,自己指向自己,并且返回移除节点自身的值。

image-20220810201958382

通过画图给大家看,应该可以能够理解上面的代码的意思了。

image-20220810172824314

从上面的截图我们可以知道,queue.stream()本质是调用了LinkedBlockingQueue.LBQSpliterator#tryAdvance的方法。

接下来我们认真分析下:tryAdvance的逻辑,就能搞懂为什么会有死循环了

  1. tryAdvance是stream每遍历一个元素都会执行的逻辑
  2. q.fullyLock()会尝试获取take和put的锁,之后才能保证线程安全,不是整个迭代期间都锁住
  3. 首次进入,判断current==null,肯定成立
  4. 然后我们可以看到while里面有个循环,循环退出条件:e != null
  5. 并且current的值会一直变更的,current=current.next

image-20220810201958382

image-20220811192914786

这是stream()遍历到第二个元素时候的运行截图,结合下面的图片。图文并茂。

image-20220811193657462

在介绍完dequeue()和tryAdvance()方法之后,接下来我为你一步步揭秘死循环产生的原因。

接下来是全文的重点,前方高能请集中注意力。

假设第二次 tryAdvance 方法触发的时候,执行到下面框起来的部分的任意一行代码,也就是还没有获取锁或者获取不到锁的时候:

image-20220811194235365

这时候有另外一个线程来了,它在执行remove()方法,不断移除头节点。

执行了三次remove()之后,出现如下的情况。

image-20220811195408432

我们准备第二次进入while,发现current != null && e = null都成立,死循环复显实锤!current=current.next;都是同一个对象。

image-20220811195918987

此时我们从上帝视角,看到tryAdvance()看到是这样的:

image-20220812093932840

好了,到此我们终于把死循环的bug讲解清楚了。

我们都知道问题是出现在remove()方法上,移除对象的时候自己指向自己。在LinkedBlockingQueue还有一个带参数的remove(o),会不会也有这样的问题呢,源码之下无秘密。

image-20220812112228519

又是老八股文了,双指针删除指定节点,核心逻辑在unlink()逻辑。

image-20220812112811860

p是我们要删除的节点,核心逻辑很简单:trail.next = p.next;,为什么remove(o)不采用自己指向自己的方案?

大佬在注释方面直接说了:p.next不改变,以允许遍历p的迭代器保持弱一致性保证。已经考虑到迭代器的情况。

image-20220812114043620

我们重复上面的流程,调用remove(o)的方法。通过IDEA强大的evaluate。

image-20220812114301356

可以看到我们可以看到此时的队列是这样的。

image-20220812115013082

while循环,会遍历到item=4,然后break。完成本次的迭代。正常退出。

How to fix it

JDK官网Bug

image-20220812144434712

JDK官网在2016年的时候,就发现了这个bug。在JDK1.9的版本修复了。

image-20220812150429420

在JDK17的时候,可以看到tryAdvance()进行了一波fix bug,主要的变更在succ逻辑中。

image-20220815203431006

回到我们产生死循环的场景,succ是怎么解决的。image-20220812093932840

图中的current是p对象,我们从之前的debug可以知道,p==p=p.next是成立,所以,head.next=4条件成立。在succ外面一层直接不满足循环条件,直接退出。完美。

上面是JDK给到我们的解决方案。

我们还有没有其他方案解决呢?在不升级JDK的场景下。

  1. 不使用stream()触发到tryAdvance()的逻辑,直接使用迭代器。
  2. 使用synchronized(queue){},我们自己定义锁。

To learn more

回到我这篇文章开篇的一个问题:LinkedBlockingQueue 这个玩意是线程安全的吗?

你面试的时候遇到这个问题,你就微微一笑,答到:由于内部有读写锁的存在,这个玩意一般情况下是线程安全的。但是,在 JDK8 的场景下,当它遇到 stream 操作的时候,又有其他线程在调用无参的 remove 方法,会有一定几率出现死循环的情况。