Java JVM 调优与 JDK8 GC 实战

从采集证据、识别瓶颈到收集器选型,系统梳理 JDK8 下 Serial、Parallel、CMS、G1 的调优方法

Posted by Ekko on July 4, 2026

这篇笔记的目标,不是把 JVM 调优写成一份参数清单,而是把它还原成一个可执行的排障过程:先判断问题到底是吞吐、延迟、内存占用还是对象生命周期失衡,再结合 GC 日志堆使用率线程状态堆外内存 证据决定是否改参数、改多大、要不要换收集器。

文章范围限定在 HotSpot JDK8。重点关注生产里最常见的 4 类收集器组合:SerialParallelCMSG1。它不展开 JDK9+ 的统一日志体系,也不讨论 ZGCShenandoah 这一类不属于 JDK8 主线生产选项的收集器。

参考资料:

官方文档:HotSpot VM Garbage Collection Tuning GuideAvailable CollectorsErgonomicsSizing the Generations

收集器细节:The Parallel CollectorConcurrent Mark Sweep (CMS) CollectorGarbage-First Garbage CollectorG1 Tuning

诊断与排障:Java SE Troubleshooting GuideVisualVMEclipse MAT

[TOC]


一、先给最短答案

如果只用一句话概括 JVM 调优:

JVM 调优的本质,不是“把堆调大一点”,而是让对象分配速度、对象存活时间、GC 回收节奏和业务延迟目标重新匹配。

因此大多数调优动作都绕不开 4 个问题:

  1. 当前问题到底是 吞吐下降停顿过长内存泄漏,还是 配置与业务模型不匹配
  2. 现在线上使用的是哪种收集器,它追求的是吞吐、低停顿还是小内存占用。
  3. 问题发生在 年轻代老年代元空间,还是 线程栈 / 直接内存 / 本地内存
  4. 这件事应该通过 改参数 解决,还是必须回到代码层修复对象滞留、缓存膨胀和线程泄漏。

很多“调优无效”的根源,不是参数不够多,而是问题类型判断错了。


二、JVM 调优到底在调什么

站在生产视角看,JVM 调优通常只围绕 5 类目标展开:

调优目标 关注指标 常见症状 第一反应
降低停顿时间 GC pause、接口 P99、交易超时数 单次 GC 停顿把请求打穿 优先考虑低停顿收集器与年轻代节奏
提高吞吐 GC time / wall clock、CPU 利用率 CPU 大量消耗在 GC,业务线程得不到时间片 优先看 Parallel GC 或适当放宽停顿目标
稳定内存曲线 Old 占用、Full GC 次数、回收后占用 老年代持续上升,回收后不下降 先排查泄漏,再讨论阈值与堆大小
控制资源占用 RSS、容器内存、堆外内存、线程数 容器被 OOM Kill,系统 swap 抖动 先核算总内存预算,不急着增大 -Xmx
缩短故障定位时间 GC 日志、heap dump、线程 dump 完整性 故障复现了但没有证据 先补日志、dump、监控埋点

可以把调优对象进一步拆成两层:

  • 堆内: 新生代、老年代、元空间、对象晋升、碎片与回收节奏。
  • 堆外: 线程栈、本地内存、直接内存、JIT 代码缓存、JNI 相关开销。

这也是为什么有些问题看起来像 GC,实际根因却是线程数爆炸、Netty DirectBuffer 失控或类加载器泄漏。


三、不要先改参数,先采证

调优前最重要的动作是把证据链补齐。没有证据,任何参数都只是猜测。

1. 最低限度要采集什么

证据 作用 建议做法
GC 日志 判断 GC 频率、停顿、晋升和 Full GC 原因 JDK8 开启 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
堆 dump 判断是否内存泄漏、谁在保活对象 开启 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/heap.hprof
线程 dump 判断业务线程阻塞、锁竞争、GC 相关停顿感知 卡顿时连续抓 3 份 jstack
进程参数 防止“以为线上参数是 A,实际跑的是 B” jcmd <pid> VM.flagsjinfo -flags <pid>
运行时统计 快速判断年轻代、老年代、FGC 次数 jstat -gcutil <pid> 5s 20

2. 建议保留的 JDK8 启动参数

1
2
3
4
5
6
7
8
9
10
11
12
-server
-Xms4g
-Xmx4g
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/java/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/data/logs/java/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=20M

这组参数的价值,不在于“已经调优完成”,而在于故障发生时至少能留下可分析的数据。

3. 一张图看调优顺序

graph TB
    A[业务报警: 延迟升高 或 OOM] --> B[确认线上 JVM 参数]
    B --> C[查看 GC 日志与 jstat]
    C --> D{问题类型}
    D --> E[年轻代过频 GC]
    D --> F[老年代回收差]
    D --> G[Full GC 频繁]
    D --> H[非堆问题]
    E --> I[调整年轻代与对象晋升节奏]
    F --> J[分析泄漏、碎片或收集器不匹配]
    G --> K[核对堆大小、收集器、元空间与大对象]
    H --> L[排查线程数、直接内存、类加载器]
    I --> M[回归验证]
    J --> M
    K --> M
    L --> M

四、HotSpot JDK8 支持的主流收集器组合

虽然 JDK8 支持多个收集器部件,但生产调优一般按“组合”来理解,而不是把每个部件拆开单独选。

收集器组合 开启参数 核心目标 优势 典型短板 典型场景
Serial -XX:+UseSerialGC 小堆、低开销 实现简单、单核环境成本低 停顿时间随堆增大而明显上升 小工具、单核容器、开发测试环境
Parallel -XX:+UseParallelGC 吞吐优先 多线程回收、单位时间处理量高 高峰停顿通常不够平滑 批处理、离线任务、报表服务
CMS -XX:+UseConcMarkSweepGC 低停顿优先 老年代并发回收,适合响应时间敏感业务 会产生碎片,参数较多,容易因晋升失败触发 Full GC 交易、订单、实时接口服务
G1 -XX:+UseG1GC 在吞吐和停顿间折中 Region 化、可按暂停目标调节、适合大堆 参数误用时容易吞吐下降,Humongous 对象要重点关注 中大型堆、混合负载、逐步替换 CMS

再换一个更偏实战的理解方式:

业务诉求 优先考虑 不要先做的事
单机资源很少,服务也不大 Serial 一上来切 G1 期待“自动变快”
追求任务完成速度 Parallel CMSG1 去换取并不需要的低停顿
交易链路怕抖动 CMSG1 只因为“默认配置”继续使用吞吐型 GC
堆大、对象生命周期复杂 G1 继续把 CMS 参数堆到很复杂

需要注意两点:

  1. ParNew 在 JDK8 里主要和 CMS 搭配出现,生产讨论时通常把它归到 CMS 组合一起看。
  2. G1JDK8 不是默认收集器,迁移时不能把旧的 CMS / Parallel 参数原样带过去。

五、先学会看现象,再决定换不换 GC

很多场景不是“当前收集器不行”,而是“当前收集器和业务目标不匹配”。

1. 适合继续保留当前 GC 的信号

  • 停顿可接受,且 Full GC 很少发生。
  • 回收后老年代占用能明显回落。
  • GC 总耗时占比不高,CPU 主要花在业务线程上。
  • 线上问题更像对象泄漏、缓存不受控,而不是收集器选型错误。

2. 更适合考虑切换 GC 的信号

  • Parallel 下单次停顿过长,已经影响接口 P99 / P999
  • CMS 下碎片明显,出现 concurrent mode failure 或晋升失败。
  • 堆已经较大,CMS 参数越来越多,维护复杂度高。
  • 需要兼顾较大的堆和更稳定的停顿,而不是单纯冲吞吐。

3. 一张决策表

现象 更可能的问题 优先动作
Young GC 很频繁,但每次都能快速回收 新生代偏小或对象创建速度太高 先核对对象分配速率,再考虑年轻代大小
Young GC 不频繁,但对象大量晋升 Survivor / 晋升阈值不合适,或对象存活时间变长 看年龄分布,再决定是否调 SurvivorRatioMaxTenuringThreshold
Old GC 后占用还是很高 泄漏、大缓存或长生命周期对象过多 先分析 dump,不急着扩堆
Full GC 一来就卡很久 收集器选型不匹配、堆太小或碎片严重 先看是 CMS 碎片、G1 Humongous 还是单纯内存不足

六、JDK8 调优里最常用的一组参数

下面这张表更适合作为“参数地图”,而不是“上线模板”。

参数 作用 常见用法 什么时候碰它
-Xms / -Xmx 设置堆初始值与最大值 通常设为相同,减少运行时扩缩容抖动 堆明显不足或过度保守时
-Xmn 直接设置年轻代大小 传统 GC 下常用,G1 下通常不建议固定 只在 Serial / Parallel / CMS 明确需要时考虑
-XX:NewRatio 年轻代与老年代比例 比显式 -Xmn 更柔和 需要调代际比例又不想写死大小时
-XX:SurvivorRatio Eden 与 Survivor 比例 影响对象在年轻代的缓冲空间 晋升过快、Survivor 太紧时
-XX:MaxTenuringThreshold 最大晋升年龄 延迟对象进入老年代 中等寿命对象较多时
-XX:ParallelGCThreads 并行 GC 线程数 控制 ParallelG1 等收集器的 GC 工作线程 CPU 被 GC 抢占过多或线程太少时
-XX:ConcGCThreads 并发 GC 线程数 常见于 CMS / G1 并发标记跟不上业务分配速度时
-XX:MetaspaceSize / -XX:MaxMetaspaceSize 元空间阈值与上限 控制类元数据增长 类加载很多、动态代理多时
-XX:InitiatingHeapOccupancyPercent G1 并发标记触发阈值 控制何时开始老年代并发回收 G1 老年代回收偏晚时
-XX:CMSInitiatingOccupancyFraction CMS 触发阈值 例如 7075 CMS 启动偏晚导致 Full GC 时

有一个非常重要的边界:

G1 依赖自适应年轻代来满足停顿目标,因此不要沿用 CMS / Parallel 时代那种强行固定 -Xmn 的习惯。


七、实战案例一:单核小服务使用 Serial GC

场景

  • 业务类型:内部配置中心或轻量任务调度器
  • 机器规格:1C2G
  • 堆大小:512m
  • 目标:资源占用小,业务吞吐压力不高

现象

  • 服务偶发停顿,但单次停顿可接受。
  • CPU 资源很紧,GC 线程一多反而和业务线程争抢。
  • 大部分时间堆使用率不高,Full GC 也不频繁。

为什么 Serial 可能更合适

在这种场景里,GC 的核心矛盾不是“停顿太长”,而是“资源太少”。Serial GC 用单线程回收,虽然停顿模型简单,但不会为了并行回收再额外制造线程竞争。

推荐起步参数

1
2
3
4
5
6
7
8
-Xms512m
-Xmx512m
-XX:+UseSerialGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/java/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/java/gc.log

调优重点

观察点 结论方向 动作
Minor GC 次数很多,但停顿很短 小堆下正常现象 先不动
Full GC 偶尔出现,回收后占用明显下降 更多像临时流量波动 先观察业务高峰
Full GC 频繁,回收后仍接近满堆 更像内存不足或泄漏 优先分析对象存活情况

这类场景常见误区

  • 误以为 G1 一定比 Serial 更先进,因此必须切换。
  • 1C 环境里同时开太多 GC 线程,结果上下文切换反而更重。
  • 服务本身几乎没有性能压力,却因为“统一模板”套上大堆和复杂 GC 参数。

结论通常很朴素:如果服务小、机器小、停顿可接受,Serial 不一定是问题。


八、实战案例二:批处理任务使用 Parallel GC

场景

  • 业务类型:夜间报表、离线结算、批量导入导出
  • 机器规格:8C16G
  • 堆大小:8g
  • 目标:总任务执行时间尽可能短,对单次暂停不敏感

现象

  • 接口延迟不是核心指标,但 GC 总时间占比偏高。
  • 单次停顿即使达到数百毫秒,业务也能接受。
  • 更关心任务在固定时间窗内是否完成。

为什么优先看 Parallel

Parallel GC 的核心价值是吞吐优先。对于批处理任务来说,只要总处理时间更短、CPU 能更充分地用起来,偶发较长停顿通常不是首要矛盾。

推荐起步参数

1
2
3
4
5
6
7
8
9
-Xms8g
-Xmx8g
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:ParallelGCThreads=8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/data/logs/java/gc.log

观察与调优思路

现象 判断 常见动作
Young GC 很频繁,但停顿总量仍可接受 对象创建量大,但吞吐仍可控 先看业务对象分配是否合理
CPU 大量被 GC 占用 GC 线程数、年轻代大小或对象分配速率不平衡 调整 ParallelGCThreads,必要时扩大堆
Full GC 次数上升 老年代压力增大 看是否缓存累积、批次峰值过大或堆太小

一个典型优化过程

  1. 初始配置 -Xmx4g,任务高峰时 FGC 多次出现,总耗时 48 分钟。
  2. 把堆提升到 8g 后,FGC 基本消失,但 Young GC 仍偏频繁。
  3. 结合日志发现对象主要是批次中间结果,生命周期集中在年轻代。
  4. 保留 Parallel GC,适当增加年轻代比例后,任务总耗时下降到 36 分钟。

关键结论

这类场景里,最有效的动作往往是:

  • 先确认堆是否足够承载批次峰值。
  • 再看 GC time 是否真的影响总吞吐。
  • 最后才是细调线程数和代际比例。

如果业务目标是“任务尽快跑完”,Parallel GC 往往比低停顿收集器更直接。


九、实战案例三:交易接口使用 CMS

场景

  • 业务类型:订单、支付、库存扣减、实时风控
  • 机器规格:8C32G
  • 堆大小:12g
  • 目标:控制停顿时间,避免交易链路尖刺

现象

  • 平均响应时间不错,但 P99 / P999 在高峰期明显抖动。
  • GC 日志显示老年代回收停顿开始变长。
  • 偶尔出现 promotion failedconcurrent mode failure

为什么 CMS 曾经很常见

JDK8 时代,CMS 是大量低延迟系统的常用选择。它把老年代回收的大部分工作放到并发阶段做,思路就是“少停业务线程,尽量在后台回收”。

CMS 的难点也非常典型:

  • 不做压缩整理,容易产生碎片。
  • 触发太晚,可能在并发回收还没完成时就把老年代打满。
  • 老年代晋升压力一高,就容易从低停顿退化成耗时 Full GC。

推荐起步参数

1
2
3
4
5
6
7
8
9
10
-Xms12g
-Xmx12g
-XX:+UseConcMarkSweepGC
-XX:+CMSParallelRemarkEnabled
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=70
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-Xloggc:/data/logs/java/gc.log

调优过程示例

1. 第一个问题:CMS 启动太晚

如果 CMS 总是在老年代已经很高时才启动,并发回收又赶不上分配速度,就会触发后备 Full GC

这种情况下优先动作通常是:

  • 提前 CMS 触发阈值,例如从默认逻辑改为 70 左右。
  • 观察并发回收是否能在高峰期间完成。
  • 同时核对业务侧是否存在大缓存、会话对象或批量积压。

2. 第二个问题:碎片导致大对象分配失败

CMS 的老年代是典型碎片高风险区。即使总剩余空间够用,也可能因为连续空间不足而触发 Full GC

这种场景的信号包括:

  • 老年代使用率不算满,但仍发生 Full GC。
  • Full GC 后占用下降明显,说明不是纯泄漏。
  • 高峰期大对象或批量晋升更容易触发故障。

3. 第三个问题:年轻代过小导致晋升压力过大

如果年轻代太小,大量“本来还能再活一轮就死掉”的对象会过早晋升到老年代,CMS 压力就会持续增大。

因此 CMS 调优往往不是只改一个参数,而是同时看:

关注点 现象 动作
老年代触发阈值 CMS 启动太晚 调低 CMSInitiatingOccupancyFraction
Survivor 压力 对象过早晋升 看年龄分布,再调 SurvivorRatio 或晋升阈值
老年代碎片 大对象分配失败 评估是否继续使用 CMS,必要时迁移 G1

适合切换到 G1 的时机

  • 堆越来越大,CMS 参数越来越难维护。
  • 业务对低停顿依然敏感,但碎片问题反复出现。
  • 团队更希望用更统一的停顿目标模型,而不是长期手工压 CMS

十、实战案例四:中大型堆使用 G1

场景

  • 业务类型:中大型 Spring Boot 微服务、聚合查询服务、商品检索或营销活动系统
  • 机器规格:16C64G
  • 堆大小:16g24g
  • 目标:在吞吐可接受的前提下,把停顿控制在较稳定区间

为什么 G1 适合这类场景

G1Region 取代固定物理代区,并根据回收收益优先回收垃圾更多的区域。对于堆较大、对象生命周期混合、既想要吞吐又不想停顿太抖的系统,G1 通常比 CMS 更容易持续维护。

推荐起步参数

1
2
3
4
5
6
7
8
9
-Xms16g
-Xmx16g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:+ParallelRefProcEnabled
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/java/gc.log

一定要先记住的 3 个边界

  1. MaxGCPauseMillis 是目标,不是保证值。
  2. G1 依赖自适应年轻代,不要先把 -Xmn 固定死。
  3. 大对象会走 Humongous 路径,图片、超大数组、超长 JSON 缓冲要重点观察。

调优过程示例

1. 初始问题:停顿偶尔超过目标

日志显示大多数暂停在 150ms 左右,但活动高峰会冲到 500ms+。这时不应该立刻堆更多参数,而是先分辨停顿来自哪一类阶段:

  • Young only collection 太重。
  • Mixed GC 回收集合过大。
  • RSet 更新和扫描开销过高。
  • Humongous 对象太多,触发了特殊路径。

2. 第一轮动作:只保留少量核心参数

迁移到 G1 时,比较稳妥的做法是:

  • 保留 -Xms / -Xmx
  • 保留 -XX:MaxGCPauseMillis
  • 保留必要的日志参数
  • 去掉过去给 CMS / Parallel 准备的大量年代参数

这样做的原因很简单:G1 的自适应策略和旧收集器不同,旧参数带过去可能直接把 G1 的调节空间锁死。

3. 第二轮动作:观察老年代并发标记是否启动过晚

如果混合回收开始太晚,老年代可能在高峰时持续逼近上限。此时可以结合日志,评估是否需要适度调低 InitiatingHeapOccupancyPercent

4. 第三轮动作:确认是否存在 Humongous 对象问题

一些系统把大 byte[]、大批量结果集、超大报文缓冲直接堆进内存,G1 下会更容易看到 Humongous 相关现象。此时往往更应该做的是:

  • 限制单次批量处理大小。
  • 改为流式处理。
  • 避免无边界缓存大数组和大字符串。

一个典型结果

某聚合查询服务从 CMS 迁移到 G1 后:

  • Full GC 从高峰期每天数次降到偶发。
  • P99900ms 左右回落到 350ms 左右。
  • 平均吞吐略有损失,但故障率和停顿尖刺明显下降。

这类结果说明:

当业务更怕长尾停顿而不是极致吞吐时,G1 往往是 JDK8 里更平衡的选择。


十一、如何区分“内存不够”和“代码泄漏”

这是 JVM 调优里最容易走偏的地方。

现象 更像堆太小 更像内存泄漏
高峰过去后堆占用明显回落
回收后老年代长期贴近上限
扩大 -Xmx 后故障显著推迟但仍会复发 可能 很可能
dump 里少数对象链条持有大量 Retained Heap

判断原则可以概括成两句:

  1. 如果回收后能降下来,优先怀疑容量与峰值不匹配。
  2. 如果回收后降不下来,优先怀疑对象根本不该活这么久。

因此下面这些问题,严格说都不是“单纯 GC 参数问题”:

  • ThreadLocal 没有清理。
  • 本地缓存没有上限。
  • 消息堆积形成长生命周期对象链。
  • 动态代理、脚本引擎、热部署导致类加载器泄漏。

十二、排障时最有用的几条命令

1. 看 JVM 进程

1
jps -l

2. 看当前 GC 和堆概况

1
jstat -gcutil <pid> 5s 20

3. 看最终生效参数

1
jcmd <pid> VM.flags

4. 看对象直方图

1
jmap -histo:live <pid>

5. 导出堆 dump

1
jmap -dump:format=b,file=/data/logs/java/heap.hprof <pid>

6. 抓线程栈

1
jstack <pid>

命令本身并不复杂,真正重要的是把它们和 GC 日志、业务监控时间点对齐。


十三、常见误区

误区一:只要 Full GC 了,就一定要换 GC

正确认知

Full GC 只是“JVM 认为必须扩大回收范围”的结果,不是“当前收集器落后”的同义词。它说明年轻代回收已经无法解决问题,但根因可能是容量不足、碎片、晋升失败、元空间压力、大对象分配失败,甚至是代码层对象泄漏。

底层机制

从原理上看,Full GC 的触发,往往意味着 JVM 需要处理的不再只是年轻代里的短命对象,而是要进一步处理老年代、元空间,或者执行一次更重的压缩整理与兜底回收。不同收集器触发 Full GC 的路径不同,但核心信号是一致的:

  • 普通 Young GC 已经无法提供足够的可用空间。
  • 老年代回收赶不上对象晋升或对象存活速度。
  • 当前堆布局发生了碎片、连续空间不足等结构性问题。

因此 Full GC 首先是内存状态恶化的表现,其次才是收集器选择问题。

典型日志信号

  • Full GC 次数明显升高,且发生后业务停顿明显。
  • CMS 日志中出现 concurrent mode failurepromotion failed
  • 出现 Metadata GC Threshold,说明可能是元空间压力而不只是堆问题。
  • Full GC 后老年代占用几乎不下降,说明更像对象长期存活或泄漏。

推荐排查动作

  1. 先看 Full GC 前后 OldMetaspace 占用是否回落。
  2. 再判断是堆容量不足、CMS 碎片、G1 大对象,还是元空间压力。
  3. 如果回收后占用仍然很高,优先抓 heap dump,不要先换 GC。
  4. 只有在确认业务目标和当前收集器目标不匹配时,才考虑迁移收集器。

补充知识点

可以先把 Full GC 理解成一个“故障症状分类入口”:

Full GC 后表现 更可能的根因 优先动作
占用大幅下降 堆偏小、峰值流量、碎片、分配失败 先看容量与分配模式
占用几乎不降 长生命周期对象、缓存膨胀、泄漏 优先分析 dump
伴随 Metadata GC Threshold 元空间紧张、类加载过多 看类加载器与动态代理
伴随 concurrent mode failure CMS 回收跟不上 看触发阈值与老年代压力

误区二:把堆调得越大越稳

正确认知

堆增大只是增加了对象缓冲区,不等于 JVM 更稳定。它可能降低 GC 频率,但也可能拉长单次回收时间、吞掉本地内存预算,并把原本应该尽早暴露的泄漏问题推迟。

底层机制

Java 进程的内存从来不只有堆。一次典型的生产进程至少包含:

  • Java 堆
  • 元空间
  • 线程栈
  • 直接内存
  • JIT 代码缓存
  • JNI 与其他本地内存

如果 -Xmx 不断增大,JVM 对象虽然有了更大的活动空间,但整个进程的 RSS 也会同步上升。容器或物理机看到的是进程总占用,不会因为“堆内还没满”就放过这个进程。

另一方面,堆越大,很多 GC 阶段的工作集也可能越大:

  • 老年代扫描范围更大。
  • 并发标记需要追踪的对象图更大。
  • 对象复制和压缩整理的成本也会更高。

所以“堆更大”换来的常常是“次数更少,但单次更重”。

典型日志信号

  • GC 次数下降了,但单次停顿时间明显拉长。
  • 堆占用看起来正常,但进程 RSS 持续接近容器上限。
  • 扩大 -Xmx 后问题只是延后出现,没有真正消失。
  • Full GC 后堆仍然居高不下,说明不是简单的容量不足。

推荐排查动作

  1. 先做总内存预算,不要只算堆。
  2. 观察 GC 次数和单次停顿是否在做不划算的交换。
  3. 核对线程数、直接内存、元空间使用,而不是只盯 heap usage
  4. 如果增大堆后问题只是推迟出现,优先怀疑泄漏或对象滞留。

补充知识点

更适合记住的是“容量”和“成本”要一起看:

动作 好处 代价
增大堆 降低 GC 频率、增加缓冲空间 可能拉长单次停顿,抬高总内存占用
缩小堆 让问题更早暴露、回收更快 容量缓冲变小,峰值更容易触顶

真正稳的配置,不是堆尽量大,而是总内存预算、业务峰值和收集器成本之间达到平衡。

误区三:G1 一定全面优于 CMS / Parallel

正确认知

GC 选型从来不是“新版本一定全面替代旧版本”,而是不同收集器在吞吐、停顿、内存占用和调优复杂度之间做不同取舍。

底层机制

ParallelCMSG1 的设计目标并不一样:

  • Parallel 追求高吞吐,核心是尽快完成回收,把更多总时间留给业务执行。
  • CMS 追求低停顿,核心是把老年代一部分工作并发化,减少长时间 Stop-The-World。
  • G1 追求平衡,通过 Region、收益优先回收和停顿目标控制,让较大堆下的停顿更可预测。

这说明“更先进”不等于“任何场景都更优”。如果业务最关心总任务耗时,Parallel 往往非常直接;如果是 JDK8 低延迟接口服务,很多团队对 CMS 的行为边界更熟悉;如果是大堆、混合负载,希望在吞吐和停顿之间折中,G1 才通常更合适。

G1 也有自己的成本:

  • 要维护跨 Region 引用相关数据结构。
  • 要在停顿目标下动态平衡年轻代和混合回收节奏。
  • 会受到 Humongous 对象、RSet 开销、Mixed GC 选择策略的影响。

典型日志信号

  • Parallel 下停顿长但整体吞吐高,说明它可能并没有选错,只是不适合延迟敏感业务。
  • CMS 下出现 concurrent mode failure、碎片相关现象,说明维护成本开始上升。
  • G1 下出现明显的 Humongous、Mixed GC 负担或 RSet 开销,说明它也不是无脑收益。

推荐排查动作

  1. 先定义业务目标是吞吐、低停顿还是平衡。
  2. 再判断当前收集器的目标函数是否和业务目标一致。
  3. 迁移到 G1 时,先清理旧时代参数,不要原样搬运 CMS / Parallel 配置。
  4. 用真实日志对比迁移前后的停顿分布、GC 总耗时和 Full GC 频率。

补充知识点

可以直接用这张表帮助记忆:

收集器 更适合的目标 最怕的误用方式
Parallel 吞吐优先 拿它去服务严格低延迟接口
CMS 低停顿优先 忽视碎片和晋升失败风险
G1 大堆平衡型负载 沿用旧参数把自适应能力锁死

误区四:看到 Young GC 频繁就必须扩堆

正确认知

Young GC 高频,本身可能只是一个健康的分代回收现象。真正要关注的不是“次数”,而是“代价”和“后果”。

底层机制

分代回收建立在分代假说之上:

  • 大多数对象是短命的。
  • 少数对象才会长期存活。
  • 年轻代应该被设计成“高频、低成本、快速清理短命对象”的区域。

这意味着高并发系统里频繁发生 Young GC 并不奇怪。请求对象、临时集合、序列化缓冲、DTO 和中间结果,本来就会在年轻代快速产生和消亡。如果这些对象能在年轻代里低成本被清理掉,说明 JVM 正在按设计工作。

真正危险的是两类情况:

  • Young GC 很频繁,而且每次停顿成本已经不可接受。
  • Young GC 之后大量对象晋升老年代,说明很多对象并不短命。

典型日志信号

  • 频繁出现 GC (Allocation Failure),但每次停顿很短,老年代平稳,这通常不一定是故障。
  • 每次 Young GC 后 Old 持续上涨,说明晋升压力大。
  • PrintTenuringDistribution 显示对象很快达到晋升年龄,说明 Survivor 压力可能过大。
  • GC 总耗时占比持续升高,说明虽然是 Young GC,但总成本已经不可忽视。

推荐排查动作

  1. 先看单次 Young GC 停顿是否真的影响 P99 / P999
  2. 再看晋升速率,而不是只看 Young GC 次数。
  3. 检查对象分配速率是否异常,比如大批临时集合或过度装箱拆箱。
  4. 必要时结合 SurvivorRatioMaxTenuringThreshold 与年龄分布做判断。

补充知识点

可以把 Young GC 问题分成两类:

现象 更可能是正常分配模型 更可能是问题
次数高,但单次停顿短
次数高,且 Old 稳定
次数高,同时晋升快
次数高,同时总 GC 时间占比高

所以更准确的说法是:

Young GC 频繁不是异常,Young GC 高频且成本失控才是异常。

误区五:调优只看 JVM,不看代码

正确认知

JVM 只能决定“如何回收不可达对象”,不能决定“业务代码为什么一直持有这些对象”。真正的调优闭环,必须同时看 JVM 行为和代码对象生命周期。

底层机制

GC 的核心判断标准是可达性,不是“这个对象是否应该存在”。只要对象还在引用链上,哪怕业务上它早就不该继续保留,GC 也不能把它回收。

这就是很多线上问题最终回到代码层的原因:

  • ThreadLocal 没清理,导致线程长生命周期持有大对象。
  • 本地缓存无上限,命中率没提升,但 retained heap 持续放大。
  • 消息积压、批量查询、一次性加载大结果集,导致对象生命周期被人为拉长。
  • 动态代理、脚本编译、热部署形成类加载器泄漏。

参数只能改变回收节奏,不能修复错误的引用关系。

典型日志信号

  • Full GC 很频繁,但回收后占用始终偏高。
  • 扩堆后故障延后,但最终仍然复发。
  • 老年代曲线持续抬升,看不到明显回落。
  • Heap dump 中少数对象链条占据大量 retained heap。

推荐排查动作

  1. GC 日志 判断问题是在年轻代、老年代还是元空间。
  2. 用 dump 找“谁在持有对象”,而不是只看“对象多不多”。
  3. 回查代码中的缓存、ThreadLocal、批处理和异步堆积链路。
  4. 调参数前先确认对象生命周期是否合理。

补充知识点

可以把调优分成两条并行主线:

视角 主要回答的问题
JVM 视角 什么时候回收、为什么停顿、为什么晋升、为什么 Full GC
代码视角 对象为什么活着、谁在引用、为什么生命周期变长

只有两条线合并,调优才不是“参数碰运气”。

误区六:出现 OOM 就一定是堆太小

正确认知

OutOfMemoryError 只是一个统称,真正的内存问题可能发生在堆、元空间、直接内存、线程栈甚至系统级资源上。

底层机制

JVM 抛出的 OOM 类型很多,常见的就包括:

  • Java heap space
  • GC overhead limit exceeded
  • Metaspace
  • Direct buffer memory
  • unable to create new native thread

这些错误的根因完全不同。比如 Metaspace 更偏向类加载与元数据问题,Direct buffer memory 更偏向 NIO 或框架堆外缓冲使用,unable to create new native thread 往往是线程数量或操作系统限制问题。

典型日志信号

  • java.lang.OutOfMemoryError: Java heap space
  • java.lang.OutOfMemoryError: Metaspace
  • java.lang.OutOfMemoryError: Direct buffer memory
  • java.lang.OutOfMemoryError: unable to create new native thread

推荐排查动作

  1. 先精确看 OOM 错误类型,不要只看到 OutOfMemoryError 就默认扩堆。
  2. heap space 优先看 dump,Metaspace 优先看类加载器,Direct buffer memory 优先看框架和堆外分配。
  3. 如果是线程相关 OOM,先看线程数、线程池和系统限制。

补充知识点

OOM 类型 优先怀疑方向
Java heap space 堆不足、泄漏、缓存膨胀
GC overhead limit exceeded JVM 花太多时间 GC,通常仍是堆问题或泄漏
Metaspace 类加载器泄漏、动态代理、脚本引擎
Direct buffer memory 堆外缓冲未释放、NIO/Netty 使用不当
unable to create new native thread 线程泄漏、线程栈过大、OS 限制

误区七:JVM 参数越多,说明调优越专业

正确认知

真正专业的调优通常是“参数少但证据充分”,而不是“参数很多看起来很厉害”。

底层机制

JVM 参数之间经常存在覆盖、耦合和目标冲突。参数越多,越容易出现下面几类问题:

  • 某个参数把另一个参数的自适应能力锁死。
  • 旧收集器参数迁移到新收集器后变成负优化。
  • 团队没人真正理解参数之间的优先级和副作用。

G1 是一个典型例子。它依赖年轻代大小自适应来满足停顿目标,如果继续沿用 -Xmn 这类强约束参数,就可能直接削弱它的调节空间。

典型日志信号

  • 参数很多,但 GC 行为仍然不可预测。
  • 迁移到新收集器后,停顿反而更差。
  • 线上参数和团队理解不一致,没人说得清哪些参数真正生效。

推荐排查动作

  1. 先保留少量核心参数:堆大小、收集器、必要日志。
  2. 每次只改一小批参数,保留前后证据。
  3. 迁移收集器时,优先删除旧时代遗留参数。
  4. jcmd VM.flagsjinfo -flags 确认最终生效配置。

补充知识点

好的调优参数集通常具备 3 个特征:

  • 解释得清楚
  • 证据能支撑
  • 团队能维护

误区八:只看平均响应时间,不看停顿长尾

正确认知

GC 对业务最危险的地方,往往不是平均耗时,而是少量长停顿对交易、接口超时和线程池积压造成的放大效应。

底层机制

一次较长的 Stop-The-World,不会只影响当前线程,而是会同时冻结大量业务线程。对线上系统来说,这种停顿会形成连锁反应:

  • 当前请求被拉长。
  • 后续请求在连接池、线程池、队列里积压。
  • 依赖调用开始级联超时。

这就是为什么 GC 调优不能只盯平均值。平均值很容易掩盖尾部风险,而真正打穿 SLA 的,通常是 P99 / P999

典型日志信号

  • 平均 RT 正常,但业务高峰偶发超时尖刺。
  • GC 日志里偶尔出现特别长的停顿,和业务报警时间点能对上。
  • Full GC 次数不多,但每次都足以造成线程池堆积。

推荐排查动作

  1. 把 GC 停顿时间和接口 P99 / P999、超时数对齐分析。
  2. 不只看平均值,也看最大值和分位数。
  3. 对延迟敏感业务优先评估长尾停顿,而不是只看总吞吐。

补充知识点

可以把指标理解成两层:

指标 更适合回答的问题
平均值 系统整体是否大致健康
P99 / P999 长尾停顿是否会打穿业务 SLA
最大停顿 是否存在灾难性尖刺

十四、一个可复用的调优顺序

如果要把整篇文章压缩成一份操作顺序,可以直接记下面 8 步:

  1. 确认线上真实 JVM 参数,不要凭配置文件想象。
  2. 补齐 GC 日志heap dump、线程 dump。
  3. 先判断是停顿、吞吐、泄漏还是堆外内存问题。
  4. 再判断当前收集器目标是否和业务目标一致。
  5. 优先改最基础的容量参数和触发阈值,不要一口气改十几个选项。
  6. 每次只改一小批参数,并保留前后日志对比。
  7. 如果问题本质是对象生命周期异常,回到代码修复。
  8. 调优完成后,把监控和故障采集参数长期保留。

十五、总结

JDK8 下的 JVM 调优,可以概括成一张简单地图:

业务类型 优先考虑的收集器 调优关键词
小服务、小机器、低并发 Serial 低开销、别过度设计
批处理、离线计算、吞吐优先 Parallel 吞吐、GC 线程、堆峰值
低延迟接口、交易链路 CMS 触发阈值、碎片、晋升失败
中大型堆、混合负载、平衡型诉求 G1 停顿目标、并发标记、Humongous

真正有效的经验并不是“记住了多少参数”,而是形成下面这条判断路径:

先搞清问题是什么,再决定是否需要换收集器;先采到证据,再决定参数怎么改;先让对象生命周期合理,再期待 GC 表现稳定。

只有这样,JVM 调优才不会停留在“背参数”和“撞运气”。