做 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
继续向下看计算过程中时间主要消耗在哪些调用上。
这里需要注意,trace、watch 这类命令在生产环境不能毫无节制地使用。尤其是调用频率非常高的方法,观察范围应该尽量缩小,避免排查工具本身给线上服务增加额外压力。
如果怀疑是某类对象大量堆积,还可以进一步生成 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 参数都更接近真正的答案。