Java 与 JVM 列表全部文章
Java 与 JVM
技术笔记›Java 与 JVM第 3 / 6 篇

JVM 调优与线上排查笔记:内存结构、GC 选型、OOM 怎么查

一份能照着操作的 JVM 笔记:运行时数据区、对象创建与回收、G1/ZGC 怎么选、常用启动参数、jstat/jmap/jstack/arthas 的用法,以及一次大报表导出把堆撑爆的真实排查过程。

更多

JVM 的知识平时用不上,用上的时候基本都是线上事故。这篇按「结构 → 参数 → 工具 → 排查」的顺序整理,重点放在出问题时该敲什么命令、看什么指标。

一、运行时数据区,记住这一张图就够

线程共享 堆 Heap 对象实例 / 数组,GC 主战场 元空间 Metaspace 类元数据(JDK8 后取代永久代) 常量池 字符串常量池 线程私有 虚拟机栈:栈帧(局部变量表 / 操作数栈) 本地方法栈:Native 方法 程序计数器:下一条指令地址 直接内存(堆外,NIO/Netty 常用,不受 -Xmx 限制)

堆内还要再分一层:新生代(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。

排查:

  1. 现场有 dump(因为开了 HeapDumpOnOutOfMemoryError),MAT 打开,Dominator Tree 里 ArrayList 占 1.8G,元素全是订单实体对象。
  2. 看代码,老实现是 orderMapper.selectAll() 一次性捞全量,再在内存里做分组汇总、拼 Excel。
  3. jstat 观察老年代,导出开始后 OU 从 30% 一路涨到 100%,期间 FGC 十几次都回收不掉 —— 这些对象一直被局部变量引用着,GC 根本收不掉。

根因:一次性加载全量数据到内存,数据量随业务增长,内存参数没跟着变。数据量小时没问题,商户数涨到百万级就必然爆。

改造:

  • 改用流式查询(MyBatis Cursor)+ 分批写入,每批 5000 条,处理完即释放。
  • Excel 导出换 EasyExcel,走 SXSSF 的磁盘临时文件模式,不在内存里堆整个 workbook。
  • 导出改成异步任务 + 文件下载,接口不再同步等待。
  • 加 -Xmx 之外的数据量硬上限:超过 50 万行强制走离线任务。

结论:OOM 的根因几乎从不在 JVM 参数上,而在「一次处理了多少数据」。调大 -Xmx 只是把爆的时间往后推,分批和流式才是解法。

七、调优的顺序(别颠倒)

  1. 先看是不是代码问题:全表查询、无限循环、大对象、缓存无上限。
  2. 再看参数:-Xmx、新生代比例、MaxGCPauseMillis。
  3. 最后才换收集器:G1 换 ZGC 属于核武器,能解决停顿,解决不了泄漏。

调优的目标也不是「GC 次数越少越好」,而是在满足停顿时间的前提下,吞吐量可接受。盯着 P99 和业务指标调,比盯着 GC 日志调有用得多。

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人