今天要跟大家分享的内容是关于YGC(YoungGC)的。这是我最近遇到的一个案例。希望能与大家分享排错思路。如果你以后遇到类似的问题,可以直接作为经验排查。之前写过几篇YGC的文章,可能有些人已经看过了,没看过的可以去看看。这两个坑这里就不多说了,大家可以直接作为经验使用。Java堆分为新生代和老年代。YGC其实就是新生代的垃圾回收。对象首先在新生代中分配。因此,当新生代中的内存不够分配时,就会触发垃圾回收。在正常情况下,它可能会被触发。一个YGC即可解决问题,正常分配。当然,在极端情况下,可能需要进行一次大清洗,回收整个堆,也就是我们所说的FullGC。如果发生这种情况,那将是一场悲剧。这里再提一下,YGC还会STW(stoptheworld),即暂停整个应用。不要以为YGC频繁出现不是问题。说实话,我不太喜欢排查YGC的问题,因为YGC的日志太简单了。一般情况下,我只能知道内存从新生代变成了多少,用了多长时间,并没有其他信息。所以当有人来问为什么我的程序YGC越来越长的时候,我其实是抗拒的,但我也没有办法,只能尽力帮助他们解决,包括上面提到的两个问题,也是花费了很多我才发现的能量,希望大家珍惜。..有时候越是想逃离,越是会找到你。说实话,最近YGC的问题找我的挺多的,但是很多都是我踩过的坑,所以我可以帮他们顺利解决,但是今天要说的问题是以前没遇到过的,而且是一个全新的问题,所以耗费了我很大的精力,又因为其他问题耽误了几天。这个问题最后排查其实是JVM本身设计的缺陷。改天我也会参考openjdk社区和大家讨论这个设计,希望能彻底解决这个问题。这个问题的现象也很明显,就是发现YGC的时间越来越长,从20ms慢慢增加到100ms+,甚至一直在上升。不过,这个成长过程还是相当缓慢的。其实我个人认为YGC的时间是几十毫秒,不过这次正好赶上年中大促,所以大家对时间比较敏感,所以才接手调查这起案子。首先排除了之前遇到的几种情况,然后让同事加了一个我们alijdk特有的参数,可以打印YGC过程中各个阶段的耗时情况。不幸的是,问题没有被发现,因为我们错过了。有些点已经改变,导致无法直接定位。所以对没有追踪到的逻辑表示怀疑。我首先怀疑是对引用的处理,于是请同事加了参数-XX:+PrintReferenceGC。这个参数会打印各种引用的处理时间,大致如下:点击下面从当时的日志中,我发现一个现象,就是随着YGC时间的增加,JNI弱引用的处理时间也是增加,所以基本定位了YGC直接增长的原因。JNI弱引用到底是什么?大家都知道在Java层面有各种各样的引用,包括SoftReference、WeakReference等,其中WeakReference可以保证在GC时不会阻碍其引用对象的回收。同样,在native代码中,我们也可以做类似的事情,有一个叫做JNIHandles::make_weak_global的方法来实现这个效果。于是开始修改JVM,尝试打印一些信息。不知道大家有没有注意到,我们在dump线程的时候,使用了jstack命令。在最后的输出中,我们会看到类似JNIglobalreferences:328的日志,其实就是打印在这里的。JNI中两个全局引用的总数是_global_handles和_weak_global_handles。所以我就试着把这两种情况分开,看看有多少,所以我改了第一个版本。从修改后的输出来看,_global_handles的引用数基本稳定,但是_weak_global_handles的变化却更加明显。至此,证明了JNIWeakReference的问题,于是想再次修改JVM,打印这些JNIWeakReference具体引用了哪些对象。我每次执行jstack的时候都会顺便把那些对象打印出来。当然,那时候是为了性能。毕竟程序还在线上运行,不敢多动。比如大量的输出日志是不可控的,那就麻烦了,所以使用jstack来手动触发这个逻辑。从输出来看,看到了很多下面的内容:于是问同事有没有很多Java调用JavaScript的,被告知确实用到了。问题基本找到了。我很快会请同事写下他们的用法。一个简单的demo出来重现问题。没想到很快就写出来了,而且真的很容易复现。大体逻辑如下:于是开始调试,最后确认上面的demo和下面的demo完全等价。所以直接运行上面的demo就可以重现问题。JVM参数如下:-Xmx300M-Xms300M-XX:+UseConcMarkSweepGC-XX:+PrintGCDetails-XX:+PrintReferenceGC对了,运行平台是JDK8,JDK6不存在这个问题是因为invokedynamic指令而nashorn在JDK6中是不存在的。上面的demo是不是貌似没问题,但是真的让你的GC越来越慢,调试JVM抓到如下类似的栈。JDK层面的栈如下:topresolve方法是一个native方法,可以直接调用到上面提到的JNIHandles::make_weak_global方法。JNIHandles::make_weak_global方法实际上创建了一个JNI弱引用。这里稍微描述一下,因为代码太麻烦就不贴了。JVM中有一个数据结构,叫做JNIHandleBlock。前面提到了global_handles和_weak_global_handles,其实它们是一个JNIHandleBlock的链表。可以想象有一个next字段链接到下一个JNIHandleBlock,还有一个长度为32的数组_handle[]。当我们要分配一个JNI弱引用时,相当于在这个JNIHandleBlock中找一个linkedlist空闲位置(那些_handle数组),如果发现每个JNIHandleBlock的_handle数组都满了,就会创建一个新的JNIHandleBlock加入链中。注意这条链可以越长越好,那么问题来了,如果我们上层代码一直触发底层调用JNIHandles::make_weak_global创建JNI弱引用,那是否意味着JNIHandleBlock链会继续下去成长,它会无限成长吗?答案是肯定的,既然有createdJNIWeakReferenceAPI也有销毁JNIWeakReference的API吗?当然它确实存在。可以看到有一个JNIHandles::destroy_weak_global方法。这个实现其实很简单,相当于设计了一个标记来表示这个数组中的值。这个位置可以重复使用。当GC发生时,如果发现坑被标记,则将坑添加到一个free_list中。当我们接下来要分配一个JNIWeakReference时,我们可以有机会从free_list分配一个reuse。但是这个api在什么情况下可以调用呢?实际上这个api只有在类卸载的时候才会被调用。那么卸载什么样的类,也就是调用了MethodHandles.lookup()方法的类,从我们上面的demo来看,就是MHTest本身的主类。从同事给我的demo来看,其实就是jdk.nashorn.internal.runtime.Context这个类,只不过这个类实际上是通过ext_classloader来加载的。也就是说,这个类根本不会被卸载。如果无法卸载,问题就严重了。就是说当GC发生的时候,引用对象的死坑无法清空,这样当我们需要再次分配JNIWeakReference时,就没有机会重用那些坑了,最终的结果就是不断的创建新的JNIHandleBlocks并添加它们到链表中,导致链表越来越长。但是GC会不断的扫描链表,所以当你看到GC的时候,你也会越来越长。那么还有一个问题。如果真的卸载了调用MethodHandles.lookup()的类,这还存在吗?答案是GC时间不会再恶化了,但是之前已经达到的恶化已经无法修复了。所以,这是JVM设计中的一个缺陷。只要Java层面能够触发连续调用JNIHandles::make_weak_global,那么这个问题马上就会重现。其实我也想到了一个解决办法,就是在遍历这些JNIHandleBlock的时候,如果对应的_handle数组都为空,那么就直接回收这个JNIHandleBlock,这样在GC过程中就不会扫描到很多JNIHandleBlock耗时了。至于我同事的问题的解决方法,其实很简单。对于同一个JavaScript脚本,不要每次都调用eval方法。它可以被缓存,这减少了不断触发调用JNIHandles::make_weak_global的需要,避免了JNIHandleBlock增长问题。【本文为专栏作家李嘉鹏原创文章,转载请微信公众号(你个假笨蛋,id:lovestblog)联系作者授权转载】点此查看本作者更多好文
