说起JVM调优,如果有经验的朋友应该都会知道通常的做法就是基于性能测试的结果结合调整JVM启动参数来使应用在充分利用计算机资源的基础上最大限度的提高性能指标。经过调优后应用不会出现由于OOM或者栈溢出而出现应用异常甚至是导致应用服务死机。应该说能做到这些应该差不多可以高枕无忧了。然而直到有一天出现一个诡异的现象使我坐立不安,原因是应用服务看起有内存溢出的趋势,然后应用确一直没有出现任何异常都是正常提供服务。搞得我好像在悬崖边上走的那种感觉。直到后来搞懂一切后才豁然开朗。下面为大家复盘一下这个内存问题。内容有点长,但建议大家看完,保证会让大家对于JVM的垃圾回收有前所未有的认识。
问题描述
事情是这样shei的。一天线上运维人员报告服务内存使用率居高不下,而且有swap内存的使用怀疑应用有内存泄漏。然而他并没有说应用出现任何响应问题或者异常日志。只发了下面两个截图给我。本着负责任的态度,我还是把活接过来了。
内存使用率
JVM配置
推断堆外内存泄漏
于是我就开始排除问题。从运维人员提供的GC日子看出Full GC频率很低,平均10天一次Full GC。日志中也没有出现内存溢出异常日志。初步怀疑是有堆外内存泄漏。于是朝着这一方向继续排查。
排查代码
由于项目是出要业务是处理文件IO。在处理文件IO时大部分是以申请堆外内存作为缓冲区以提高处理效率。由于堆外内存不直接被垃圾回收器回收而是由操作系统管理。当引用直接内存的对象被垃圾回收器回收后,操作系统会回收该对象所持有的直接内存缓存。但是如果引用直接内存的对象没有被正确回收,那么他持有的直接内存就永远不会被回收。虽然该对象在堆内存中占用的内存很小,不回收这个对象可能不会导致堆内存的泄漏。但是他持有的堆外内存会一直被JVM进程占用导致JVM整体内存使用率过高。出于这方面的考虑我们要确保申请堆外内存的地方都去主动释放堆外内存而不要去依赖垃圾回收器去释放堆外内存。我走查了项目的代码把没有主动释放堆外内存的地方都补齐了。可能不同的项目申请堆外内存的方式不一样。这不是重点。重点是要有主动释放的意识。下面是我们项目中使用堆外内存的例子
//自定义一个InputStream 持有NIO的FileChannel和缓存池ByteBuffer
class CustomizeFileInputStream extend InputStream {
private ByteBuffer fileBuffer;
private FileChannel fileChannel;
private int bufferSize;
private int paddingSize;
private long position;
public CustomizeFileInputStream(Path filePath, int bufferSize, int paddingSize) throws IOExcpetion {
this.paddingSize = paddingSize;
this.bufferSize = bufferSize;
this.fileChannel = FileChannel.open(file, StandOperation.READ);
this.fileBuffer = ByteBuffer.allocateDirect(bufferSize + paddingSize);//此处申请堆外内存
this.fileBuffer.limit(0);
}
@Override
public void close() throws IOException {
//这里回收堆外内存,也就是当我们使用完这个InputSteam的时候在调用它的close方法时区主动回收堆外内存
(DirectBuffer) fileBuffer).cleaner.clean();
}
}
测试结果跟想的不一样
代码改完后再进行测试。测试场景如下
集群状态:单节点
主机配置:2 core 4G
JVM配置:
测试场景:调用服务持续上传下载100M大小的文件(每2秒触发一次)
测试结果如下:
- 应用启动后开始测试之前JVM进程内存使用率大概在22%
- 测试期间内存使用率快速上涨到50%然后就在50%左右徘徊。
- GC频率状态通过下面命令没五秒打印一次gc状态
jstat -gc {pid} 5000
- 测试过程没有出现任何异常,文件处理都成功。
看起来结果很好了,然而当测试结束后再统计JVM内存使用率的时候。他还是停留在80%以上而并没有下来。按理说已经没有请求在处理了。内存使用率应该降下来才对。然而这个现象让我百思不得其解。
进程内存使用率为何不降
上面的现象推断有以下两种可能
- JVM 在测试完成后堆内存还使用了很大空间,但是还没有达到触发full gc的条件,导致依然有大量内存没有被回收。
- 由于线程池配置的core size比较大,应用可能启动了大量的线程,而且在测试完成后,线程状态还是活的,那么线程栈中引用的对象就不会被回收。
针对以上两条可能的原因我执行了以下两个操作去验证,由于没有出现内存泄露,所以什么heap dump然后分析就不用做了。
1.手动触发一次full gc,执行下面一条命令。然后观察进程内存使用率
jmap -histo:live pid
2.调节线程池配置为最小值1,重复以上测试,观察测试结果
经过以上两种方式的验证,JVM进程内存使用率依然是居高不下。难道还有堆外内存没有回收。没办法,继续排除看看有没有堆外内存泄漏问题。
如何排查堆外内存泄漏
排查堆外内存问题需要打开JVM收集native memory使用的开关,配置如下
-XX:+StartAttachListener // 打开监听堆外内存使用开关
-XX:NativeMemoryTracking=detail //堆外内存收集报告方式 有summary和detail两种,建议设置为detail,因为detail收集包含summary内容
测试之前我们可以给堆外内存收集设置一个基准线,以便与后面收集结果比较基准线来判断堆外内存使用的增减情况
jcmd {pid} VM.native_memory baseline
测试过程中通过以下命令收集堆外内存使用情况
jcmd {pid} VM.native_memory detail.diff
// 如果-XX:NativeMemoryTracking设置为summary,那么后面就根summary.diff
下面是堆外内存使用收集报告:
Thu Sep 23 18:22:28 CST 2021
26929:
Native Memory Tracking:
Total: reserved=4902495 - 94KB, committed=3401463KB -94KB
//堆内内存
- Java Heap (reserved=3145728KB, committed=2883584KB)
(mmap: reserved=3145728KB, committed=2883584KB)
// 类数据,对应元数据区
- Class (reserved=1130596KB +2KB, committed=91780KB + 2KB)
(classes #15788)
(malloc=2148KB + 2KB #26670)
(mmap: reserved=1128448KB, committed=89632KB)
//线程栈所占空间
- Thread (reserved=141448KB, committed=141448KB)
(thread #138)
(stack: reserved=140836KB, committed=140836KB)
(malloc=451KB #692)
(arena=161KB #272)
//生成代码所占内存,也是对应元数据区
- Code (reserved=256913KB, committed=41701KB)
(malloc=7313KB #11104)
(mmap: reserved=249600KB, committed=34388KB)
/gc 占用的空间
- GC (reserved=11883KB, committed=8383KB)
(malloc=5975KB #421)
(mmap: reserved=5908KB, committed=2408KB)
//编译生成代码时所占用的空间
- Compiler (reserved=357KB, committed=357KB)
(malloc=227KB #961)
(arena=131KB #5)
//其他内部内存,java申请的直接内存就统计在这块
- Internal (reserved=70290KB + 5KB, committed=70290KB + 5KB)
(malloc=70258KB + 5KB #22162)
(mmap: reserved=32KB, committed=32KB)
//保留字符串(Interned String)的引用与符号表引用放在这里
- Symbol (reserved=20001KB, committed=20001KB)
(malloc=17787KB #184033)
(arena=2214KB #1)
// Native Memory收集器本身占的内存
- Native Memory Tracking (reserved=3874KB, committed=3874KB)
(malloc=20KB #232)
(tracking overhead=3854KB)
- Arena Chunk (reserved=950KB, committed=950KB)
(malloc=950KB)
- Unknown (reserved=49968KB, committed=49968KB)
(mmap: reserved=49968KB, committed=49968KB)
通过堆外内存收集报告看出堆外内存只占用了几十MB。这个量级不会导致堆外内存泄漏的。走到这里我已经怀疑人生了。完全被卡住了。不知道再去排查什么问题了。然鹅在当天回家喂狗的时候不知怎么滴有个想法就是会不会是JVM进程在垃圾回收后只是把内存清空以便新的对象能重新申请次块内存,但是并没有把内存交还给操作系统,所以操作系统还认为你这个进程还在使用这些内存,所以通过top命名看到的JVM进程就一直维持在测试结束后那个值左右了。
柳暗花明
基于上面的推断,我们需要使用Linux的smap命令查看进程在测试前,测试中,和测试结束后进程内存消耗详细
/proc/{pid}/smaps
从报告可以看出测试后内存使用情况:内存地址700000000-7c00000000是操作系统为java进程分配的堆内存地址区间,且RSS显示实际已分配超过2.5G内存。JVM进程reserved的内存大小即为最大堆内存的大小。测试之前由于没有多少堆内存申请需求。JVM进程实际只使用了一小部分堆内存空间。随着测试的进行,有大量的对象需要创建,即使full gc后释放的内存也不能满足新对象创建的内存需求。于是JVM就申请commit更多的内存。直到达到设置的maxheap size值。也就是操作系统reserve给JVM的堆内存的大小。在测试结束后commit的内存仍然没有减少。即使full gc去回收这块内存也不会减少commit的内存。我翻阅了官方的资料。官方的说法是JVM垃圾回收是否把内存交还给操作系统取决于垃圾回收算法。而我们应用采用的是CMS垃圾回收算法,该算法不保证垃圾回收会释放内存给操作系统,而更新的G1垃圾回收器就会回收还给操作系统。
总结
大概是垃圾回收算法的设计者认为,既然应用在某个时间段需要那么多的堆内存处理业务,那么这些内存大概率后面还是会被JVM进程使用到的。于是就继续持有这部分内存而不会交还给操作系统。毕竟从操作系统中申请内存是比较昂贵的行为。所以如果应用的JVM垃圾回收算法配置的是CSW或者其他老版本的GC算法,在堆内存配置的比较大的情况下,那么即使发现进程内存使用率很高也不代表应用就有问题。对于JVM进程而言,正常的垃圾回收能保证一直有足够的堆内存使用,而且程序又保证了堆外内存的正确使用与释放。那么内存使用率过高这个指标对于应用的健康没有绝对意义。这只是操作系统级别的指标。如果公司对于基础设置有这方面的硬指标的话建议调整堆内存的大小不去触发操作系统的一些监控指标
参考资料: