背景:8G物理内存,8核CPU,jvm使用的G1垃圾回收器。
问题:线上内存占用告警,内存占用超过85%,且现象一直持续。
分析
看一下jvm启动参数配置:
-Xms6144m
-Xmx6144m
-Xss256k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/tmp/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:+UseStringDeduplication
-XX:GCLogFileSize=16M
-Djava.awt.headless=true
-Djava.net.preferIPv4Stack=true
-Duser.timezone=Asia/Shanghai
-Dfile.encoding=UTF-8
进容器内,观察内存占用。
top
free -h
发现java进程占用了7G多的内存,但内存占用大小波动很小。第一想法是否有内存泄漏(回过头看,想法是好的:内存占用高怀疑是否有内存泄漏。但其实已经走了误区,内存占用稳定应当去定位java进程占用的内存被谁占用了。堆内存、元空间还是堆外内存?堆内的话是年轻代还是老年代?但还是记录一下这次jvm问题定位排查的过程),去查看堆内存的实际内存占用。
先看一下java进程pid、jmap命令路径:
ps -ef | grep java
find / -name jmap
然后,执行jmap命令查看堆内存概览:
堆信息概览:
jmap -heap pid
堆中对象概览:
jmap -histo pid | head -50
例如我实际执行的:
/data/services/jdk8u161/bin/jmap -heap 376
/data/services/jdk8u161/bin/jmap -histo 376 | head -50
堆内存分配了6G,实际占用800M,而java进程占用了7G,其它内存被谁占用了?首先想到的是堆外内存,是否是堆外内存的溢出导致的?
元空间的观察
jvm GC信息,间隔一秒打印一次:
jstat -gc pid 1000
或者
jstat -gcutil pid 1000
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
0.0 96256.0 0.0 96256.0 1644544.0 1105920.0 4550656.0 138468.4 114280.0 109400.0 13688.0 12775.8 14 1.211 0 0.000 1.211
0.0 96256.0 0.0 96256.0 1644544.0 1105920.0 4550656.0 138468.4 114280.0 109400.0 13688.0 12775.8 14 1.211 0 0.000 1.211
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 89.54 3.04 95.73 93.34 14 1.211 0 0.000 1.211
0.00 100.00 89.66 3.04 95.73 93.34 14 1.211 0 0.000 1.211
MC(元空间分配):114280.0K,MU(元空间使用):109400.0K。元空间使用了100M多一点,排除。但为防止元空间的内存溢出,JVM启动参数中建议添加参数:-XX:MaxMetaspaceSize=256M
堆外内存的观察
为了观察java进程堆外内存的占用,JVM启动参数中添加参数:-XX:NativeMemoryTracking=summary,这个参数对jvm可能会有5%左右的性能损耗,所以生产环境不推荐开启。
同时,-XX:+DisableExplicitGC:禁止显示GC,即代码中声明的 System.gc();//建议jvm进行gc 不再生效。在jdk源码中使用nio申请堆外内存时,堆外内存不足时会执行 System.gc() 进行堆外内存的回收,所以,堆外内存使用较多时不推荐配置 -XX:+DisableExplicitGC。
最后,为了防止堆外内存的溢出,jvm启动参数建议添加:-XX:MaxDirectMemorySize=1024M
修改后的jvm启动参数配置如下:
-Xms6144m
-Xmx6144m
-Xss256k
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxDirectMemorySize=1024M
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/tmp/gc-%t.log
-Duser.timezone=Asia/Shanghai
-Dfile.encoding=UTF-8
jcmd 追踪java本地内存
配置好后,重新启动容器,jcmd 追踪java进程本地内存:
查看java进程内存占用详细情况(-XX:NativeMemoryTracking=summary,关闭NMT命令:jcmd pid VM.native_memory shutdown):
jcmd pid VM.native_memory scale=MB
保存java进程内存占用情况的基准版本:
jcmd pid VM.native_memory scale=MB baseline
与基准版本进行比较(若怀疑存在内存泄漏,可过段时间再执行观察):
jcmd pid VM.native_memory scale=MB summary.diff
我实际执行的命令:
/data/services/jdk8u161/bin/jcmd 376 VM.native_memory scale=MB
/data/services/jdk8u161/bin/jcmd 376 VM.native_memory scale=MB baseline
/data/services/jdk8u161/bin/jcmd 376 VM.native_memory scale=MB summary.diff
对比2张图,可发现内存波动很小。Java Heap(堆内存)分配了6G,实际也占用了6G。堆外内存实际占用了800多M,主要被Class(元空间)、Thread(java线程栈,含GC本地线程)、Code(本地字节码,即JIT存储热点代码地方)、GC(JVM GC额外占用的,例如G1中的Remembered Set等数据结构)、Internal(Direct Buffer直接内存,例如nio)等占用。Native Memory Tracking表示该功能自身占用的部分。
一段时间后,再次对比发现内存波动还是很小,这时候才反应过来,是不是不存在内存泄漏,只不过是java进程真的占用了这么高的内存。那为什么java进程内存占用一直这么高呢?仔细看一下上图,发现这一行: Java Heap (reserved=6144MB, committed=6144MB) ,JVM为java堆保留了6G,堆实际也占用了6G,才回想起来JVM的启动参数配置中:-Xms6144m -Xmx6144m,自己设置了最大堆为6G,再加上堆外内存的800多M,那java进程实际不就是占用7G左右嘛。
解决
修改-Xms6144m -Xmx6144m为-Xms4096m -Xmx4096m,物理内存的一半,重启后,容器的内存占用最后稳定在60%左右,内存占用过高的问题没有再次出现。最后的JVM启动参数配置为:
-Xms4096m
-Xmx4096m
-Xss256k
-XX:MetaspaceSize=256M
-XX:MaxMetaspaceSize=256M
-XX:MaxDirectMemorySize=1024M
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/tmp/gc-%t.log
-Duser.timezone=Asia/Shanghai
-Dfile.encoding=UTF-8
思考
为何jvm堆实际占用稳定在6G?我尝试手动进行了full GC,马上再去观察,发现堆内存实际占用还是6G。可是jmap -heap pid观察堆信息概览,看到heap used实际只有几十M,那这是为什么呢?为什么JVM没有将内存归还给操作系统呢?可参考博客:
总结:JVM 的垃圾回收,只是一个逻辑上的回收,回收的只是 JVM 申请的那一块逻辑堆区域,将数据标记为空闲之类的操作,不是调用 free 将内存归还给操作系统。
JVM 还是会归还内存给操作系统的,只是因为这个代价比较大,所以不会轻易进行。而且不同垃圾回收器的内存分配算法不同,归还内存的代价也不同。或者说归还内存给操作系统的操作并没有那么简单,执行起来代价过高,JVM 自然不会在每次 GC 后都进行内存的归还。
JVM问题定位使用到的命令
top
free -h
ps -ef | grep java
find / -name jmap
堆信息概览:
jmap -heap pid
堆中对象概览:
jmap -histo pid | head -50
jvm GC信息:
jstat -gc pid 1000
jstat -gcutil pid 1000
查看java进程内存占用详细情况(-XX:NativeMemoryTracking=summary,关闭NMT命令:jcmd pid VM.native_memory shutdown):
jcmd pid VM.native_memory scale=MB
Full GC后显示存活对象:
jmap -histo:live pid | head -20
查看jvm启动参数:
jinfo -flags pid
or
jps -v
Full GC后dump堆栈文件:
jmap -dump:format=b,live,file=heapdump.hprof pid
保存java进程内存占用情况的基准版本:
jcmd pid VM.native_memory scale=MB baseline
与基准版本进行比较(若怀疑存在内存泄漏,可过段时间再执行观察):
jcmd pid VM.native_memory scale=MB summary.diff