关于后端:喜提JDK的BUG一枚多线程的情况下请谨慎使用这个类的stream遍历

2次阅读

共计 7115 个字符,预计需要花费 18 分钟才能阅读完成。

前段时间在 RocketMQ 的 ISSUE 外面冲浪的时候,看到一个 pr,虽说是在 RocketMQ 的地盘上发现的,然而这个玩意吧,其实和 RocketMQ 没有任何关系。纯纯的就是 JDK 的一个 BUG。我先问你一个问题:LinkedBlockingQueue 这个玩意是线程平安的吗?这都是老八股文了,你要是不能脱口而出,应该是要挨板子的。

答案是:是线程平安的,因为有这两把锁的存在。

然而在 RocketMQ 的某个场景下,竟然稳固复现了 LinkedBlockingQueue 线程不平安的状况。先说论断:LinkedBlockingQueue 的 stream 遍历的形式,在多线程下是有肯定问题的,可能会呈现死循环。老有意思了,这篇文章带大家盘一盘。搞个 DemoDemo 其实都不必我搞了,后面提到的 pr 的链接是这个:github.com/apache/rock…在这个链接外面,后面围绕着 RocketMQ 探讨了很多。然而在两头局部,一个昵称叫做 areyouok 的大佬切中时弊,指出了问题的所在。间接给出了一个非常简单的复现代码。而且齐全把 RocketMQ 的货色剥离了进来:

正所谓前人栽树后人乘凉,既然让我看到了 areyouok 这位大佬的代码,那我也就间接拿来当做演示的 Demo 了。如果你不介意的话,为了示意我的尊敬,我斗胆说一声:感激雷总的代码。

我先把雷总的代码粘进去,不便看文章的你也实际操作一把:public class TestQueue {
    public static void main(String[] args) throws Exception {
        LinkedBlockingQueue<Object> queue = new LinkedBlockingQueue<>(1000);
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    queue.offer(new Object());
                    queue.remove();
                }
            }).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”);
        }
    }
}
复制代码介绍一下下面的代码的外围逻辑。首先是搞了 10 个线程,每个线程外面在不停的调用 offer 和 remove 办法。须要留神的是这个 remove 办法是无参办法,意思是移除头节点。再强调一次:LinkedBlockingQueue 外面有 ReentrantLock 锁,所以即便多个线程并发操作 offer 或者 remove 办法,也都要别离拿到锁能力操作,所以这肯定是线程平安的。而后主线程外面搞个死循环,对 queue 进行 stream 操作,看看能不能找到队列外面第一个不为空的元素。这个 stream 操作是一个障眼法,真正的关键点在于 tryAdvance 办法:

先在这个办法这里插个眼,一会再细嗦它。

按理来说,这个办法运行起来之后,应该不停的输入这两句话才对:begin scan, i still alive
finish scan, i still alive
复制代码然而,你把代码粘进来用 JDK 8 跑一把,你会发现控制台只有这个玩意:

或者只交替输入几次就没了。然而当咱们不动代码,只是替换一下 JDK 版本,比方我刚好有个 JDK 15,替换之后再次运行,交替的成果就进去了:

那么基于下面的体现,我是不是能够大胆的猜想,这是 JDK 8 版本的 BUG 呢?

当初咱们有了能在 JDK 8 运行环境下稳固复现的 Demo,接下来就是定位 BUG 的起因了。啥起因呀?先说一下我拿到这个问题之后,排查的思路。十分的简略,你想一想,主线程应该始终输入然而却没有输入,那么它到底是在干什么呢?我初步狐疑是在期待锁。怎么去验证呢?敌人们,可恶的小相机又呈现了:

通过它我能够 Dump 以后状态下各个线程都在干嘛。然而当我看到主线程的状态是 RUNNABLE 的时候,我就有点懵逼了:

啥状况啊?如果是在期待锁,不应该是 RUNNABLE 啊?再来 Dump 一次,验证一下:

发现还是在 RUNNABLE,那么间接就能够排除锁期待的这个狐疑了。我专门体现出两次 Dump 线程的这个操作,是有起因的。因为很多敌人在 Dump 线程的时候拿着一个 Dump 文件在哪儿使劲剖析,然而我感觉正确的操作应该是在不同工夫点屡次 Dump,比照剖析不同 Dump 文件外面的雷同线程别离是在干啥。比方我两次不同工夫点 Dump,发现主线程都是 RUNNABLE 状态,那么阐明从程序的角度来说,主线程并没有阻塞。然而从控制台输入的角度来说,它仿佛又是阻塞住了。经典啊,敌人们。你想想这是什么经典的画面啊?

这不就是,这个玩意吗,线程外面有个死循环:System.out.println(“begin scan, i still alive”);
while (true) {}
System.out.println(“finish scan, i still alive”);
复制代码来验证一波。从 Dump 文件中咱们能够察看到的是主线程正在执行这个办法:at java.util.concurrent.LinkedBlockingQueue$LBQSpliterator.tryAdvance(LinkedBlockingQueue.java:950) 还记得我后面插的眼吗?这里就是我后面说的 stream 只是障眼法,真正要害的点在于 tryAdvance 办法。点过来看一眼 JDK 8 的 tryAdvance 办法,果不其然,外面有一个 while 循环:

从 while 条件上看是 current!=null 始终为 ture,且 e!=null 始终为 false,所以跳不出这个循环。然而从 while 循环体外面的逻辑来看,外面的 current 节点是会发生变化的:current = current.next; 来,联合这目前有的这几个条件,我来细嗦一下。LinkedBlockingQueue 的数据后果是链表。在 tryAdvance 办法外面呈现了死循环,阐明循环条件 current=null 始终是 true,e!=null 始终为 false。然而循环体外面有获取下一节点的动作,current = current.next。综上可得,以后这个链表中有一个节点是这样的:

只有这样,才会同时满足这两个条件:current.item=nullcurrent.next=null 那么什么时候才会呈现这样的节点呢?

这个状况就是把节点从链表上拿掉,所以必定是调用移除节点相干的办法的时候。纵观咱们的 Demo 代码,外面和移除相干的代码就这一行:queue.remove(); 而后面说了,这个 remove 办法是移除头节点,成果和 poll 是一样一样的,它的源码外面也是间接调用了 poll 办法:

所以咱们次要看一下 poll 办法的源码:java.util.concurrent.LinkedBlockingQueue#poll()

两个标号为 ① 的中央别离是拿锁和开释锁,阐明这个办法是线程平安的。而后重点是标号为 ② 的中央,这个 dequeue 办法,这个办法就是移除头节点的办法:java.util.concurrent.LinkedBlockingQueue#dequeue

它是怎么移除头节点的呢?就是我框起来的局部,本人指向本人,做一个性情孤僻的节点,就完事了。h.next= h 也就是我后面画的这个图:

那么 dequeue 办法的这个中央和 tryAdvance 办法外面的 while 循环会产生一个什么样神奇的事件呢?这玩意还不好形容,你晓得吧,所以,我决定上面给你画个图,了解起来容易一点。

画面演示当初我曾经把握到这个 BUG 的原理了,所以为了不便我 Debug,我把实例代码也简化一下,外围逻辑不变,还是就这么几行代码,次要还是得触发 tryAdvance 办法:

首先依据代码,当 queue 队列增加完元素之后,队列是长这样的:

画个示意图是这样的:

而后,咱们接着往下执行遍历的操作,也就是触发 tryAdvance 办法:

下面的图我专门多截了一个办法。就是如果往上再看一步,触发 tryAdvance 办法的中央叫做 forEachWithCancel,从源码上看其实也是一个循环,循环完结条件是 tryAdvance 办法返回为 false,意思是遍历完结了。而后我还特意把加锁和解锁的中央框起来了,意思是阐明 try 办法是线程平安的,因为这个时候把 put 和 take 的锁都拿到了。说人话就是,当某个线程在执行 tryAdvance 办法,且加锁胜利之后,如果其余线程须要操作队列,那么是获取不到锁的,必须等这个线程操作实现并开释锁。然而加锁的范畴不是整个遍历期间,而是每次触发 tryAdvance 办法的时候。而每次 tryAdvance 办法,只解决链表中的一个节点。到这里铺垫的差不多了,接下来我就带你逐渐的剖析一下 tryAdvance 办法的外围源码,也就是这部分代码:

第一次触发的时候,current 对象是 null,所以会执行一个初始化的货色:current = q.head.next; 那么这个时候 current 就是 节点 1:

。接着执行 while 循环,这时 current!=null 条件满足,进入循环体。在循环体外面,会执行两行代码。第一行是这个,取出以后节点外面的值:e = current.item; 在我的 Demo 外面,e=1。第二行是这行代码,含意是保护 current 为下一节点,等着下次 tryAdvance 办法触发的时候间接拿来用:current = current.next;

接着因为 e!=null,所以 break 完结循环:

第一次 tryAdvance 办法执行实现之后,current 指向的是这个地位的节点:

敌人们,接下来有意思的就来了。假如第二次 tryAdvance 办法触发的时候,执行到上面框起来的局部的任意一行代码,也就是还没有获取锁或者获取不到锁的时候:

这时候有另外一个线程来了,它在执行 remove() 办法,一直的移除头结点。执行三次 remove() 办法之后,链表就变成了这样:

接下来,当我把这两个图合并在一起的时候,就是见证奇观的时候:

当第三次执行 remover 办法后,tryAdvance 办法再次胜利抢到锁,开始执行,从咱们的上帝视角,看到的是这样的场景:

这一点,我能够从 Debug 的视图外面进行验证:

能够看到,current 的 next 节点还是它本人,而且它们都是 LinkedBlockingQueue$Mode@701 这个对象,并不为 null。所以这个中央的死循环就是这么来的。

剖析完了之后,你再回忆一下这个过程,其实这个问题是不是并没有设想的那么艰难。你要置信,只有给到你能稳固复现的代码,所有 BUG 都是可能调试进去的。我在调试的过程中,还想到了另外一个问题:如果我调用的是这个 remove 办法呢,移除指定元素。

会不会呈现一样的问题呢?我也不晓得,然而很简略,试验一把就晓得了。还是在 tryAdvance 办法外面打上断点,而后在第二次触发 tryAdvance 办法之后,通过 Alt+F8 调出 Evaluate 性能,别离执行 queue.remove 1,2,3:

而后察看 current 元素,并没有呈现本人指向本人的状况:

为什么呢?源码之下无机密。

答案就写在 unlink 办法外面:

入参中的 p 是要移除的节点,而 trail 是要移除的节点的上一个节点。在源码外面只看到了 trail.next=p.next,也就是通过指针,跳过要移除的节点。然而并没有看到后面 dequeue 办法中呈现的相似于 p.next=p 的源码,也就是把节点的下一个节点指向本人的动作。为什么?

作者都在正文外面给你写分明了:p.next is not changed, to allow iterators that are traversing p to maintain their weak-consistency guarantee.p.next 没有产生扭转,因为在设计上是为了放弃正在遍历 p 的迭代器的弱一致性。说人话就是:这玩意不能指向本人啊,指向本人了要是这个节点正在被迭代器执行,那不是完犊子了吗?所以带参的 remove 办法是思考到了迭代器的状况,然而无参的 remove 办法,的确考虑不周。怎么修复的?我在 JDK 的 BUG 库外面搜了一下,其实这个问题 2016 年就呈现在了 JDK 的 BUG 列表外面:bugs.openjdk.org/browse/JDK-…

在 JDK9 的版本外面实现了修复。我本地有一份 JDK15 的源码,所以给你比照着 JDK8 的源码看一下:

次要的变动是在 try 的代码块外面。JDK15 的源码外面调用了一个 succ 办法,从办法上的正文也能够看进去就是专门修复这个 BUG 的:

比方回到这个场景下:

咱们来细嗦一下以后这个状况下,succ 办法是怎么解决的:Node<E> succ(Node<E> p) {
    if (p == (p = p.next))
        p = head.next;
    return p;
}
复制代码 p 是上图中的 current 对应的元素。首先 p = p.next 还是 p,因为它本人指向本人了,这个没故障吧?那么 p == (p = p.next),带入条件,就是 p==p,条件为 true,这个没故障吧?所以执行 p = head.next,从上图中来看,head.next 就是元素为 4 的这个节点,没故障吧?最初取到了元素 4,也就是最初一个元素,接着完结循环:

没有死循环,完满。延长一下回到我这篇文章开篇的一个问题:LinkedBlockingQueue 这个玩意是线程平安的吗?下次你面试的时候遇到这个问题,你就微微一笑,答到:因为外部有读写锁的存在,这个玩意个别状况下是线程平安的。然而,在 JDK8 的场景下,当它遇到 stream 操作的时候,又有其余线程在调用无参的 remove 办法,会有肯定几率呈现死循环的状况。说的时候自信一点,个别状况下,能够唬一下面试官。后面我给的解决方案是降级 JDK 版本,然而你晓得的,这是一个大动作,一般来说,能跑就不要四平八稳,所以另外我还能想到两个计划。第一个你就别用 stream 了呗,老老实实的应用迭代器循环,它不香吗?第二个计划是这样的:

成果杠杠的,相对没问题。你外部的 ReentrantLock 算啥,我间接给你来个锁晋升,内部用 synchronized 给你包裹起来。来,你有本事再给我表演一个线程不平安。

当初,我换一个问题问你:ConcurrentHashMap 是线程平安的吗?我之前写过,这玩意在 JDK8 下也是有死循环的《震惊!ConcurrentHashMap 外面也有死循环,作者留下的“彩蛋”理解一下?》在文章的最初我也问了一样的问题。过后的答复再次搬运一下:是的,ConcurrentHashMap 自身肯定是线程平安的。然而,如果你使用不当还是有可能会呈现线程不平安的状况。给大家看一点 Spring 中的源码吧:org.springframework.core.SimpleAliasRegistry 在这个类中,aliasMap 是 ConcurrentHashMap 类型的:

在 registerAlias 和 getAliases 办法中,都有对 aliasMap 进行操作的代码,然而在操作之前都是用 synchronized 把 aliasMap 锁住了。为什么咱们操作 ConcurrentHashMap 的时候还要加锁呢?

这个是依据场景而定的,这个别名管理器,在这里加锁应该是为了防止多个线程操作 ConcurrentHashMap。尽管 ConcurrentHashMap 是线程平安的,然而假如如果一个线程 put,一个线程 get,在这个代码的场景外面是不容许的。具体情况,须要具体分析。如果感觉不太好了解的话我举一个 Redis 的例子。Redis 的 get、set 办法都是线程平安的吧。然而你如果先 get 再 set,那么在多线程的状况下还是会有问题的。因为这两个操作不是原子性的。所以 incr 就应运而生了。我举这个例子的是想说线程平安与否不是相对的,要看场景。给你一个线程平安的容器,你使用不当还是会有线程平安的问题。再比方,HashMap 肯定是线程不平安的吗?说不能说的这么死吧。它是一个线程不平安的容器。然而如果我的应用场景是只读呢?在这个只读的场景下,它就是线程平安的。总之,看场景,不要脱离场景探讨问题。情理,就是这么一个情理。

最初,再说一次论断:LinkedBlockingQueue 的 stream 遍历的形式,在多线程下是有肯定问题的,可能会呈现死循环。

正文完
 0