假笨说-又发现一个导致JVM物理内存消耗大的Bug(已提交Patch)

概述

最近我们公司在帮一个客户查一个JVM的问题(JDK1.8.0_191-b12),发现一个系统老是被OS Kill掉,是内存泄露导致的。在查的过程中,阴差阳错地发现了JVM另外的一个Bug。这个Bug可能会导致大量物理内存被使用,我们已经反馈给了社区,并得到快速反馈,预计在OpenJDK8最新版中发布(JDK11中也存在这个问题)。

PS:用户的那个问题最终也解决了,定位下来算是C2的一个设计缺陷导致大量内存被使用,安全性上没有得到保障。

找出消耗大内存的线程

接下来主要分享下这个BUG的发现过程,先要客户实时跟踪进程的情况,当内存使用明显上升的时候,通过/proc/<pid> /smaps,看到了不少64MB的内存分配,Rss也基本消耗完了。

再通过strace命令跟踪了下系统调用,再回到上面的虚拟地址,我们找到了相关的mmap系统调用

执行mmap的线程是71号线程,接着通过jstack把线程dump出来,找到了对应的线程其实是C2 CompilerThread0

最后再grep了一下strace的输出,果然看到这个线程在大量的进行内存分配,总共有2G多。

经典的64M问题

对于64M的问题,是一个非常经典的问题,在JVM中并没有这种大量分配64M大小的逻辑,因此可以排除JVM特定意义的分配。这其实是glibc里针对malloc函数分配内存的一种机制,glibc从2.10开始提供的一种机制,为了分配内存更加高效,glibc提供了arena的机制,默认情况下在64位下每一个arena的大小是64M,下面是64M的计算逻辑,其中sizeof(long)为8

p2= (char*) MMAP (aligned_heap_area, HEAP_MAX_SIZE, PROT_NONE,MAP_NORESERVE);

一个进程最多能分配的arena个数在64位下是8 * core,32位下是2 * core个

{intn = __get_nprocs ();

if(n >= 1)narenas_limit = NARENAS_FROM_NCORES (n);else/* We have no information about the system. Assume twocores. */narenas_limit = NARENAS_FROM_NCORES (2);}

这种分配机制的好处,主要是应对多线程的环境,为每个核留有几个64M的缓存块,这样线程在分配内存的时候因为没有锁而变得更高效,如果达到上限了就会去慢速的main_arena里分配了。

可以通过设置环境变量MALLOC_ARENA_MAX来设置64M块的个数,当我们设置为1的时候就会发现这些64M的内存块都没有了,然后都集中分配到一个大区域了,也就是main_arena,说明这个参数生效了。

无意的发现

再回过来思考为什么C2线程会出现大于2G的内存消耗的时候,无意中跟踪C2这块代码发现了如下代码可能会导致大量内存消耗,这个代码的位置是nmethod.cpp的nmethod::metadata_do方法,不过这块如果真的发生的话,肯定不是看到C2的线程大量分配,而是看到VMThread这个线程,因为下面这块代码主要是它执行的。

inline CompiledIC* CompiledIC_at(RelocIterator* reloc_iter) {assert(reloc_iter->type() == relocInfo::virtual_call_type ||reloc_iter->type() == relocInfo::opt_virtual_call_type, "wrong reloc. info");CompiledIC* c_ic = newCompiledIC(reloc_iter);c_ic->verify();returnc_ic;}

注意上面的CompiledIC *ic = CompiledIC_at(&iter);这段代码,因为CompiledIC是一个ResourceObj,这种资源会在c heap里分配(malloc),不过他们是和线程进行关联的,假如我们在某处代码声明了ResourceMark,那当执行到这里的时候会标记当前的位置,再接下来线程要分配内存的时候如果线程关联的内存不够用,就会malloc一块插进去并被管理起来,否则会实现内存的复用。当ResourceMark析构函数执行的时候,会将之前的位置还原,后面这个线程如果要分配内存又会从这个位置开始复用内存块。注意这里说的内存块和上面的64M内存块不是一个概念。

因为这段代码在while循环里,因此存在非常多次数的重复调用,这样明明在执行完一次之后可以复用内存的地方并不能复用,而可能会导致大量的内存被不断分配。表现起来可能就是物理内存消耗很大,远大于Xmx。

这个修复办法也很简单,就是在CompiledIC *ic = CompiledIC_at(&iter);前加上ResourceMark rm;即可。

这个问题主要发生的场景是针对频繁大量做Class Retransform或者Class Redefine的场景。所以如果系统里有这种agent的时候还是要稍微注意下这个问题。

这个问题发现后我们给社区提了patch,不过后面发现再JDK12中其实已经修复了,但是在之前的版本里的都没有修复,这个问题提交给社区后,有人很快响应了,并可能在OpenJDK1.8.0-212中被fix。

最后在这里也简单提下客户那边的那个问题,之所以C2线程消耗太大,最主要的原因是存在非常大的方法需要编译,而这个编译的过程是需要大量的内存消耗的,正因为如此,才会导致内存突然暴增,所以给大家一个建议,方法不要写太大啦,如果这个方法调用还很频繁,那真的会很悲剧的。

题外话

在解决这个C2的问题过程中,和雨迪讨论了挺多,也学到不少,给了不少建议,雨迪是Oracle虚拟机团队里主要从事Graal编译器开发的,对C2也是比较了解。在此非常感谢雨迪,另外他在极客时间上开了一门JVM的课程,有几万人买了,这两天正好在搞活动,原价99的现在只要58,有兴趣的可以看看。

如果大家在公司碰到比较难解的JVM相关的性能问题,可以随时联系我们(微信号:PerfMa),另外上海分公司也已经注册下来了,有意向在上海发展的同学也可以联系我。返回搜狐,查看更多

阅读 ()
平台声明
该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。