JDK的BUG致使的内存溢出!反正我是没想到还能有续集。

https://mp.weixin.qq.com/s/jmAerkEPyp9CIpDIAAPTkg

image.png

荒腔走板


你们好,我是 why,欢迎来到我连续周更优质原创文章的第 57 篇。java


老规矩,先来一个简短的荒腔走板,给冰冷的技术文注入一丝色彩。node


上面这个图是个人第一台笔记本电脑,从上面的标签能够看到,是购于 2012 年 6 月 10 日,那一天是高考完的次日。
程序员


我读高三的时候妈妈承诺我,考完以后就能够拥有一台本身的笔记本电脑。那个时候妈妈和我都不太懂电脑,我记得很清楚,是姑父开车带我去买的,花了 3000 多。web


一晃 8 年多的时间过去了,这台电脑也跟着我从老家到成都再到北京再回成都。
算法


其实我大三实习的时候就想本身换个 Mac,而后一想可能要存钱去北京,因而想着再等等吧。
bootstrap


到北京后,发现每月剩下的钱很少很多,这电脑也能用,那能省则省了吧,再等等吧。安全


这一等,就是 5 年。说来惭愧,我到如今都还没拥有一台本身的 Mac。微信


上周的监控图就是用这台电脑跑的,我不是说预计还须要跑 19 天才可能会看到 OOM 吗。
数据结构


其实我挺担忧跑着跑着就蓝屏了,可是发完文章后不到一小时,小区就停电了。多线程


因而,这场停电,终止了这个监控,也拯救了这个电脑。从某种角度上来讲,这场停电也拯救了个人钱包。


好了,说回文章。

BUG究竟是怎么修复的?


上周《个人程序跑了60多小时,就是为了让你看一眼JDK的BUG致使的内存泄漏》这篇文章发布后。


有好几个同窗都来问了我一些相关的问题。


好比这样的:


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1写上篇文章的时候,个人侧重点主要在 ConcurrentLinkedQueue(下文统一缩写 CLQ)存在过一个可能会致使内存泄漏的 BUG ,这个 BUG 的前因后果是怎样的,以及怎么经过可视化工具让咱们感觉到这个 BUG 的存在。

其实对于 BUG 在源码里面具体是怎样体现的,以及修改以后为何就不会内存泄漏了并无进行详细的解读。

开始的想法是,告诉你们有这个事情,若是有兴趣的能够直接去调试分析一下。
可是有的同窗反映调试也看不明白啊。一个方法,在断点处一脸懵逼的进来,又一脸懵逼的出去。640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
苦思冥想没搞清楚,而后就来问我。
我发现一两句话也说不太清楚,因而把 Debug 的关键截图放到文档里面配以文字说明,才勉强能说的比较清楚一点,也不知道这位同窗看明白了没。
但就拿这个文档来讲:真的是暖男石锤了。640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
因此,文本主要是分享一个我本身调试的奇淫技巧,最后再作个 remove 方法的解读。
可是若是要深入理解 CLQ 这个十分优秀、十分有想法的基于非阻塞方法实现的线程安全的队列,你们须要去看的是 offer、poll 方法。而后一个状况一个状况的去分析,本身拿着草稿本在上面写写画画。
我也妄想过经过这篇文章给大家把它讲的明明白白的,后来我发现这对我而言难度有点大。
最后再说一下若是你用 IDEA 调试时,大几率会碰到的一个巨坑。
好了,先把以前的这个坑给填上。
修复以后的 JDK8 到底怎么就避免了内存泄漏的问题了?


自定义CLQ



咱们先看一下 CLQ 的数据结构。

CLQ 的 Node 里面有一个 item(放的是存储的对象),还有一个 next 节点(指向的是当前 Node 的下一个节点)。

从数据结构来看,也知道这是一个单向链表了。640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1Java 程序员,就靠日志活着的。
因此我想经过日志的方式直接输出链表结构,这应该是最简单的演示方式了。
为了经过日志体现出数据变化的过程,咱们先来一个自定义的 CLQ。
方法很简单,直接把 JDK 8 的 CLQ 复制出来一份,而后修更名称就能够,咱们这里的名称是 whyConcurrentLinkedQueue(下文简写为 WhyCLQ):
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
搞一个测试用例跑一下:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
而后你会发现报错了:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
这个错误是关于 Unsafe 操做的,在代码的第 931 行:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
getUnsafe 方法的源码是这样的:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
而这个方法里面就是判断当前类的类加载器是否是为 null:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
这里抛出异常了,说明不是 null。也就会说当前类的加载器不是启动类加载器 BootstrapClassLoader。
咱们知道,rt.jar 包下的类是须要 bootstrap 类加载器加载的。
诶,巧了。这个类就位于 rt.jar 包里面:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
来,再复习一下双亲委派机制:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
若是咱们自定义了一个 CLQ ,那么这个类的类加载器是什么类加载器呢?

咱们验证一下:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1从 Debug 的截图能够看出当前类 WhyCLQ 的类加载器是 AppClassLoader。其父类加载器(parent)是 ExtClassLoader 类加载器。

不是 BootstrapClassLoader ,因此咱们这里抛出了异常。
在介绍怎么解决这个异常以前,先简单的说一下 Unsafe。
这个类名称一听就是很是牛逼的。Unsafe,不安全。
感受像是在钓鱼执法,表面上疯狂的在那给你摆手,说:别靠近我,别使用我,我很不安全。
实际上心里是这样的:640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
做为一个正常的男人,看到这个东西谁不想去调用一下,看看究竟是怎么不安全的呢?

咱们看一下《美团点评 2019 技术年货》里面是怎么描述的:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
同时,看一下它相关的 API:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
因为 Unsafe 不是文本重点,我就不展开说明了。若是你对 Unsafe 这个类掌握的还不深入,建议你好好了解一下。若是你清楚的知道这个类的威力,在某些场景下能够达到意想不到的效果,它就是一枚银弹般的存在。

《美团点评 2019 技术年货》里面有一小节是专门分享这个类的,有兴趣的朋友能够查看文末获取方式。
好了,知道抛出问题的缘由了,咱们自定义的 CLQ 就不能用了吗?
固然不是,别忘了,咱们还有极其“流氓”的反射方法可使用:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
这样,咱们自定义的 CLQ 就可使用了。免费附赠你一个 Unsafe 的知识,不用谢。
640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1接下来咱们就能够在不修改源码逻辑的状况下,加入输出语句以方便调试。

好比咱们须要这样清晰的输出日志:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
因此在咱们自定义的 CLQ 里面加一个打印链表结构的方法640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
而后给咱们的 remove 方法增长一个循环次数的入参,并在操做队列以前和以后调用咱们打印链表结构的方法,就像下面这样式儿的:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
其中的 printWhyCLQ 方法以下:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
有的朋友确定注意到了,我这个方法名称是 removeJDK8 。这个方法里面的逻辑就是 JDK8 的 CLQ 的 remove 方法。
你能够这么理解:我就是把 JDK8 的 CLQ 的 remove 方法的名称变成了 removeJDK8 
为何这样命名呢?
由于我要把 JDK7 对应的 remove 方法直接拿过来,放在同一个类里面方便调用,操做和上面的 JDK8 方法一致:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
这样,咱们就有一个自定义的 CLQ,里面包含 JDK7 和 JDK8 对应的 CLQ 的 remove 方法。
万事俱备,就差个 Demo 跑起来了:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
在下面一小节中,咱们对比一下修复前(JDK7)和修复后(JDK8)的输出日志,一切就会很是的明了。


修复前 vs 修复后



咱们把 Demo 跑起来,看输出结果,进行对比:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
你仔细品这个输出结果,还须要我给你分析个啥玩意?
640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1和 JDK8 的方法比起来,上面 JDK7 的方法执行完成后链表长度都长了一些。

JDK8 的方法执行完成后,链表长度最长也没有超过 3 个。

咱们再看 JDK7,我拿一次循环出来分析:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
这就是我上篇文章中说到的:一个节点中的 item 对象被置为 null 了,可是该节点,因为代码问题,并无从链表中取下来,致使不能被回收。
而上篇文章中提到的“愈来愈慢”,因为能够直接的看到链表结构了,因此也很好解释了:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
好比,我把 Demo 中 for 循环的次数修改成 100,运行以后,咱们看最后一次循环的结果为:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
remove 方法是从链表的头结点开始遍历链表,而咱们每次须要移除的实际上是最后一个节点,因为链表愈来愈长,因此遍历链表的时间愈来愈长。
因此致使咱们上一篇的案例中每循环 10000 次,时间都会增长。

image.png

源码导读



接下来咱们看一下 JDK8 的源码中的 remove(obj) 方法究竟是怎么样工做的。
这个方法的目的就是从头结点开始遍历链表,而后判断每一个 Node 里面的 item 是否是须要被删除的这个,若是是则删除,若是不是则继续遍历。

我想了好久这个地方怎么能把代码的执行流程说清楚呢?
除了 Debug 以外,由于 Debug 须要截很是多的图才可能说的清楚。
只有疯狂的输出日志了。

咱们先看简单的分析一下 JDK8 对应的源码:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
490 行是在对象被移除以前,咱们能够在这里加一行输出语句打印当前的链表结构。

505 行是在对象被移除以后,咱们能够在这里加一行输出语句打印删除操做完成以后的链表结构。

纵观整个方法,只有我标注的两个地方会去修改链表结构。因此,咱们分别在这两处地方的先后输出相关日志,而后分析日志,就能够知道这个方法的工做流程了。
知道它的工做流程了,再返回去看代码,那还不是易如反掌的事儿?

这就是传说中的蛇皮走位,反向操做。

image.png

因此,按照咱们上面的分析,在自定义的 CLQ 里面加入输出语句以下:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
其中的 sortName 方法是为了把 java.lang.Object@xxx 截取为 @xxx,精简输出:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
上面的 removeJDK8 方法除了输出语句以外,其余的代码逻辑和 JDK8 的对应方法如出一辙。
咱们仍是用这个示例代码:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
跑起来分析日志:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
日志不少,可是细细分析下来流程很是的清晰,你能够在草稿本上画一画。
我带着你们分析前两个循环,一共 10 行日志,咱们一行行的分析,注意咱们下面画的图仅体现了 node 里面的 item 元素:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【0】次循环,【移除以前】,链表item对象指向 = @723279cf->@10f87f48->


从测试代码中能够知道,被删除以前咱们确实是有两个节点:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
因此根据日志画图以下:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【0】次循环,【修改节点item为null】被修改的p节点的item为(@10f87f48),即须要被删除的节点


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【0】次循环,【修改节点item为null以后】,链表item对象指向 = @723279cf->null->


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【0】次循环,【移除以后】,链表item对象指向 = @723279cf->null->


其实移除以后,就是把节点的 item 修改成 null 以后,因此结构和上面仍是同样的:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
第【0】次循环就分析完了。能够看到如今的链表里面有一个 item 为 null 的元素,它还在链上,因此不会被回收。

接下来,咱们分析一下第【1】次循环。


第【1】次循环,【移除以前】,链表item对象指向 = @723279cf->null->@10f87f48->


因为进入下次循环,因此会先执行 add 方法,因此如今的链表结构变成了这样:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【1】次循环,【处理null节点】把item为(@723279cf)的pred节点的next节点,从item为(null)的p节点修改成item为(@10f87f48)的next节点



pred 节点里面的 item 就是 @723297cf。
p 节点里面的 item 就是 null。

next 节点里面的 item 就是 @10f87f48。640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【1】次循环,【处理null节点以后】,链表item对象指向 = @723279cf->@10f87f48->


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【1】次循环,【修改节点item为null】被修改的p节点的item为(@10f87f48),即须要被删除的节点


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【1】次循环,【修改节点item为null以后】,链表item对象指向 = @723279cf->null->


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


第【1】次循环,【移除以后】,链表item对象指向 = @723279cf->null->


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
第【1】次循环完成后又回到了第【0】次循环完后的样子。

中间的那个 item 为 null 的节点去哪了呢?

由于这是个单向链表,从头节点已经不能遍历到这个节点了。因此等待它的命运将是被回收,因此也就不会内存溢出了。
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1到这里,我以为这个问题算是回答清楚了吧?
关于 remove(obj) 我就分享到这里。
实话实话,这个方法对于 CLQ 并非很是的重要,咱们通常使用场景也比较少。
我写这节主要是两个目的。
一是回答读者的提问,由于毕竟是看了个人文章引起出来的问题,我有义务回答。
二是分享一下这种本身 copy 一个类出来,而后只加入输出语句的调试方式。这个调试方法老读者确定知道了,我在写 ArrayList 的时候也用过,写 Dubbo 负载均衡算法的时候也用过。当你被一步步 debug 带晕的时候,你能够试一试这种方式,先总体再局部。好比本文的 CLQ,多线程调试 CLQ 的状况下,我以为日志的输出对于你理解它的精髓很是的有帮助。
仍是以前说过的,若是要深入理解 CLQ 这个十分优秀、十分有想法的基于非阻塞方法实现的线程安全的队列,你们须要去看的是 offer、poll 方法。而后一个状况一个状况的去分析,看看它是怎么避免频繁 CAS 的,本身拿着草稿本在上面写写画画。
我也妄想过经过这篇文章给大家把它讲的明明白白的,后来我发现这对我而言难度有点大。
我在这里给你们指个路,看哪几种状况:


  1. 单线程下的 offer。

  2. 单线程下的 poll。

  3. 多线程下的一个线程 offer ,一个线程 poll。offer 比 poll 快。

  4. 多线程下的一个线程 offer ,一个线程 poll。offer 比 poll 慢。



就这四种状况,玩去吧。
一种很是优秀的思想,很是牛逼的实现,我但愿你能静下心来坚持过半小时。640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


IDEA DEBUG 模式的巨坑



640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
看了我上面的介绍,准备静下心来看第一种状况:单线程下的 offer。
若是你用 IDEA 的 Debug 调试 CLQ 的 offer 方法,半个小时后你心态应该就会炸裂:640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
你有可能会碰到的一个巨坑,好比咱们的测试代码是这样的:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1很是简单,在队列里面添加一个元素。

因为初始化的状况下 head=tail=new Node<E>(null):640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
因此在 add 方法被调用以后的链表结构里面的 item 指向应该是这样的:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
咱们在 offer 方法里面加入几个输出语句:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
执行以后的日志是这样的:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
为何最后一行输出,【offer以后】输出的日志不是 null->@723279cf 呢?

由于这个方法里面会调用 first 方法,获取真正的头节点,即 item 不为 null 的节点:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
到这里都一切正常。可是,当你用 debug 模式操做的时候就不太同样了:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
头节点的 item 不为 null 了!而头节点的下一个节点为 null,因此抛出空指针异常。
单线程的状况下代码直接运行的结果和 Debug 运行的结果不一致!这不是遇到鬼了吗。
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
我在网上查了一圈,发现遇到鬼的网友还很多。
最终找到了这个地方:


https://stackoverflow.com/questions/55889152/why-my-object-has-been-changed-by-intellij-ideas-debugger-soundlessly



这个哥们遇到的问题和咱们如出一辙:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
这个问题下面只有一个回答:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
你知道回答这个问题的哥们是谁吗?640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1IDEA 的产品经理,献上个人 respect。
640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
最后的解决方案就是关闭 IDEA 的这两个配置:
640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1
由于 IDEA 在 Debug 模式下会主动的帮咱们调用一次 toString 方法,而 toString 方法里面,会去调用迭代器。

而 CLQ 的迭代器,会触发 first 方法,这个里面和以前说的,会修改 head 元素:image.png

一切,都真相大白了。

以前,我认为是玄学。而如今,没有什么是玄学,咱们要相信科学。
我身边也有朋友碰到过这个问题,若是不知道这个坑,很是的抠脑袋,很容易就“怀疑人生”640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

☜ 滑 动 查 看 更 多 图 片


最后说一句



文章中提到的《美团点评 2019 技术年货》是公众号【美团技术团队 2019 年出品的后台技术文章集合,内容很是的丰富:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1若是你有兴趣,能够在公众号后台回复关键字【java】便可得到 PDF 的下载连接。

若是你以为麻烦了,那你也能够直接加我微信,备注【PDF】,我直接发给你:640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1放心,若是你不主动找我聊天,我也是不会主动和你搭话的。静静的躺在朋友圈里,作个点赞之交。毕竟说出来你可能不信,我也是有轻微的社交恐惧症的。
最后,你们安排个“一键三连”(转发、在看、点赞)吧,周更很累的,不要白嫖我,须要一点正反馈。640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1才疏学浅,不免会有纰漏,若是你发现了错误的地方,因为本号没有留言功能,还请你在后台留言指出来,我对其加以修改。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。


640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


我是 why,一个被代码耽误的文学创做者,不是大佬,可是喜欢分享,是一个又暖又有料的四川好男人。

还有,重要的事情说三遍:欢迎关注我呀。欢迎关注我呀。欢迎关注我呀。640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1