前段时间在 RocketMQ 的 ISSUE 里面冲浪的时候,看到一个 pr,虽说是在 RocketMQ 的地盘上发现的,但是这个玩意吧,其实和 RocketMQ 没有任何关系。

纯纯的就是 JDK 的一个 BUG。

我先问你一个问题:LinkedBlockingQueue 这个玩意是线程安全的吗?

这都是老八股文了,你要是不能脱口而出,应该是要挨板子的。

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

但是在 RocketMQ 的某个场景下,居然稳定复现了 LinkedBlockingQueue 线程不安全的情况。

先说结论: LinkedBlockingQueue 的 stream 遍历的方式,在多线程下是有一定问题的,可能会出现死循环。

老有意思了,这篇文章带大家盘一盘。

搞个Demo

Demo 其实都不用我搞了,前面提到的 pr 的链接是这个:

https://github.com/apache/rocketmq/pull/3509

在这个链接里面,前面围绕着 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,所以跳不出这个循环。

但是从 while 循环体里面的逻辑来看,里面的 current 节点是会发生变化的:

current = current.next;

来,结合这目前有的这几个条件,我来细嗦一下。

  • LinkedBlockingQueue 的数据结果是链表。

  • 在 tryAdvance 方法里面出现了死循环,说明循环条件 current=null 一直是 true。

  • 但是循环体里面有获取下一节点的动作,current = current.next。

综上可得,当前这个链表中有一个节点是这样的:

只有这样,才会同时满足这两个条件:

  • current=null

  • current.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 列表里面:

https://bugs.openjdk.org/browse/JDK-8171051

在 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 遍历的方式,在多线程下是有一定问题的,可能会出现死循环。

好了,那本文的技术部分就到这里啦。

下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。

你要不喜欢,退出之前记得文末点个“在看”哦。

荒腔走板

周五的晚上去看了《侏罗纪世界3》,反正就是真的很一般。

看完之后已经是晚上22点了,想着是周五的晚上放纵一下,准备在电影院附近找个烧烤吃,结果发现大家都是想着周五放肆一下,烧烤店门口居然排着队,就打消了撸串的想法。

但是回家的路上路过一家推着小车卖的烧烤,那个时候已经是深夜了,小车就在路边孤零零的亮着灯,远远的就看见“烧烤”两个字。

路边小摊也能解烧烤的馋,于是我们点了一些烧烤打包回家吃。

经营烧烤车的是一对中年夫妻,看起来很年轻的样子。我们点完烤串,等待制作的过程中,他们在旁若无人的聊天。

老板对老板娘说:晚上没吃饱呀,我觉得有点饿了,去给我买一个面包吧。要奶油的那种。

隔了一分钟老板娘回来了说:我没有买到有奶油的,但是这个也好吃,你尝尝。

然后老板烤串,老板娘喂他吃面包。我就在等烧烤的过程中,莫名其妙的被喂了一顿狗粮。

他们边吃边拉着家常,老板娘说:我今天在网上学到一招,烧烤应该怎么怎么做。

老板说:我是专业的,我心里有数。

老板娘说:我觉得我最近有点胖了啊。

老板说:别瞎说,没有胖。

老板娘说:你要喝口水不,吃面包是不是有点干?

老板说:可以喝一口,要是能喝啤酒就好了。

老板娘说:今天生意挺好,一会收摊了,给你买一瓶。你自己给自己烤烧烤。

...

我在其他的类似推着小车卖东西的地方,如果是两口子,大多时候是沉默,或者是只会听到他们在聊生意上的事情。

但是烧烤摊前面的这对夫妻,他们就在烧烤的烟火气息里面,旁若无人的聊着这些有的没的,一股不言而喻的生活的味道围绕着他们。

夜晚的风一阵吹过,烧烤的烟气全部向我扑面而来,那一刻我站在这份烟火气里面,最直观的感受是:生活多美好,人间多值得。

喜提JDK的BUG一枚!多线程的情况下请谨慎使用这个类的stream遍历。相关推荐

  1. java socket 回调函数_请问Java网络编程如何在不使用多线程的情况下实现异步返回?...

    我指的是在不使用多线程的情况下进行并发处理 具体的情况是,在不使用多线程的情况下,服务器侦听某个端口,在有连接进来的时候会调用某个函数对此连接进行处理,但是由于处理的过程可能会比较长,为了不让后面连接 ...

  2. 【Java】JDK 7 HashMap 头插法在并发情况下的成环问题

    CONTENT 问题描述 成因详解 总结 Reference 问题描述 JDK 7 的 HashMap 解决冲突用的是拉链法,在拉链的时候用的是头插,每次在链表的头部插入新元素.resize() 的时 ...

  3. tomcat7.0支持什么版本的jdk_恭喜你喜提JDK,那你知道JDK是什么吗?先来看看吧

    点击蓝字 关注一行 JDK   大家都知道电脑的操作系统是由汇编和C语言写出,因此操作系统无法直接识别其他语言.这时我们就需要为我们写的Java程序配备一名翻译官 ----- 编译环境,将Java程序 ...

  4. 我给 Apache 顶级项目提了个 Bug

    作者 | 骆俊武 来源 | IT人的职场进阶(ID:BestITer) 这篇文章记录了给 Apache 顶级项目 - 分库分表中间件 ShardingSphere 提交 Bug 的历程. 说实话,这是 ...

  5. 我给Apache顶级项目提了个Bug

    这篇文章记录了给 Apache 顶级项目 - 分库分表中间件 ShardingSphere 提交 Bug 的历程. 说实话,这是一次比较曲折的 Bug 跟踪之旅.10月28日,我们在 GitHub 上 ...

  6. 给 JDK 官方提了一个 Bug,结果...

    图 by:石头@北京-望京 关于作者:程序猿石头(ID: tangleithu),现任阿里巴巴技术专家,清华学渣,前大疆后端 Leader. 背景 分享一下之前踩的一个坑,背景是这样的: 我们的项目依 ...

  7. 雷军喜提第四家上市公司;梨视频 App 被全网下架;Flutter 1.17 稳定版发布 | 极客头条...

    整理 | 屠敏 头图 | CSDN 下载自东方 IC 快来收听极客头条音频版吧,智能播报由标贝科技提供技术支持. 「极客头条」-- 技术人员的新闻圈! CSDN 的读者朋友们早上好哇,「极客头条」来啦 ...

  8. 【算法题解】2022年第四届河南省CCPC大学生程序设计竞赛(喜提银牌)

    文章目录 A. Mocha 上小班啦 E. Serval 的俳句 F. 集合之和 G. Mocha 上大班啦 H. 旋转水管 比赛题目已上传到CF:2022 CCPC Henan Provincial ...

  9. JustAuthPlus(JAP)喜提 Gitee GVP(最有价值项目)称号!

    JustAuthPlus(JAP)喜提 Gitee GVP(最有价值项目)称号! [Gitee]您申请的项目 jap 已入选本年度码云最有价值开源项目!领取项目专属奖杯详见码云站内私信 感谢朋友们的支 ...

  10. AI修复100年前晚清影像喜提热搜,这两大算法立功了

    整理 | 夕颜 来源 | CSDN(ID:CSDNnews) 昨天,一条"100 年前北京晚清的影像"喜提热搜,博主用 AI 技术修复了一段 10 多分钟的古董视频,使得 100 ...

最新文章

  1. ubuntu下eclipse中键盘失灵
  2. .net core HttpClient 使用之消息管道解析(二)
  3. Centos7利用fpm制作rpm包(fpm安装及使用)
  4. 400是什么错误_Spring Boot的REST API错误处理
  5. vim 基本操作总结
  6. [考试反思]0813NOIP模拟测试20
  7. 对Java回调函数的理解
  8. [c++] WINAPI
  9. 安装ADOBE READER时无法将数值写入键
  10. [技术脑洞] 如果把14亿中国人拉到一个微信群里技术上能实现吗?
  11. 增量型编码器与绝对值编码器
  12. 基于药效团的药物设计(Pharmacophore Construction)
  13. 如何成为全栈工程师?
  14. 【入门数据分析】淘宝用户行为分析
  15. WMS LICENSE PLATE NUMBERS LPN CONTEXT
  16. Kaprekar constant(卡普雷卡尔黑洞)
  17. 关于——Git命令大全
  18. 免费苹果CMS影视站采集器影视站SEO优化教程
  19. JAVA当中的Document
  20. unix与linux,windowsNT的区别

热门文章

  1. Mac 禁止插上手机自动启动iTunes
  2. 【模电】0013 反馈放大电路基础
  3. 基于SPSS的正态性检验方法汇总——以个股收益率为例(超详细)
  4. 翻译: Revisiting Contrastive Methods for Unsupervised Learning of Visual Representations
  5. 11个LOGO设计灵感网站推荐,帮你提高LOGO设计工作效率
  6. 科研第一步:怎样获得好的idea?!
  7. 1.3.1 计算机的主要性能指标 (机器字长、数据通路带宽、主存容量、运算速度、吞吐量、响应时间、主频和时钟周期、CPI、CPU执行时间、MIPS、MFLOPS、GFLOPS、TFLOPS)
  8. c语言setw,在C++中,setw(int n)
  9. 除了专业技能,Web前端工程师需要具备哪些基本素质?
  10. PC机并口、并口扩展卡使用EPP模式通信,ECP SPP等