JVM 的知识平时用不上,用上的时候基本都是线上事故。这篇按「结构 → 参数 → 工具 → 排查」的顺序整理,重点放在出问题时该敲什么命令、看什么指标。
一、运行时数据区,记住这一张图就够
堆内还要再分一层:新生代(Eden + Survivor0/1)和老年代。对象先落在 Eden,熬过一次 Minor GC 进 Survivor,年龄到阈值(默认 15)或 Survivor 放不下,就晋升老年代。
二、GC 怎么选
| 收集器 | 适用场景 | 关键参数 |
|---|---|---|
| G1(默认) | 绝大多数服务端场景,堆 4G–32G | -XX:MaxGCPauseMillis=200 |
| ZGC | 大堆、要求极低延迟(TB 级、暂停 <10ms) | -XX:+UseZGC,JDK 17+ 生产可用 |
| Parallel | 吞吐量优先、批处理任务 | -XX:+UseParallelGC |
| CMS | 已废弃,JDK 14 移除 | 不建议新项目使用 |
我自己的经验是:不要一上来就换收集器。90% 的 GC 问题是代码产生的垃圾太多(大对象、全表查询、一次性 list 塞几百万条),先把 -Xmx 调大观察,再看对象分配速率。换 ZGC 能缓解停顿,但挡不住内存泄漏。
三、常用启动参数(可直接抄)
java -server \
-Xms4g -Xmx4g \ # 堆大小固定,避免动态扩缩容抖动
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \ # OOM 自动留现场,必开
-XX:HeapDumpPath=/data/dump/ \
-Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=10,filesize=64m \
-XX:+PrintGCDetails \
-jar app.jar
-Xms 和 -Xmx 设成一样,是我认为最该养成的习惯:避免运行期扩容触发 Full GC。-XX:+HeapDumpOnOutOfMemoryError 更是必开,没有 dump 的 OOM 事后基本只能靠猜。
四、工具箱:出问题时敲什么
| 想看什么 | 命令 | 关注点 |
|---|---|---|
| GC 频率与耗时 | jstat -gcutil <pid> 1000 |
OU 老年代是否只涨不跌;YGC/FGC 次数增长斜率 |
| 实时 GC 情况 | jstat -gc <pid> 1s |
单次 FGC 耗时超过 1s 就要警惕 |
| 堆里谁最大 | jmap -histo <pid> \| head -30 |
类名 + 实例数,最快定位「哪个对象在涨」 |
| 留现场 | jmap -dump:format=b,file=heap.hprof <pid> |
会 STW,生产慎用,低峰期做 |
| 线程在干嘛 | jstack -l <pid> > stack.txt |
连续拉 3 次,看 BLOCKED 是否一直是同一批 |
| 一键诊断 | arthas(dashboard / thread -n 5 / heapdump) |
线上最省事的入口 |
| 看 JVM 参数 | jinfo -flags <pid> |
确认实际生效的 -Xmx 等 |
分析 dump 用 Eclipse MAT:打开后看 Leak Suspects 报告,再按 Dominator Tree 找占用最大的对象,右键 Path to GC Roots 看是谁在引用它 —— 这条链路就是泄漏点。
五、四种 OOM 的区分与处理
| 报错信息 | 含义 | 处理方向 |
|---|---|---|
Java heap space |
堆不够 / 内存泄漏 | 看 dump,找大对象;先排查是不是一次查了全表 |
Metaspace |
类加载过多 | 检查动态代理、反射、热部署、Groovy 脚本 |
unable to create new native thread |
线程数超系统限制 | ulimit -u;检查是不是有人在循环里 new 线程 |
Direct buffer memory |
堆外内存没释放 | Netty 的 ByteBuf 是否 release();-XX:MaxDirectMemorySize |
六、生产复盘:一次报表导出把堆撑爆
脱敏自某 B2B 电商平台的财务报表子系统,服务百万级零售商户。
现象:运营每月 1 号导出「商户月度订货汇总」,导到一半服务直接 OOM,同一个 Pod 上的其他接口一起 502。
排查:
- 现场有 dump(因为开了
HeapDumpOnOutOfMemoryError),MAT 打开,Dominator Tree里ArrayList占 1.8G,元素全是订单实体对象。 - 看代码,老实现是
orderMapper.selectAll()一次性捞全量,再在内存里做分组汇总、拼 Excel。 jstat观察老年代,导出开始后 OU 从 30% 一路涨到 100%,期间 FGC 十几次都回收不掉 —— 这些对象一直被局部变量引用着,GC 根本收不掉。
根因:一次性加载全量数据到内存,数据量随业务增长,内存参数没跟着变。数据量小时没问题,商户数涨到百万级就必然爆。
改造:
- 改用流式查询(MyBatis
Cursor)+ 分批写入,每批 5000 条,处理完即释放。 - Excel 导出换 EasyExcel,走
SXSSF的磁盘临时文件模式,不在内存里堆整个 workbook。 - 导出改成异步任务 + 文件下载,接口不再同步等待。
- 加
-Xmx之外的数据量硬上限:超过 50 万行强制走离线任务。
结论:OOM 的根因几乎从不在 JVM 参数上,而在「一次处理了多少数据」。调大 -Xmx 只是把爆的时间往后推,分批和流式才是解法。
七、调优的顺序(别颠倒)
- 先看是不是代码问题:全表查询、无限循环、大对象、缓存无上限。
- 再看参数:
-Xmx、新生代比例、MaxGCPauseMillis。 - 最后才换收集器:G1 换 ZGC 属于核武器,能解决停顿,解决不了泄漏。
调优的目标也不是「GC 次数越少越好」,而是在满足停顿时间的前提下,吞吐量可接受。盯着 P99 和业务指标调,比盯着 GC 日志调有用得多。
一起聊聊