Skip to content

JVM 调优方法论:参数、思路与常见场景

本文是 JVM & GC 系统学习系列的实战篇(L3)。前置:收集器演进史--调参数之前你得知道每个收集器在优化什么,不然参数就是玄学。 学完可以配合面试题食用:JVM 参数调优

先泼冷水:90% 的"调优"不是调 JVM 参数

线上服务 RT 突然涨了,很多人第一反应是改 JVM 参数。实际排查下来,大多数 GC 问题的根因在代码和架构:一次 SQL 查回来 50 万行装进 List,堆内存怎么调都白搭;缓存不设上限,堆只会越撑越大;一个泄漏的 ThreadLocal 让老年代涨到 Full GC 打止。

参数能做的事其实很有限:给对象分配和回收安排一个合理的空间布局,选定收集器,设置停顿目标。它治不了代码病。所以动手调参之前,先确认问题真的出在 JVM 层--方法就是方法论四步里的"定目标、采数据",后面细说。

一个可操作的判断顺序:

  1. GC 日志里停顿和频率正常,但 RT 高 --> 问题在业务代码或下游依赖,不是 GC
  2. GC 频率异常(比如 Minor GC 从分钟级变成秒级)--> 看分配速率,大概率代码里有大对象或循环创建
  3. 老年代只涨不降,Full GC 后也回收不掉 --> 内存泄漏或缓存无界,dump 分析,不是调参能救的

参数分层:三类参数各管一件事

JVM 参数上百个,但生产环境真正常用的就三层,每层解决一个问题。

内存布局层:决定对象放哪、每个区域多大。

bash
java -Xms4g -Xmx4g \        # 初始/最大堆,生产环境设成一样,避免运行期扩缩容抖动
     -Xmn1536m \            # 新生代,堆的 1/3 到 1/2 之间起步
     -XX:SurvivorRatio=8 \  # Eden : Survivor = 8:1:1,默认值,一般不动
     -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m

堆和新生代设好之后,老年代大小就定了(堆减新生代),不需要也没有参数直接设老年代。

收集器与停顿目标层:选收集器,告诉它你想要什么。

bash
# 吞吐优先(后台任务、批处理)
-XX:+UseParallelGC

# 延迟优先(在线服务,JDK 8 之后的默认选择)
-XX:+UseG1GC -XX:MaxGCPauseMillis=100   # 停顿目标,G1 会自适应调整年轻代大小来逼近它

# JDK 17+ 大堆低延迟
-XX:+UseZGC

注意 MaxGCPauseMillis 是软目标:设成 50ms 不代表每次 GC 都 50ms 以内,只代表 G1 会朝这个方向调整。设得越小,年轻代越小,GC 越频繁,吞吐越差--停顿和吞吐天生是 trade-off,不能既要又要。

排障兜底层:出事时留下证据,不设的话事后查无可查。

bash
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dump/ \                      # OOM 时自动 dump,文件名带进程号
-Xlog:gc*,gc+heap=debug:file=/log/gc.log:time,uptime,level,tags \
                                                     # JDK 9+ 统一日志;JDK 8 用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+ExitOnOutOfMemoryError                          # OOM 后直接退出,交给编排系统拉起,别带病运行

这套参数不管服务有没有问题都该默认带上。OOM dump 是事后定位内存泄漏的唯一可靠证据,等出事再想着加就晚了。

方法论:定目标 -> 采数据 -> 改一个变量 -> 验证

调优不是拍脑袋改参数重启,是受控实验。四步缺一不可。

第一步,定目标,量化到数字。"GC 有点频繁"不是目标,"Young GC 频率低于每 10 秒一次、单次停顿小于 50ms、Full GC 一天不超过一次"才是。目标得从业务指标反推:接口 P99 要求 200ms,那 GC 单次停顿预算可能只有 50ms,超出就得换收集器。

**第二步,采数据。**两份日志缺一不可:

  • GC 日志:频率、单次耗时、各代回收量。看趋势,不看单次
  • JFR(Java Flight Recorder):JDK 11+ 免费可用,能看到分配热点(哪个方法在狂建对象)、锁、IO。分配速率(allocation rate,MB/s)是最值得盯的数字,它直接决定 Minor GC 频率

**第三步,一次只改一个变量。**同时改了堆大小和收集器,指标变好了也不知道该谢谁,变坏了也不知道该怪谁。改动记录在案:谁、什么时候、改了什么、为什么。

**第四步,验证。**对比改动前后同负载下的指标,最好压测复现。改了没效果就回滚,别让参数表越来越像补丁堆。

mermaid
flowchart LR
    A[定目标<br/>量化 RT/吞吐/频率] --> B[采数据<br/>GC 日志 + JFR]
    B --> C[改一个变量<br/>记录在案]
    C --> D[验证<br/>同负载对比]
    D -->|指标达标| E[固化参数]
    D -->|没效果| B

三个真实场景的排查路径

**场景一:频繁 Full GC,CPU 常态 100%。**GC 日志显示 FGC 每小时几十次,每次回收后老年代占用只降一点点。这是典型的内存泄漏或缓存无界。路径:开 HeapDumpOnOutOfMemoryError 等下次 dump,或者手动 jmap -dump:live,MAT 打开看 Dominator Tree,通常能直接定位到某个静态 Map 持着几万个 entry。修代码,别调参。

**场景二:大对象直接进老年代。**HBase scan 场景,一次拉回的 Result 列表几十 MB,超过 G1 的 Region 一半就直接分配到老年代(humongous allocation),老年代快速涨满触发并发标记周期,标记跟不上分配就退化成 Full GC。GC 日志里能看到特征:Pause Young 正常,但混着大量 humongous allocation 记录。对策两条:业务侧改成分批 scan;或者调大 -XX:G1HeapRegionSize(默认按堆大小取 1-32MB,大堆可显式设 32m),让单次 scan 不至于触发大对象分配。

**场景三:本地缓存撑大堆。**服务里塞了个几 GB 的 Guava Cache,堆设到 8G 还是频繁 GC。缓存对象存活时间长,每次标记都要扫它们,但它们基本不死。这个场景下参数怎么调都不划算,正解是把缓存挪出堆:换 Caffeine 配合 W-TinyLFU 限制容量,量大就上 Redis。堆留给短命的业务对象,GC 效率立刻回来。

常见误区与小结

  • 盲目调 NewRatio/SurvivorRatio:CMS 时代调分代比例是常规操作,但 G1 会动态调整年轻代大小来满足停顿目标,手动设 -Xmn 反而废掉这个自适应能力(G1 下除非确有理由,别固定年轻代大小)
  • 容器里忽略内存 limits:JDK 8u191 之前的 JVM 不感知 cgroup,容器 limits 4G、JVM 默认堆按宿主机 32G 内存算,直接被 OOMKilled。升级 JDK 或显式设 -Xmx,且给堆外留余量:limits 4G,堆最多 3G,剩下给元空间、线程栈、直接内存
  • 把 MaxGCPauseMillis 当硬承诺:它是 G1 的调节方向,不是保证。停顿不达标先看大对象和分配速率,再考虑收紧目标或换 ZGC
  • 没有 GC 日志就上线:等于开车不装仪表盘。统一日志参数全服务默认带上,成本几乎为零

小结:调优的主体是代码和架构,参数负责空间布局和停顿目标。流程上定目标、采数据、单变量、验证,四步走完才算一次合格的调优。本系列下一篇是JVM 排障工具箱,讲 jps/jstat/jmap/jstack 和 JFR 在现场怎么组合使用。

参考

参考:《深入理解 Java 虚拟机(第 3 版)》第 4 章调优案例;Oracle 官方文档 Tuning the JVM;JEP 158 Unified JVM Logging

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1