概述
本周有个同事过来咨询一个比较诡异的 gc 问题,大概现象是,系统一直在做 cms gc,但是老生代一直不降下去,但是执行一次 jmap -histo:live 之后,也就是主动触发一次 full gc 之后,通过 jstat -gcutil 来看老生代一下就降下去了,初看下理论上不太可能,因为 full gc 也会对 old 做回收,于是我要同事针对他们的场景写了一个简单的 demo 出来,然后果然还真能重现,不过他的 demo 设置的 Heap 有 32G,于是我通过慢慢调整,最终在很小的内存下也能重现出来
Demo
测试代码如下:
正如我上面注释里写的 JVM 参数,控制新生代 200M,老生代 300M,老生代使用率达到 90% 的时候触发 CMS GC,大家可以跑跑看,这种情况下会发现不断做 CMS GC,但是老生代就是不降下去,但是只要你主动触发一次 Full GC,老生代立马就会回收。
当 allocateMemory 方法执行完之后,期待的结果是 gc 之后 List 及里面的 byte 数组都应该被回收掉,可是事实并不是这样的
初步定位
这段代码非常简单,我翻来覆去地看着这段代码,试图想改变点什么,能让问题出现峰回路转,我不断地控制 for 循环的次数和每次分配的内存大小,最终我将目标转移到那个 ArrayList 上,List 里有个数组,在 add 过程中如果发现数组不够了,于是会进行扩容,那扩容就是创建新的数组,将老的对象放到新数组里,那我试想要是不做扩容会不会有问题?于是我开始调整 ArrayList 的初始化大小,当我调到一定大小,保证在 add 过程中不会做扩容,问题真出现了反转,居然能正常回收了,比如上面的 demo,将数组长度设置为 len,那结果就完全不一样了,老生代很快就被回收了
那目标能锁定到数组扩容了
数组扩容
ArrayList 里的数组扩容,使用的是 System.arrayCopy 调用,这是一个 native 方法,在 java 层面创建一个新的长度的数组,然后将老数组和新数组都传进去,在 native 里将老数组里的元素指针拷贝到新数组里,其实做的是浅拷贝,反复看 native 这块实现,也基本解释不通那个现象,一度怀疑我对 GC 的理解了,是不是有哪些细节没有注意到。
经过我内存 dump 分析,发现上面 Demo 里的 List 对象确实被回收了,但是 List 里的数组没有被回收,这个数组里的 byte 数组都没有被回收
原来是这个鬼
带着百思不得其解的疑惑和我们组同事讨论,看看还有没有其他可能的没考虑到疑惑点,开始也都觉得疑惑,后来传胜突然想到会不会是存在跨代引用的问题,于是回过来仔细再想想每个步骤,好像还真有可能,因为传给 System.arrayCopy 的新数组是在 java 层面构建传进来的,在新生代分配的可能性最大,这样再加上拷贝仅仅是浅拷贝,那么老生代里的 byte 数组因为存在新生代里新数组的引用,那仅仅做 CMS GC 就不可能回收这些老生代的对象了,因为 CMS GC 的一个 gc root 就是新生代里的对象
那何解
至此终于抓出了那个鬼,于是想应对策略,既然这样,只要保证在 cms gc 回收 old 之前做一次 ygc 就能保证新生代里的那个新数组被回收而没有指向老生代那些 byte 数组,那么这些数组就能正常被 cms gc 回收了,所以加上 -XX:+CMSScavengeBeforeRemark 即可解此问题。
一起来学习吧:
PerfMa KO 系列课之 JVM 参数【Memory 篇】
实战:OOM 后我如何分析解决的