1 问题背景
深夜,一阵急促的报警声响起,原来是收到一连串容器内存使用率过高的报警。查看服务器状态,容器内存使用率持续高达99%,记录排查过程和分析,供大家参考。
2 问题现象
检查容器状态,观察到以下现象:
-
JVM堆内存使用率正常,在50%左右波动,在凌晨1点的时候年轻代使用无明显波动,老年代使用有直线上涨,但是执行了一次
Full GC(也可能是Major GC)
后恢复了正常。 -
容器内存使用率在凌晨1点的时候有一个直线的上涨,并且随后一直保持在上涨后的水平。
服务使用的配置如下:
主要启动参数:-Xms4g -Xmx4g -Xmn2g -XX:+UseG1GC -XX:G1HeapRegionSize=8m -XX:G1ReservePercent=15 -XX:InitiatingHeapOccupancyPercent=50
容器配置:4C5G
3 定位原因
虽然容器内存已经使用99%,但是JVM堆使用率在经过一次Full GC
后已经降到了50%之后,所以并没有立马重启服务,还是先来看看造成Full GC
的原因。
鉴于堆使用率已经恢复正常,查看当时的内存快照意义不大,决定先从观察到的现象入手。因为凌晨1点的时候系统的流量不会很大,所以大概率是因为定时任务造成了,先排查报警服务凌晨1点执行的定时任务。幸运的是调度平台当时只有一个定时任务在执行,立马查看对应的逻辑,发现了以下代码片段中潜在的问题(简化之后):
public void job() {
// ... do business
int pageSize = 500;
while ( xxx ) {
// 每次查询500个订单
List<String> orderNoList = orderService.getOrderPage(pageSize);
// 查询500个订单对应的账单
List<OrderBill> orderBills = billService.findByOrderNos(orderNoList);
// ... do business
}
// ... do business
}
由于大部分订单包含12至24期账单,导致此处的orderBills在平均情况下含有数千至数万条数据。经过粗略估算,这些对象的总大小大约为5MB左右。
看启动参数中G1HeapRegionSize=8m
,代表每个Region的大小为8M,G1 GC
会将大于Region一半大小的对象直接分配到老年代。所以orderBills对象会被直接分配到老年代,这也和在凌晨1点的时候年轻代使用无明显波动,老年代使用有直线上涨的现象相符合。在后续的循环中,由于垃圾收集器清理的速度赶不上内存分配的速度,导致垃圾逐渐积累并最终填满整个堆空间,触发了Full GC
。
为什么GC之后容器的内存还是一直居高不下呢?
经过一番查阅资料,原来JVM启动时并不会立即实际占用全部Xms
指定的内存。Xms
参数指定的是堆的初始大小,JVM会按照这个值预留内存空间,但实际上只有在需要时才会逐渐使用这些预留的内存。
JVM在完成内存释放后,是否将释放的内存返回给操作系统,这一行为取决于具体的JVM实现及其采用的垃圾回收策略。在很多情况下,JVM在进行垃圾收集并释放了堆内存之后,并不会立即将这部分内存归还给操作系统。
相反,它会保留这部分内存以备将来Java应用程序的使用,因为从操作系统重新申请内存通常会比从JVM内部管理的内存分配更为昂贵(时间成本上)。所以一般程序还会将Xms
和Xmx
配置为相等的大小,避免频繁申请和释放内存造成的性能开销。
上面两个问题找到原因了,小菜同学决定在本地复现一下进行验证。
新建一个memorytest
项目,写一个方法模拟内存分配:
/**
* 模拟内存分配
* @param num 循环次数
* @param size 每次分配几MB的数据
*/
@RequestMapping("/memory/add/{num}/{size}")
public String add(@PathVariable("num") Integer num, @PathVariable("size") Integer size) {
for (int i = 0; i < num; i++) {
// 模拟查询出来的大对象
byte[] allocation = new byte[size * 1024 * 1024];
}
return "";
}
用以下命令启动:
java -Xms2g -Xmx2g -Xmn1g -XX:+UseG1GC -XX:G1HeapRegionSize=8m -XX:G1ReservePercent=15 -XX:InitiatingHeapOccupancyPercent=50 -jar memorytest-0.0.1-SNAPSHOT.jar
使用Jdk自带的命令查询JVM内存分配情况,先使用jps -l
查询进程ID:
jps -l
16988 sun.tools.jps.Jps
9068 memorytest-0.0.1-SNAPSHOT.jar
使用jmap -heap <pid>
查询堆内存分配:
jmap -heap 9068
Heap Usage:
G1 Heap:
regions = 256
capacity = 2147483648 (2048.0MB)
used = 230686720 (220.0MB)
free = 1916796928 (1828.0MB)
10.7421875% used
G1 Young Generation:
Eden Space:
regions = 26
capacity = 1115684864 (1064.0MB)
used = 218103808 (208.0MB)
free = 897581056 (856.0MB)
19.548872180451127% used
Survivor Space:
regions = 2
capacity = 16777216 (16.0MB)
used = 16777216 (16.0MB)
free = 0 (0.0MB)
100.0% used
G1 Old Generation:
regions = 0
capacity = 1015021568 (968.0MB)
used = 0 (0.0MB)
free = 1015021568 (968.0MB)
0.0% used
使用jstat -gcutil <pid> <interval[s|ms]>
1秒1次监控堆内存使用和GC情况(也可以使用jconsole
可视化处理工具来查看内存的使用情况):
jstat -gcutil 9068 1000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 21.80 0.00 94.17 88.38 1 0.023 0 0.000 0.023
该命令返回的主要的参数的意义:S0
:Survivor space 0
区的使用率。S1
:Survivor space 1
区的使用率。E
:Eden
区的使用率。O
:Old
区(老年代)的使用率。YGC
:年轻代垃圾收集事件的次数。YGCT
:年轻代垃圾收集所耗费的时间(秒)。FGC
:Full GC(全堆垃圾收集)
事件的次数。FGCT
:Full GC
所耗费的时间(秒)。GCT
:垃圾收集所耗费的总时间(秒)。
此时使用ps aux --sort -rss
查看Java进程占用的本机内存才370MB左右,并没有直接占用Xms
设置的2g。
ps aux --sort -rss
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 9068 5.4 9.9 4718036 374536 pts/1 Sl+ 15:10 0:09 java -Xms2g -Xmx2g -Xmn1g -XX:+UseG1GC -XX:G1HeapRegionSize=8m -XX:G1ReservePercent=15 -XX:InitiatingHeapOccupancyPercent=50 -jar memorytest-0.0.1-SNAPSHOT.jar
该命令返回的主要的参数的意义:PID
: 进程ID。%CPU
: 进程使用的CPU百分比。%MEM
: 进程使用的物理内存百分比。RSS
: 进程当前占用的物理内存大小,单位通常是KB。COMMAND
: 启动进程的命令行命令。
开始模拟业务分配内存,为了方便观察直接循环100000次,每次分配5MB空间:
http://127.0.0.1:8080/memory/add/100000/5
jstat此时监控到内存情况:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 24.81 0.00 94.17 88.38 1 0.023 0 0.000 0.023
0.00 0.00 1.48 61.36 93.38 89.74 12 0.192 0 0.000 0.192
0.00 100.00 1.49 86.37 92.74 89.74 33 0.294 0 0.000 0.294
0.00 0.00 2.27 99.41 92.74 89.74 56 0.395 0 0.000 0.395
0.00 0.00 2.15 99.55 92.75 89.74 84 0.522 0 0.000 0.522
0.00 0.00 1.94 99.77 92.20 89.00 119 0.663 0 0.000 0.663
0.00 0.00 4.00 99.71 92.21 89.01 169 0.834 0 0.000 0.834
0.00 100.00 0.75 21.43 92.22 89.01 232 0.998 1 0.051 1.049
0.00 100.00 0.84 99.68 92.22 89.01 252 1.025 1 0.051 1.077
0.00 100.00 0.88 99.87 92.25 89.01 274 1.064 1 0.051 1.115
0.00 0.00 1.48 70.73 92.25 89.01 299 1.110 1 0.051 1.161
0.00 0.00 1.48 75.90 92.25 89.01 327 1.168 1 0.051 1.219
0.00 0.00 1.77 99.81 92.25 89.01 361 1.239 1 0.051 1.290
0.00 0.00 2.41 99.92 92.27 89.01 409 1.340 1 0.051 1.392
此时可以看到对象都直接分配到了老年代,年轻代的内存使用没有多大的变化,并且当YGC(Young Generation Garbage Collection,年轻代垃圾回收)
来不及回收时就会发生FGC(Full Garbage Collection,全堆垃圾回收)
。
此时再使用ps aux --sort -rss
查看Java进程占用的本机内存已经到了2G了,并且在方法执行完成后(也包括FGC
后)也没有释放此内存。
ps aux --sort -rss
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 9068 77.1 60.4 4720084 2043996 pts/1 Sl+ 15:10 6:03 java -Xms2g -Xmx2g -Xmn1g -XX:+UseG1GC -XX:G1HeapRegionSize=8m -XX:G1ReservePercent=15 -XX:InitiatingHeapOccupancyPercent=50 -jar memorytest-0.0.1-SNAPSHOT.jar
再模拟一次业务分配内存,为了方便观察直接循环100000次,这次每次分配2MB空间:
http://127.0.0.1:8080/memory/add/100000/2
jstat此时监控到内存情况:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 15.04 0.00 94.14 88.45 1 0.027 0 0.000 0.027
0.00 100.00 15.04 0.00 94.14 88.45 1 0.027 0 0.000 0.027
0.00 100.00 93.18 0.00 93.15 89.36 2 0.043 0 0.000 0.043
0.00 100.00 51.49 0.66 93.15 89.36 22 0.146 0 0.000 0.146
0.00 0.00 68.15 1.48 93.15 89.36 44 0.167 0 0.000 0.167
0.00 0.00 81.48 1.48 93.15 89.36 66 0.186 0 0.000 0.186
0.00 0.00 16.30 1.48 93.15 89.36 89 0.207 0 0.000 0.207
0.00 0.00 58.52 1.48 93.15 89.36 111 0.226 0 0.000 0.226
0.00 0.00 4.44 1.48 93.16 89.36 134 0.246 0 0.000 0.246
0.00 100.00 50.00 0.66 93.16 89.36 156 0.265 0 0.000 0.265
0.00 0.00 88.15 1.49 93.16 89.36 178 0.284 0 0.000 0.284
0.00 0.00 41.48 1.49 93.16 89.36 201 0.305 0 0.000 0.305
0.00 0.00 87.41 1.49 93.16 89.36 223 0.324 0 0.000 0.324
0.00 0.00 27.41 1.49 93.17 89.36 246 0.344 0 0.000 0.344
0.00 0.00 89.63 1.49 93.17 89.36 263 0.358 0 0.000 0.358
0.00 0.00 89.63 1.49 93.17 89.36 263 0.358 0 0.000 0.358
此时很明显可以看到对象都直接分配到了年轻代,年轻代的回收效率也比老年代高,并且没有产生FGC
。
再结合本次报警的现象,解决办法也呼之欲出:
-
通过减少查询返回的数据量,避免大对象直接分配至老年代。
-
调整Region大小,较大的Region可以提高大对象分配的效率,同时可能会导致GC停顿时间变长,较小的Region可能意味着更短的停顿时间,但是会增加维护开销,可能导致更高的CPU使用率。所以需要通过监控工具(如
JConsole
、VisualVM
等)观察不同Region大小设置下的GC表现和应用吞吐量,以找到最佳平衡点。
4 问题小结
JVM内存问题可能不常有,但是一旦发生可能对我们系统造成极大的影响,此次内存飙升也给我们敲响了警钟,发现了我们在这边注重的不够,很多监控和日志并不完整,如启动参数中没有加上发生OOM
时自动dump文件的参数等。好在此次服务未受到影响,所有有足够的时候给我们去排查和验证。特此结合此次排查的过程的一些收获记录一下,假设下次还有发生可以有所参考:
-
如果对业务产生了影响,服务不可用或者进程直接没有了,首先需要快速止损,包括熔断、重启、手动GC等方式。但是操作前需要先dump出内存快照方便问题排查,命令如下:
#jmap命令保存整个Java堆(在你dump的时间不是事故发生点的时候尤其推荐)
jmap -dump:format=b,file=heap.bin <pid>
#jmap命令只保存Java堆中的存活对象, 包含live选项,会在堆转储前执行一次Full GC
jmap -dump:live,format=b,file=heap.bin <pid>
#jcmd命令保存整个Java堆,Jdk1.7后有效
jcmd <pid> GC.heap_dump filename=heap.bin
还有在启动参数中添加发生OOM时自动生成heapdump的参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.bin
启动参数中添加GC日志打印相关的参数:
# Java8及以下
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:<path>
# Java9及以上
-Xlog:gc*:<path>:time
-
如果对业务无影响,可以先观察现象进行排查。如果是近期有业务增加,则可以考虑是否需要扩容,如果是周期性的表现,则可以排查定时任务。
-
对导出的内存快照文件使用MAT等工具进行分析,一般会比较直观的看到当前堆内情况。
-
如果服务还可用,也可以使用命令进行排查,Jdk本身也已经提供非常多轻量的小工具,主要用于监视虚拟机运行状态和进行故障处理,常用的如下(还有一些第三方的工具也很好用,如
arthas
等):
jstat -gcutil <pid> :监视Java堆状况,主要关注已使用空间的百分比和GC情况
jmap -heap <pid> :显示Java堆详细信息
jmap -histo[:live] <pid> :显示堆中对象统计信息:快速识别哪些类的实例占用了大量的堆内存
-
如果是栈溢出,抛出
StackOverflowError
异常,需要着重检查是否有死循环或者代码调用链路不合理。 -
还有可能是堆外内存泄露(元空间、直接内存等),此情况一般较少发生,排查起来也更加复杂,小菜同学对于这部分的实操有限,这里就不详细分析了,还需要继续学习。
5 总结
面对内存使用率的异常,不仅要关注即时的解决方案,还需要考虑如何从根本上避免此类问题的再次发生。如使用优化数据结构、减少不必要的计算、采用懒加载等策略,以及建立细致的内存监控体系等。性能优化不应该仅仅是面对问题时的临时行动,而应该成为开发文化的一部分。
希望这次的经历和总结能够为未来可能遇到的类似问题提供一点参考的价值,也希望屏幕前的大佬对小菜同学写的不对或者遗漏的地方不吝指正,非常感谢!
关于作者 蔡闯,金融技术部后端研发工程师