一、现象:进程突遭“谋杀”
问题表现
- 公司新服务部署后,经常出现宕机。
- demsg日志明确记录:Out of memory: Kill process [pid],但JVM自身未抛出OOM异常。
初步猜想
-
矛盾点:Java堆内存稳定,宿主机RSS耗费到7G以上后,被内核Kill。
- 机器配置为4C8G。JVM内存配置:-Xms4096m -Xmx4096m -XX:ThreadStackSize=256 -XX:MaxDirectMemorySize=1024m。
-
排查方向:基于内存配置以及物理内存表现,猜测为堆外内存泄漏。
二、问题定位:抽丝剥茧内存分配-JIT编译
现象还原
- 缩小压测量级,单机多次执行压测脚本,得到内存增长现象如下。
问题排查
排除堆内异常引用对象
- 连续压测,并抓取dump,分析在内存增长各阶段,堆内引用对象量级变化。
jmap -dump:format=b,live,file=bcs.hprof pid
- 使用JProfiler打开对比,并无明显异常
堆外内存使用分析
启动参数增加:-XX:NativeMemoryTracking=detail 注:必须增加到 java后,其余位置不生效。
压测过程中采集指标:jcmd pid VM.native_memory detail 多次执行后对比分布
- 通过不同阶段的内存分布对比,初步可以锁定与编译有关。
Linux内存分配分析
pmap -x pid | sort -n -k3 执行后发现有大量的64M的地址
pmap -x pid |grep '65'
基于jcmd的结果,如jcmd中有本文类似的异常结果,可以使jcmd详情中的内存地址区间与pmap结果匹配。如jcmd无明显异常结果,就只能通过pmap 所有的内存地址在jcmd内存地址区间做匹配来确认Native Code申请的堆外内存。
追踪内存分配的进程线程
strace -o error.log -f -e trace=brk,mmap,munmap -p pid
cat error.log|grep '65'
根据第⼀列的pid(线程ID), 转为jstack 的16进制的nid: 确认空间由C1/2 Complier 线程分配
JIT优化排查
添加-XX:+PrintCompilation 打印jit优化的编译⽇志;
确定了执⾏编译优化的类⽂件及涉及的执⾏次数/编译数据⼤⼩;
-
通过made not entrant基本确认Jit出现了反优化。
-
排查对应的类文件,发现字段数对应get/set⽅法达到500+。
-
排查jit优化主要涉及的两个场景:
- ⽅法内联
- ⼤循环逻辑
-
优先排查⽅法内联,因为涉及到⼤量的get/set短⽅法,符合内联的场景。
- 跟进代码发现,项目底层框架中为了实现通过配置表的方式进行字段转换,扩展了Jackson的能力。每次调用的get/set,均采用refectasm包中的MethodAccess.get 来进行字节码增强。
- 最终类中的每一个get/set方法都生成了类似下图中的增强实现。这么做的原因是因为在执行invoke的时候,可以根据方法id进行调用,比反射更节省资源。
- get/set高频次调用,确定符合内联场景,继续确定Jit优化次数是否符合。
- 跟进代码发现,项目底层框架中为了实现通过配置表的方式进行字段转换,扩展了Jackson的能力。每次调用的get/set,均采用refectasm包中的MethodAccess.get 来进行字节码增强。
java -XX:+PrintFlagsFinal -version | grep CompileThreshold
通过实体类中的字段和get/set的个数,在结合各阶段转换的次数,基本可达到c2默认的15000次调⽤。
- 禁用jit内联优化,再次压测,内存增加的现象消失。
Java启动参数增加:-XX:Inline 【生产环境不能禁用。CPU会上升30%以上】
原因总结
- 项目中实体的get/set较多,调用频次也比较高。
- 使用时对每一个get/set都进行字节码增强,且增强后的方法体占用较大(MethodAccess.invoke)。
- JIT对get/set进行内联优化。但最终是对一个超大方法体的MethodAccess.invoke方法执行了优化。
- MethodAccess.invoke大方法编译的过程需要占用大量的内存。
修复方式
虽然还有些技术细节没有想明白,但基本已经定位是引入了字节码增强导致的。该机制最初是为了优化反射调用性能而引入,但并未充分考虑到对高频方法进行增强后带来的运行时编译开销。文中对于get/set的增强本质上来说是存在缺陷的,会增加方法体的复杂度和执行开销。鉴于临近上线,考虑到重新实现代码增强的成本和不确定因素,最终采用反射替换MethodAccess。
三、深入理解
为什么JIT占用的内存刚好是64M?
对于64M的问题,是一个非常经典的问题,在JVM中并没有这种大量分配64M大小的逻辑,因此可以排除JVM特定意义的分配。这其实是glibc里针对malloc函数分配内存的一种机制,glibc从2.10开始提供的一种机制,为了分配内存更加高效,glibc提供了arena的机制,默认情况下在64位下每一个arena的大小是64M。
glibc 使用内存池为 java 应用通过 malloc 申请堆外内存,当 jvm 回收内存归还操作系统时,操作系统在 free 的时候并不会真正释放内存,而是维护到 glibc 的内存池中供下次使用,从而避免重复申请。
为什么C2会一直分配内存?
针对这个问题百思不得其解,进行了各种google,直到发现已经被提交的Path。
- 在本文的排查过程中发现,MethodAccess 的实现采用了字节码增强机制。从本质上来说,这种机制必然会触发 ClassRedefine 操作,进而致使 JIT出现 [made not entrant] 情况,并且会触发反优化操作,也就是 “撤销” 之前已经完成的编译。
- 再结合官方给出的结论,即 “该 BUG 在某些极端情形下,C2 编译器会出现 OOM问题”,基本能够确定那些经过增强处理的 get/set 方法始终处于大方法编译状态,CompieldIC又没有被ResourceMark标记,不会复用内存,从而持续不断地申请新的内存资源。
四、总结
-
字节码增强技术优势很大,可以实现无侵入式编程,但同样带来的不确定性也更多,在使用时需要保证对于规范和字节码优化机制的正确理解。
-
相较与堆内,堆外内存的异常查询难度更高,基于本文的查询路径,总结思路如下:
-
先确认最大堆是否受到控制[Xmx],如果超出物理内存的75%,就需要调整。
- 堆内监控手段较多,也可使用原生命令jcmd查看。
-
再确认最大直接内存有无控制[-XX:MaxDirectMemorySize]。
- NIO、DateBuffer内存占用
-
Arena区占用是否较多
- 可以直接使用pmap查看64M的内存占用(这一步可以不挂载NativeMemoryTracking场景重启)。
-
挂载NativeMemoryTracking detail查看内存分配
- 如果有明确方向,就可以使用detail中的地址直接定位。
- 如果没有明确方向,基本上就是native方法导致,需要从pmap结果中找出不属于deital中的内存地址值。
-
使用strace查看OS申请的内存请求。
- 这一步对于内存缓慢上升的现象需要长时间采集。
-
如果到这里还定位不出来,只能使用终极工具jemalloc来排查。[内容太多,后续分享]。
-