jvm相关的问题通常是很难遇到的,因此积累排查问题的经验并不容易,但是我们可以借鉴他人的排查案例,来总结排查的流程和方法。

问题分类

首先是最关键的一点,问题究竟是不是和jvm有关?和jvm相关的问题一般有如下几个特征

  • gc变得频繁,导致吞吐量变小
  • 单次gc时间变长,容易引发接口超时
  • 新生代或老年代(或永久代、元空间)占有率异常,比之前要大,从而引发gc
  • 栈空间不足,通常引发stack overflow
  • 堆外空间申请过多,也会触发oom

总结起来,就是空间不足(可能是代码bug导致使用了过量对象)或者空间分配不合理(比如新生代和老年代、eden和survivor)。
如果问题没有显现类似特征,我们有理由相信是问题是其他原因引发的。

OOM类型

  • 堆内存不足,java.lang.OutOfMemoryError: Java heap space
  • 1.7及之前,永久代不足,java.lang.OutOfMemoryError: PermGen space
  • 1.8及之后,元空间不足,java.lang.OutOfMemoryError: Metaspace
  • 栈空间不足,java.lang.StackOverflowError
  • 直接内存不足,java.lang.OutOfMemoryError: Direct buffer memory

分代空间比例

知道哪块空间不足,并不是要盲目扩充整个堆大小,最重要的是确定不同分代间的空间分布。

  • 整个堆空间分为已提交和未提交,即最小到最大堆内存的差值,在所需空间增大过程中也会产生GC
  • 已提交空间分为新生代和老年代,需根据对象分布调整,一般老年代大小应为长期对象体积的2到3倍
  • 新生代分为eden和survivor,survivor相当于到老年代的缓冲,过小可能导致过早的full GC
  • 堆外内存一般有元空间和直接内存,可以根据需要调整大小

常见场景

查询大对象

大对象经常是引发频繁GC的元凶,在CMS和G1中,过多的大对象容易引发内部碎片,导致oom。
这里我们设置一个场景,设定最大堆内存为500MB,循环申请32MB数组(不释放),显然循环到第16次时,会发生oom。
代码见测试类ObjectAddressTesttest_big_object方法。
对应到服务端,就会出现服务宕机的情况,排查过程如下

  • 首先查看监控上的gc频率、暂停时间、各个分代大小,可以看到老年代占用率过高(大对象在G1中直接分配到老年代)
  • 在k8s上配置vm options
    -XX:+HeapDumpOnOutOfMemoryError // 导出堆快照
    -XX:HeapDumpPath=<file-path> // 导出路径
    -Xlog:gc* (针对JVM 9及以上,打印详细gc信息)
    -XX:+PrintGCDetails -Xloggc:<file-path> (针对JVM 8及以下)
    
  • 分析日志,G1日志显示的是清理前后已使用的区域个数,以eden举例,清理前是3个,清理了3个,可以看到大对象区域占用最多
    [0.144s][info][gc,heap      ] GC(0) Eden regions: 3->0(124)
    [0.144s][info][gc,heap      ] GC(0) Survivor regions: 0->1(3)
    [0.144s][info][gc,heap      ] GC(0) Old regions: 0->0
    [0.144s][info][gc,heap      ] GC(0) Humongous regions: 99->99
    [0.144s][info][gc,metaspace ] GC(0) Metaspace: 4788K(5120K)->4788K(5120K) NonClass: 4394K(4608K)->4394K(4608K) Class: 393K(512K)->393K(512K)
    
  • 下载mat解析堆快照,注意版本不超过1.11(支持jdk8)
  • 编辑文件 MemoryAnalyzer.ini 添加 -vmargs – Xmx4g,保证能解析几个G的堆快照
  • 由于程序比较简单,可以看到占用内存最大的就是我们申请的byte[],真实程序的分析更加复杂,需要结合不同分代的占用率和代码变更来看

原因:最常见的原因是数据库查询时未加limit限制

缓存未释放

业务中常用的本地缓存如guava,一般是通过静态Map实现的,如果不设置上限,内存占用率会一直累积。
首先会逐步填满新生代,导致频繁的minor GC,之后填满老年代,引发full GC。
我们用静态Map不断插入新对象来,设置最大堆内存200MB,见方法test_static_map
从GC日志可以看到,每次minor GC都会使老年代占用率变高,然后逐渐引发full GC。

[0.131s][info][gc,start     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[0.139s][info][gc,heap      ] GC(0) Eden regions: 24->0(13)
[0.139s][info][gc,heap      ] GC(0) Survivor regions: 0->3(3)
[0.139s][info][gc,heap      ] GC(0) Old regions: 0->20
[0.139s][info][gc,heap      ] GC(0) Humongous regions: 0->0
[0.139s][info][gc,metaspace ] GC(0) Metaspace: 3690K(4864K)->3690K(4864K) NonClass: 3374K(4352K)->3374K(4352K) Class: 316K(512K)->316K(512K)
[0.139s][info][gc           ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->22M(200M) 7.537ms

[0.165s][info][gc,start     ] GC(3) Pause Young (Normal) (G1 Evacuation Pause)
[0.171s][info][gc,heap      ] GC(3) Eden regions: 25->0(25)
[0.171s][info][gc,heap      ] GC(3) Survivor regions: 4->4(4)
[0.171s][info][gc,heap      ] GC(3) Old regions: 59->85
[0.171s][info][gc,heap      ] GC(3) Humongous regions: 0->0
[0.171s][info][gc,metaspace ] GC(3) Metaspace: 3690K(4864K)->3690K(4864K) NonClass: 3374K(4352K)->3374K(4352K) Class: 316K(512K)->316K(512K)
[0.171s][info][gc           ] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 88M->88M(200M) 6.339ms

[0.354s][info][gc,start       ] GC(45) Pause Full (G1 Evacuation Pause)
[0.359s][info][gc,heap        ] GC(45) Eden regions: 0->0(10)
[0.359s][info][gc,heap        ] GC(45) Survivor regions: 0->0(2)
[0.359s][info][gc,heap        ] GC(45) Old regions: 200->200
[0.359s][info][gc,heap        ] GC(45) Humongous regions: 0->0
[0.359s][info][gc,metaspace   ] GC(45) Metaspace: 3693K(4864K)->3693K(4864K) NonClass: 3376K(4352K)->3376K(4352K) Class: 316K(512K)->316K(512K)
[0.359s][info][gc             ] GC(45) Pause Full (G1 Evacuation Pause) 198M->198M(200M) 4.601ms

pause young即minor GC,可以看到old regions在变大,pause full即full GC,可以看到old regions已经满了(这里每个region是1MB)。

直接内存未释放

java生态中一般用到直接内存的地方有NIO和netty,如果申请超过上限MaxDirectMemorySize也会报oom,提示是Direct buffer memory,所以这种问题相对好定位。
测试方法为test_direct_buffer,通过ByteBuffer申请直接内存直到报错。
直接内存不足导致的宕机并不会留下堆快照。

总结

定位问题的一般步骤如下

  • 发现问题,如服务报错增多、超时增多、gc频率或分代占用率高于平均值,依赖监控组件
  • 变量分析,变化来自外部或内部,首先一定是代码diff,除了代码还需要检查依赖项的版本是否变更,如果是对外的api还需要关注对接方是否有变更通告
  • 如果上述分析还不能定位到问题,需要打开gc日志,如果宕机需要到处堆快照,通过日志和MAT工具分析异常的分代和对象(体积过大或数量过多)
  • 如果堆正常,检查元空间、直接内存的使用,元空间占用主要和代理类有关,直接内存使用主要和NIO和netty相关。