一次真实的 JVM OOM 排查:从 Arthas 定位到批量任务内存优化

做 JVM 调优时,一个比较容易走进的误区,是看到 OutOfMemoryError 就开始调整 -Xmx、垃圾回收器或者 GC 参数。但实际项目里,很多 OOM 并不是 JVM 参数配置得不够好,而是应用在某个时间窗口内制造对象的速度和规模,已经超过了 JVM 能够承受的范围。

之前做学生体质健康相关项目时,我们就遇到过这样一个问题。

系统平时运行一直比较稳定,接口响应、CPU 和内存都没有明显异常,但到了学期末体测数据集中导入的时候,服务偶尔会突然出现 Full GC,随后接口越来越慢,严重时直接抛出 java.lang.OutOfMemoryError: Java heap space

最开始我们也把它当成一个 JVM 内存配置问题处理,但用 Arthas 和 JVM 工具真正把内存里的对象看清楚以后,才发现问题的根源其实藏在业务处理方式里。

从一次 Excel 导入开始的 OOM

这个业务本身并不复杂。

学校完成体测以后,会通过 Excel 批量导入学生成绩。一个学生并不是只有一条简单的分数记录,而是包含身高、体重、肺活量、50 米跑、坐位体前屈、立定跳远、引体向上或者仰卧起坐、耐力跑等多个体测项目。

原始数据进入系统以后,还不能直接保存。

系统需要根据学生的年级、性别以及不同项目的原始成绩匹配对应评分标准,计算单项得分,再根据既定公式计算总分,最后得到优秀、良好、及格、不及格等评价等级。

整个处理过程大致可以理解成:

单看一个学生,这个计算量其实非常小。

真正的问题出现在“批量”。

假设一次 Excel 导入 5000 名学生,每个学生有十几个体测相关字段。解析以后,系统除了 Excel 原始对象,还会产生学生对象、项目成绩对象、评分标准匹配结果、中间计算对象以及最终成绩对象。

如果整个导入过程都是一次性处理,那么 JVM 看到的并不是“5000 个学生”,而可能是短时间内出现的数万甚至更多相互引用的 Java 对象。

平时只有一个老师导入时,这种问题可能并不明显。

但学期末是体测高峰期,多个学校、多个班级集中上传数据。只要同时出现几个大 Excel:

用户 A:5000 名学生
用户 B:6000 名学生
用户 C:4000 名学生
用户 D:5000 名学生

这些任务同时进入应用,内存占用就会快速上涨。

这也是这个问题比较隐蔽的地方:它不是稳定复现,而是和数据量、并发量以及 GC 时机共同相关。


OOM 出现以后,我们先看 JVM 到底发生了什么

第一次遇到 OOM 时,很自然的想法就是:

是不是 JVM 堆给小了?

例如原来的配置是:

-Xms2g
-Xmx2g

那么最直接的处理方式似乎就是:

-Xms4g
-Xmx4g

这样确实可能让问题暂时消失。

但它并不能回答一个更重要的问题:

这 2GB 内存究竟被什么东西占满了?

如果只是正常业务确实需要更大的堆,那么扩大 Xmx 是合理的;但如果是某个批处理任务在短时间内创建了大量对象,单纯增加堆内存实际上只是把 OOM 出现的时间向后推。

所以当时没有继续盲目调整 JVM 参数,而是先通过 Arthas 观察线上 JVM 的实际状态。

进入目标 Java 进程以后,首先看的不是某个具体方法,而是 JVM 的整体状态。

dashboard

dashboard 可以很快看到线程、CPU、堆内存以及 GC 等基本情况。

当批量任务开始以后,比较明显的现象就是 Old Generation 的占用不断增加,GC 次数也随之上升。正常情况下,业务产生的大量短生命周期对象经过 Young GC 后应该被回收,但如果对象仍然被当前批处理流程引用,它们就不能被释放。

这时候问题开始从:

JVM 为什么 OOM?

变成:

为什么这些对象经过 GC 以后还活着?

这是排查方向真正发生变化的地方。


Young GC 很频繁,并不一定是最危险的信号

很多人第一次观察 JVM 时,会特别关注 Young GC 次数。

实际上,对于一个不断处理请求的 Java 服务来说,Young GC 本来就是正常现象。大量临时对象创建在 Eden 区,经过 Minor GC/Young GC 很快消失,只要暂停时间和频率处于合理范围,并不一定存在问题。

真正需要警惕的是另一种趋势:

业务开始
   ↓
Young GC
   ↓
Old Gen  800 MB

继续处理
   ↓
Young GC
   ↓
Old Gen  1.2 GB

继续处理
   ↓
Young GC
   ↓
Old Gen  1.6 GB

继续处理
   ↓
Full GC
   ↓
Old Gen  仍然 1.5 GB

也就是说,GC 在工作,但回收不下来。

如果 Full GC 前后 Old Gen 占用下降非常有限,就说明大量对象依然是存活对象。

这和真正的内存泄漏还有区别。

Java 里的 OOM 并不意味着一定存在传统意义上的 Memory Leak。对象可能完全符合业务逻辑,也确实还存在引用,只不过同一时间存活的对象实在太多了。

我们这个问题最终就属于后者。

不是某个静态 Map 永远保存对象,也不是 ThreadLocal 忘记清理,而是批量计算过程中需要等待后续步骤使用的数据太多,导致大量对象在一个较长的时间窗口内同时存活。

可以把它理解成:

             JVM Heap

┌──────────────────────────────────────┐
│                                      │
│  Excel 原始数据                       │
│  █████████████                       │
│                                      │
│  学生对象                             │
│  █████████████████                   │
│                                      │
│  体测项目对象                         │
│  █████████████████████████           │
│                                      │
│  评分计算中间对象                     │
│  ███████████████████                 │
│                                      │
│  最终成绩对象                         │
│  ███████████                         │
│                                      │
└──────────────────────────────────────┘

       GC 想回收
          ↓
     对象仍然被引用
          ↓
      无法释放

【这里很适合配第二张图:JVM Heap 中随着批量任务推进不断堆积的几类业务对象,GC 在旁边但无法大量回收。】

这张图比画复杂的 JVM 新生代/老年代结构更有意义,因为它直接解释了这次事故。


Arthas 帮我们把问题从 JVM 定位到了业务代码

确定“内存确实回收不下来”以后,下一步就不是继续盯着 GC 次数,而是找到究竟哪些对象占用了这些内存。

当时主要通过 Arthas 去观察应用运行状态、线程以及相关业务方法,再结合 JVM 堆信息判断对象增长情况。

例如:

memory

可以快速观察 Heap、Eden、Survivor、Old Gen 等区域的使用情况。

再通过:

thread

观察当前比较繁忙的线程。

如果已经知道批量计算对应的类,还可以进一步使用:

monitor com.xxx.service.ScoreCalculateService calculate

观察方法调用次数、成功率和耗时。

或者:

trace com.xxx.service.ScoreCalculateService calculate

继续向下看计算过程中时间主要消耗在哪些调用上。

这里需要注意,tracewatch 这类命令在生产环境不能毫无节制地使用。尤其是调用频率非常高的方法,观察范围应该尽量缩小,避免排查工具本身给线上服务增加额外压力。

如果怀疑是某类对象大量堆积,还可以进一步生成 Heap Dump:

jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

或者在 JVM 启动参数里提前加:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump/

这样真正发生 OOM 时,可以保留下当时的堆现场。

Heap Dump 再通过 MAT、VisualVM 等工具分析,就可以进一步回答:

哪些对象数量最多?

哪些对象占用内存最大?

为什么这些对象没有被 GC?

这时候关注的通常不只是 Shallow Heap,而是 Retained Heap 和对象之间的引用链。

例如一个 StudentScore 对象本身可能只有几百字节,但如果某个:

List<StudentScore>

一直持有几万个学生对象,而每个学生对象又引用十几个项目成绩对象,那么真正被这个 List 间接“保住”的内存可能非常大。

这也是 JVM 内存问题里一个很重要的判断:

不要只看某一个对象有多大,要看是谁让一大片对象无法被 GC。


最终发现,问题并不在某一个“大对象”

随着排查深入,我们发现没有一个特别夸张的对象,比如某个对象直接占用了 1GB 内存。

真正的问题反而更符合实际业务。

一个学生对象可能并不大:

Student
 ├── 基础信息
 ├── 身高体重
 ├── 肺活量
 ├── 50米
 ├── 立定跳远
 ├── 坐位体前屈
 ├── 耐力跑
 └── ...

但整个计算过程还会生成不同阶段的数据:

Excel Row
    ↓
StudentImportDTO
    ↓
PhysicalTestData
    ↓
ScoreCalculateContext
    ↓
ProjectScore
    ↓
StudentTotalScore
    ↓
EvaluationResult

假设一个学生的完整处理过程最终关联了几十个 Java 对象,那么:

5000 个学生
×
几十个相关对象

就已经是非常可观的对象数量。

再乘以几个并发导入任务,问题就完全不同了。

所以这次 OOM 最后可以概括成一句话:

不是单个对象太大,而是同一时间活着的业务对象太多。

这也是为什么单纯调 JVM 参数很难真正解决。


为什么把 Xmx 调大不是最终答案

发现堆内存不够以后,我们当然也评估过扩大 JVM Heap。

从 JVM 调优角度来看,这并没有错。

如果机器有足够的物理内存,而应用的正常工作集本来就比较大,合理提高:

-Xms
-Xmx

完全是正常的生产优化。

问题在于,我们这个场景中的内存需求随着:

Excel 数据量 × 单学生数据复杂度 × 同时导入任务数

增长。

可以粗略写成:

Memory ≈ N × S × C

其中:

  • N 是一次导入的学生数量;
  • S 是处理一个学生过程中形成的对象规模;
  • C 是同时执行的批量任务数量。

如果只是:

2 GB → 4 GB

能够解决当前问题,那么以后 Excel 更大或者并发再提高,很可能变成:

4 GB → 8 GB

继续增长以后仍然可能 OOM。

而且更大的堆也意味着 GC 需要管理更多对象。尤其是在 Old Gen 已经堆积大量存活对象的时候,单纯扩大堆空间并不能改变对象的生命周期。

因此我们最后没有把“扩大 JVM 内存”作为主要解决方案。

这次调优真正改变的是内存峰值


JVM 调优最后调的却不是 JVM

最后的处理方式其实很朴素。

Excel 上传以后,不再让整个任务立即一次性完成所有学生的解析、计算和入库,而是把任务拆开,通过队列控制消费速度,并限制一次进入计算阶段的数据量。

原来更接近:

一个大 Excel
      ↓
一次解析大量学生
      ↓
大量数据同时参与计算
      ↓
大量中间对象同时存活
      ↓
Heap 快速上涨
      ↓
Full GC
      ↓
OOM

调整以后变成:

             Excel 导入
                 │
                 ▼
             数据拆分
                 │
                 ▼
              Queue
                 │
          控制消费速度
                 │
                 ▼
        ┌─────────────────┐
        │  一小批学生数据   │
        └────────┬────────┘
                 ▼
          解析 / 评分 / 评价
                 │
                 ▼
               入库
                 │
                 ▼
        当前批次对象失去引用
                 │
                 ▼
                GC
                 │
                 ▼
              下一批

【这里建议放第三张也是最后一张图:优化前后对比。左边“大批量 → 内存峰值 → OOM”,右边“Queue → 分批处理 → 对象释放 → 下一批”。】

队列具体怎么设计、每批处理多少学生、失败如何重试,其实已经属于任务调度和业务架构问题了,这里不展开。

对 JVM 来说,最关键的变化只有一个:

以前是尽可能快地把数据全部加载和计算,现在是主动限制同一时间进入内存的数据规模。

对象生命周期缩短以后,前一批数据完成处理并失去引用,GC 就有机会真正回收这些对象。Old Gen 不再随着整个 Excel 的处理过程持续攀升,内存峰值也变得更加可控。


这次问题让我重新理解了 JVM 调优

这次 OOM 处理以后,我对“JVM 调优”这件事最大的感受,是不要过早把它理解成参数调优。

遇到:

java.lang.OutOfMemoryError: Java heap space

当然需要检查:

-Xms
-Xmx
GC
Young Gen
Old Gen
Full GC
Heap Dump

这些都是定位问题非常重要的工具。

但 JVM 提供的信息更多是在告诉我们:

应用究竟以什么方式使用内存。

真正要解决的问题可能在 JVM,也可能在代码的数据结构、对象引用关系、缓存策略、线程模型或者批处理方式。

这次事故里,Arthas 最有价值的地方并不是帮我们找到了一个“神奇的 JVM 参数”,而是让我们确认了一件事:GC 本身一直在工作,真正的问题是业务在短时间内维持了远超预期数量的存活对象。

从这个角度再回头看整个问题,解决方向就非常清楚了。

如果一次需要处理 5000 个学生,并不意味着 JVM 必须同时持有 5000 个学生完整计算过程中的所有数据。只要业务允许拆分,就可以让内存里始终只存在一个相对稳定的工作集。

所以后来再遇到类似的 JVM 内存问题,我通常不会第一时间问:

Xmx 应该设置多少?

而是会先问:

这块内存为什么必须同时存在?

很多时候,这个问题比任何一条 JVM 参数都更接近真正的答案。

发表评论