然后在凌晨 2 点 57 分(这个时间点,大佬都是不用睡觉的吗?还是说刚修完福报,下班了), max 回复到:
我不敢相信 CLQ 使用起来会有这样的问题,他们至少应该在 API 文档里面说明一下。
这里的他们,应该指的是 JDK 团队的成员,特指 Doug Lea,毕竟是他老爷子的作品。
为什么没有在 API 文档里面说明呢?
因为他们自己也不知道有这个 BUG 啊。
Greg 连着回复了两条,并且直接指出了解决方案:
问题的原因是 remove 方法的源码里面,有上图中标号为 ① 的这样一行代码。
这行代码会去取消被移除的这个 node (其值已经被替换为 null)和 list 之间的链接,然后可以让 GC 回收这个 node。
但是,当集合里面只有一个元素的时候, next != null 这个判断是不成立的。
所以就会出现这个需要移除的节点已经被置为 null 了,但却没有取消和队列之间的连接,导致 GC 线程不会回收这个节点。
他给出的解决方案也很简单,就是标号为②、③的地方。总之,只需要让代码执行 pred.casNext 方法就行。
总之一句话,导致内存泄漏的原因是一个被置为 null 的 node,由于代码问题,导致该 node 节点,既不会被使用,也不会被 GC 回收掉。
如果你还没理解到这个 BUG 的原因,说明你对 CLQ 这个队列的结构还不太清晰。
那么我建议你读一下《Java并发编程的艺术》这一本书,里面有一小节专门讲这个队列的,图文并茂,写的还是非常清晰。
这个 BUG 在 jetty 里面的来龙去脉算是说清楚了。
然后,我们再回到 JDK BUG 的这个链接中去:
他这里写的原因就是我前面说的原因,没有 unlink,所以不能被回收。
而且他说到:这个 BUG 在最新的JDK 7、8和9版本中都存在。
他说的最新是指截止这个 BUG 被提出来之前:
Demo跑起来
这一小节里面,我们跑一下 Greg 给的那个修复 Demo,亲手去摸一下这个 BUG 的样子。
https://bugs.eclipse.org/bugs/attachment.cgi?id=256704
你可以打开上面那个链接,直接复制粘贴到你的 IDEA 里面去:
注意第 13 行,因为 Greg 给的是修复 Demo,所以用的是 ConcurrentHashSet,由于我们要演示这个bug,所以使用 CLQ。
这个 Demo 就是在死循环里面调用 queue 的 add(obj) 和 remove(obj) 方法。每循环 10000 次,就打印出时间间隔、队列大小、最大内存、剩余内存、总内存的值。
最终运行起来的效果是这样的(JDK 版本是 1.7.0_71):
可以看到每次打印 duration 这个时间间隔是越来越大,队列大小始终为 1。
后面三个内存相关的参数可以先不关心,下一小节我们用图形化工具来看。
你知道上面这个程序,到我写文章写到这里的时候,我跑了多久了吗?
61 小时 32 分 53 秒。
最新一次循环 10000 次所需要的时间间隔是 575615ms,快接近 10 分钟:
这就是 Greg 说的:不仅仅是内存泄漏,而且越来越慢。
但是,同样的程序,我用 JDK 1.8.0_212 版本跑的时候情况却是这样的:
时间间隔很稳定,不会随着时间的推移而增加。
说明这个版本是修复了这个 BUG 的,我带大家看看源码:
JDK 1.8.0_212 版本的源码里面,在 CLQ 的 remove(obj) 方法的 502 行末尾注释了一个 unlink。
官方的修复方法可以看这里:
http://hg.openjdk.java.net/jdk8u/jdk8u-dev/jdk/rev/8efe549f3c87
改动比较多,但是原理还是和之前分析的一样:
我仅仅在两个 JDK 版本中跑过示例代码。
在 JDK 1.8.0_212 没有发现内存泄漏的问题,我看了对应的 remove(obj) 方法的源码确实是修复了。
在 JDK 1.7.0_71 中可以看到内存泄漏的问题。
unlink,一个简简单单的词,背后原来藏了这么多故事。
jconsole、VisualVM、jmc
既然都说到内存泄漏了,那必须得介绍几个可视化的故障排除工具。
前面说了,这个程序跑了 61 个小时了,给大家看一下这个时间段里面堆内存的使用情况:
可以看到整个堆内存的使用量是一个明显的、缓慢的上升趋势。
上面这个图就是来自 jconsole。
结合程序,通过图片我们可以分析出,这种情况一定是内存泄漏了,这是一个非常经典的内存泄漏的走势。
接下来,我们再看一下 jmc 的监控情况:
上面展示的是已经使用的堆内存的大小,走势和 jconsole 的走势一样。
然后再看看 VisualVM 的图:
VisualVM 的图,我不知道怎么看整个运行了 60 多小时的走势图,但是从上面的图也是能看出是有上升趋势的。
在 VisualVM 里面,我们可以直接 Dump 堆,然后进行分析:
可以清楚的看到, CLQ 的 Node 的大小占据了 94.2%。
但是,从我们的程序来看,我们根本就没有用到这么多 Node。我们只是用了一个而已。
你说,这不是内存泄漏是什么。
内存泄漏最终会导致 OOM。
所以当发生 OOM 的时候,我们需要分析是不是有内存泄漏。也就是看内存里面的对象到底应不应该存活,如果都应该存活那就不是内存泄漏,是内存不足了。需要检查一下 JVM 的参数配置(-Xmx/-Xms),根据机器内存情况,判断是否还能再调大一点。
同时,也需要检查一下代码,是否存在生命周期过程的对象,是否有数据结构使用不合理的地方,尽量减少程序运行期的内存消耗。
我们可以通过把堆内存设置的小一点,来模拟一下内存泄漏导致的 OOM。
还是用之前的测试案例,但是我们指定 -Xmx 为 20m,即最大可用的堆大小为 20m。
然后把代码跑起来,同时通过 VisualVM 、jconsole、jmc 这三个工具监控起来,为了我们有足够的时候准备好检测工具,我在第 8 行加入休眠代码,其他的代码和之前的一样:
加入 -Xmx20m 参数:
运行起来之后,我们同时通过工具来查看内存变化,下面三个图从上到下的工具分别是 VisualVM、jconsole、jmc:
从图片的走势来看,和我们之前分析的是一样的,内存一直在增长。