什么!服务器内存又双叒叕打满了!

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点执行的定时任务。幸运的是调度平台当时只有一个定时任务在执行,立马查看对应的逻辑,发现了以下代码片段中潜在的问题(简化之后):

java 复制代码
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内部管理的内存分配更为昂贵(时间成本上)。所以一般程序还会将XmsXmx配置为相等的大小,避免频繁申请和释放内存造成的性能开销。

上面两个问题找到原因了,小菜同学决定在本地复现一下进行验证。

新建一个memorytest项目,写一个方法模拟内存分配:

java 复制代码
/**
 * 模拟内存分配
 * @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 "";
}

用以下命令启动:

shell 复制代码
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:

shell 复制代码
jps -l
16988 sun.tools.jps.Jps
9068 memorytest-0.0.1-SNAPSHOT.jar

使用jmap -heap <pid>查询堆内存分配:

shell 复制代码
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可视化处理工具来查看内存的使用情况):

yaml 复制代码
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

该命令返回的主要的参数的意义:

S0Survivor space 0区的使用率。
S1Survivor space 1区的使用率。
EEden区的使用率。
OOld区(老年代)的使用率。
YGC:年轻代垃圾收集事件的次数。
YGCT:年轻代垃圾收集所耗费的时间(秒)。
FGCFull GC(全堆垃圾收集)事件的次数。
FGCTFull GC所耗费的时间(秒)。
GCT:垃圾收集所耗费的总时间(秒)。

此时使用ps aux --sort -rss查看Java进程占用的本机内存才370MB左右,并没有直接占用Xms设置的2g。

shell 复制代码
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空间:

ruby 复制代码
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后)也没有释放此内存。

shell 复制代码
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空间:

ruby 复制代码
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使用率。所以需要通过监控工具(如JConsoleVisualVM等)观察不同Region大小设置下的GC表现和应用吞吐量,以找到最佳平衡点。

4 问题小结

JVM内存问题可能不常有,但是一旦发生可能对我们系统造成极大的影响,此次内存飙升也给我们敲响了警钟,发现了我们在这边注重的不够,很多监控和日志并不完整,如发生OOM时自动dump文件等。好在此次服务未受到影响,所有有足够的时候给我们去排查和验证。特此结合此次排查的过程的一些收获记录一下,假设下次还有发生可以有所参考:

  • 如果对业务产生了影响,服务不可用或者进程直接没有了,首先需要快速止损,包括熔断、重启、手动GC等方式。但是操作前需要先dump出内存快照方便问题排查,命令如下:
ini 复制代码
#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日志打印相关的参数:

ruby 复制代码
# Java8及以下
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:<path>

# Java9及以上
-Xlog:gc*:<path>:time
  • 如果对业务无影响,可以先观察现象进行排查。如果是近期有业务增加,则可以考虑是否需要扩容,如果是周期性的表现,则可以排查定时任务。
  • 对导出的内存快照文件使用MAT等工具进行分析,一般会比较直观的看到当前堆内情况。
  • 如果服务还可用,也可以使用命令进行排查,Jdk本身也已经提供非常多轻量的小工具,主要用于监视虚拟机运行状态和进行故障处理,常用的如下(还有一些第三方的工具也很好用,如arthas等):
xml 复制代码
jstat -gcutil <pid> :监视Java堆状况,主要关注已使用空间的百分比和GC情况

jmap -heap <pid> :显示Java堆详细信息

jmap -histo[:live] <pid> :显示堆中对象统计信息:快速识别哪些类的实例占用了大量的堆内存
  • 如果是栈溢出,抛出StackOverflowError异常,需要着重检查是否有死循环或者代码调用链路不合理。
  • 还有可能是堆外内存泄露(元空间、直接内存等),此情况一般较少发生,排查起来也更加复杂,小菜同学对于这部分的实操有限,这里就不详细分析了,还需要继续学习。

5 总结

面对内存使用率的异常,我们不仅要关注即时的解决方案,还需要考虑如何从根本上避免此类问题的再次发生。如使用优化数据结构、减少不必要的计算、采用懒加载等策略,以及建立细致的内存监控体系等。性能优化不应该仅仅是面对问题时的临时行动,而应该成为开发文化的一部分。

希望这次的经历和总结能够为未来可能遇到的类似问题提供一点参考的价值,也希望屏幕前的大佬对小菜同学写的不对或者遗漏的地方不吝指正,非常感谢!

参考资料:

《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 - 周志明 著

Oracle Java Documentation

JVM内存问题排查Cookbook


关于作者

蔡闯,金融技术部后端研发工程师。 Talk is cheap, show me the code!

> 转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
> 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
程序员蜗牛28 分钟前
9个Spring Boot参数验证高阶技巧,第8,9个代码量直接减半!
后端
yeyong30 分钟前
咨询kimi关于设计日志告警功能,还是有启发的
后端
库森学长36 分钟前
2025年,你不能错过Spring AI,那个汲取了LangChain灵感的家伙!
后端·openai·ai编程
Java水解1 小时前
Spring Boot 启动流程详解
spring boot·后端
学历真的很重要1 小时前
Claude Code Windows 原生版安装指南
人工智能·windows·后端·语言模型·面试·go
转转技术团队1 小时前
让AI成为你的编程助手:如何高效使用Cursor
后端·cursor
shellvon1 小时前
你怎么被识别的?从TLS到Canvas的设备追踪术
后端·算法
yinke小琪1 小时前
消息队列如何保证消息顺序性?从原理到代码手把手教你
java·后端·面试
考虑考虑2 小时前
Java实现墨水屏点阵图
java·后端·java ee
网安Ruler2 小时前
第49天:Web开发-JavaEE应用&SpringBoot栈&模版注入&Thymeleaf&Freemarker&Velocity
java·spring boot·后端