这篇笔记的目标,不是把
JVM调优写成一份参数清单,而是把它还原成一个可执行的排障过程:先判断问题到底是吞吐、延迟、内存占用还是对象生命周期失衡,再结合GC 日志、堆使用率、线程状态和堆外内存证据决定是否改参数、改多大、要不要换收集器。
文章范围限定在
HotSpot JDK8。重点关注生产里最常见的 4 类收集器组合:Serial、Parallel、CMS、G1。它不展开JDK9+的统一日志体系,也不讨论ZGC、Shenandoah这一类不属于JDK8主线生产选项的收集器。
参考资料:
官方文档:HotSpot VM Garbage Collection Tuning Guide 、 Available Collectors 、 Ergonomics 、 Sizing the Generations
收集器细节:The Parallel Collector 、 Concurrent Mark Sweep (CMS) Collector 、 Garbage-First Garbage Collector 、 G1 Tuning
诊断与排障:Java SE Troubleshooting Guide 、 VisualVM 、 Eclipse MAT
[TOC]
一、先给最短答案
如果只用一句话概括 JVM 调优:
JVM调优的本质,不是“把堆调大一点”,而是让对象分配速度、对象存活时间、GC 回收节奏和业务延迟目标重新匹配。
因此大多数调优动作都绕不开 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.flags 或 jinfo -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 支持多个收集器部件,但生产调优一般按“组合”来理解,而不是把每个部件拆开单独选。
为了方便在正式展开前先建立整体视图,先放 3 张前置速查表。
1. 表格一:Serial GC、Parallel GC、CMS、G1 对比
| 收集器组合 | 新生代回收 | 老年代回收 | 核心目标 | 停顿特征 | 典型堆规模 | 主要优势 | 主要风险 | 典型场景 |
|---|---|---|---|---|---|---|---|---|
Serial |
单线程复制 | 单线程标记整理 | 低资源开销、小堆稳定 | STW 明显,但模型简单;回收行为容易观察和判断 |
小堆更常见; 通常几百 MB 到 1-2g 内更容易接受 |
实现简单; 线程开销最低; 单核环境不容易被 GC 线程反噬 |
堆一大停顿线性放大; 不适合延迟敏感链路; 大流量场景下扩展性差 |
小工具、单核容器、轻量后台服务、开发测试环境 |
Parallel |
多线程复制 | 多线程标记整理 | 吞吐优先 | STW 停顿可能偏长;但单位时间处理量高 |
中小到中等堆更常见; 常见于 2g-8g 级别吞吐型任务 |
CPU 利用率高; 批处理和离线任务通常更直接受益; 总任务耗时往往更容易下降 |
高峰停顿抖动明显; 容易打穿接口 P99;不适合追求平滑延迟的接口服务 |
批处理、报表、离线计算、非延迟敏感服务 |
CMS |
ParNew 并行复制 |
并发标记清除 | 低停顿优先 | 老年代主要阶段并发;remark 仍会 STW;整体停顿通常短于吞吐型收集器 |
中等堆更常见; 常见于对延迟敏感但还未迁移 G1 的系统 |
老年代停顿显著短于传统吞吐型收集器; 在 JDK8 低延迟系统里经验积累多 |
会产生碎片; 晋升失败或 concurrent mode failure 时容易退化成重型 Full GC;参数较多,维护成本高 |
交易、订单、实时接口、历史低延迟系统 |
G1 |
Region 内复制与疏散 | Region 化并发标记与混合回收 | 停顿可控、吞吐与延迟折中 | 有暂停目标; 停顿通常比 Parallel 更平滑;但目标值不是硬保证 |
中大堆更常见; 常见于 4g+ 甚至更大堆 |
更适合大堆与混合负载; 更容易用统一的停顿目标管理; 迁移后维护复杂度通常低于 CMS |
Humongous 对象需要重点观察; 错误参数会直接损失吞吐; 不能照搬旧 CMS / Parallel 参数 |
中大型堆、混合业务、准备替换 CMS 的系统 |
2. 表格二:不同 GC 的配置参数列表
| 收集器组合 | 常见参数 | 主要作用 | 使用提示 |
|---|---|---|---|
Serial |
-XX:+UseSerialGC |
明确启用串行年轻代和老年代回收 | 常配合小堆使用,不要在大堆上指望它维持低停顿 |
-Xms / -Xmx |
固定堆大小,避免运行期扩缩容抖动 | 小服务通常直接设成相同,便于观测稳定曲线 | |
-Xmn 或 -XX:NewRatio |
控制年轻代大小或新老年代比例 | 两者通常二选一,避免同时硬调导致判断困难 | |
-XX:SurvivorRatio |
控制 Eden 与 Survivor 比例 | 对象稍微多活几轮就晋升时可以检查这里 | |
-XX:MaxTenuringThreshold |
控制对象最大晋升年龄 | 中等寿命对象多时,比盲目扩老年代更直接 | |
Parallel |
-XX:+UseParallelGC |
启用吞吐优先的并行年轻代回收 | JDK8 下常与 Parallel Old 一起使用 |
-XX:+UseParallelOldGC |
启用并行老年代回收 | 生产里通常显式写出,避免理解歧义 | |
-XX:ParallelGCThreads |
控制并行 GC 线程数 | 不是越大越好,线程过多会和业务线程抢 CPU | |
-Xmn 或 -XX:NewRatio |
调整年轻代节奏 | 吞吐型任务常靠它减少 Young GC 频次 | |
-XX:SurvivorRatio |
调整年轻代内部比例 | Young GC 频繁且晋升快时常一起看 | |
-XX:MaxTenuringThreshold |
控制对象在年轻代停留轮次 | 批量任务里中等寿命对象多时很常见 | |
CMS |
-XX:+UseConcMarkSweepGC |
启用 CMS 老年代并发回收 |
生产上通常默认理解为 ParNew + CMS 组合 |
-XX:+UseParNewGC |
显式指定 ParNew 作为年轻代回收器 |
有些团队会写明,便于参数审计 | |
-XX:+CMSParallelRemarkEnabled |
让 remark 阶段并行化 |
降低 remark 停顿时长,常作为起步参数 |
|
-XX:+UseCMSInitiatingOccupancyOnly |
使用手工阈值而非自适应触发逻辑 | 只有准备自己管启动时机时再打开 | |
-XX:CMSInitiatingOccupancyFraction |
控制 CMS 在老年代占用多少时启动 |
常见起步值在 70 到 75,过晚容易触发 Full GC |
|
-XX:ConcGCThreads |
控制 CMS 并发阶段线程数 |
并发标记跟不上分配速率时再评估,不要盲目加线程 | |
-Xmn / -XX:NewRatio / -XX:SurvivorRatio / -XX:MaxTenuringThreshold |
控制年轻代大小和晋升节奏 | CMS 老年代问题很多时候根子在年轻代晋升过快 |
|
G1 |
-XX:+UseG1GC |
启用 G1 |
迁移时先把旧 CMS / Parallel 年代参数清掉再观察 |
-XX:MaxGCPauseMillis |
给出暂停时间目标 | 这是目标值不是承诺值,过于激进会伤吞吐 | |
-XX:InitiatingHeapOccupancyPercent |
控制并发标记启动阈值 | 老年代回收总是偏晚时可以下调 | |
-XX:ParallelGCThreads |
控制暂停阶段并行 GC 线程 | 机器核数有限时也要防止 GC 抢占业务 CPU | |
-XX:ConcGCThreads |
控制并发标记线程 | 只在并发回收明显跟不上时再考虑 | |
-XX:G1HeapRegionSize |
控制 Region 大小 | 一般先用默认值,只有大对象与 Region 分布异常时再动 | |
-XX:G1ReservePercent |
预留一部分堆空间降低疏散失败风险 | 大堆高峰期对象晋升紧张时再评估 | |
-Xms / -Xmx |
固定堆大小,减少运行时波动 | 通常仍建议 -Xms = -Xmx,但不建议同时强绑 -Xmn |
3. 表格三:实战中不同 GC 的常见配置
| 收集器组合 | 常见场景 | 常见起步配置 | 配置思路 |
|---|---|---|---|
Serial |
1C2G 以内的小服务、轻量管理台、低流量后台任务 |
-Xms512m-Xmx512m-XX:+UseSerialGC-XX:+HeapDumpOnOutOfMemoryError-XX:+PrintGCDetails-XX:+PrintGCDateStamps-Xloggc:/data/logs/java/gc.log |
先把资源开销压低,接受可控停顿,不在单核机器上引入额外 GC 线程竞争 |
Parallel |
吞吐优先的批处理、报表、离线作业 | -Xms4g-Xmx4g-XX:+UseParallelGC-XX:+UseParallelOldGC-XX:ParallelGCThreads=8-XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+PrintGCTimeStamps-Xloggc:/data/logs/java/gc.log |
先保证单位时间处理量,再围绕 Young GC 频率、GC 线程数和总任务耗时做调节 |
CMS |
延迟敏感接口、交易链路、老系统低停顿场景 | -Xms6g-Xmx6g-XX:+UseConcMarkSweepGC-XX:+UseParNewGC-XX:+CMSParallelRemarkEnabled-XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=70-XX:+PrintGCDetails-XX:+PrintGCDateStamps-Xloggc:/data/logs/java/gc.log |
提前启动老年代并发回收,重点盯碎片、晋升失败和 concurrent mode failure |
G1 |
4g+ 中大型堆、混合负载、准备替换 CMS 的系统 |
-Xms8g-Xmx8g-XX:+UseG1GC-XX:MaxGCPauseMillis=200-XX:InitiatingHeapOccupancyPercent=45-XX:+PrintGCDetails-XX:+PrintGCDateStamps-Xloggc:/data/logs/java/gc.log |
先给暂停目标和回收起点,保留 G1 自适应空间,再根据日志决定是否调并发线程和 Region 相关参数 |
再换一个更偏实战的理解方式:
| 业务诉求 | 优先考虑 | 不要先做的事 |
|---|---|---|
| 单机资源很少,服务也不大 | Serial |
一上来切 G1 期待“自动变快” |
| 追求任务完成速度 | Parallel |
用 CMS 或 G1 去换取并不需要的低停顿 |
| 交易链路怕抖动 | CMS 或 G1 |
只因为“默认配置”继续使用吞吐型 GC |
| 堆大、对象生命周期复杂 | G1 |
继续把 CMS 参数堆到很复杂 |
需要注意两点:
ParNew在 JDK8 里主要和CMS搭配出现,生产讨论时通常把它归到CMS组合一起看。G1在JDK8不是默认收集器,迁移时不能把旧的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 / 晋升阈值不合适,或对象存活时间变长 | 看年龄分布,再决定是否调 SurvivorRatio、MaxTenuringThreshold |
| 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 线程数 | 控制 Parallel、G1 等收集器的 GC 工作线程 |
CPU 被 GC 抢占过多或线程太少时 |
-XX:ConcGCThreads |
并发 GC 线程数 | 常见于 CMS / G1 |
并发标记跟不上业务分配速度时 |
-XX:MetaspaceSize / -XX:MaxMetaspaceSize |
元空间阈值与上限 | 控制类元数据增长 | 类加载很多、动态代理多时 |
-XX:InitiatingHeapOccupancyPercent |
G1 并发标记触发阈值 |
控制何时开始老年代并发回收 | G1 老年代回收偏晚时 |
-XX:CMSInitiatingOccupancyFraction |
CMS 触发阈值 |
例如 70、75 |
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 次数上升 | 老年代压力增大 | 看是否缓存累积、批次峰值过大或堆太小 |
一个典型优化过程
- 初始配置
-Xmx4g,任务高峰时FGC多次出现,总耗时 48 分钟。 - 把堆提升到
8g后,FGC基本消失,但 Young GC 仍偏频繁。 - 结合日志发现对象主要是批次中间结果,生命周期集中在年轻代。
- 保留
Parallel GC,适当增加年轻代比例后,任务总耗时下降到 36 分钟。
关键结论
这类场景里,最有效的动作往往是:
- 先确认堆是否足够承载批次峰值。
- 再看
GC time是否真的影响总吞吐。 - 最后才是细调线程数和代际比例。
如果业务目标是“任务尽快跑完”,Parallel GC 往往比低停顿收集器更直接。
九、实战案例三:交易接口使用 CMS
场景
- 业务类型:订单、支付、库存扣减、实时风控
- 机器规格:
8C32G - 堆大小:
12g - 目标:控制停顿时间,避免交易链路尖刺
现象
- 平均响应时间不错,但
P99 / P999在高峰期明显抖动。 - GC 日志显示老年代回收停顿开始变长。
- 偶尔出现
promotion failed或concurrent 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 - 堆大小:
16g到24g - 目标:在吞吐可接受的前提下,把停顿控制在较稳定区间
为什么 G1 适合这类场景
G1 用 Region 取代固定物理代区,并根据回收收益优先回收垃圾更多的区域。对于堆较大、对象生命周期混合、既想要吞吐又不想停顿太抖的系统,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 个边界
MaxGCPauseMillis是目标,不是保证值。G1依赖自适应年轻代,不要先把-Xmn固定死。- 大对象会走
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 从高峰期每天数次降到偶发。
P99从900ms左右回落到350ms左右。- 平均吞吐略有损失,但故障率和停顿尖刺明显下降。
这类结果说明:
当业务更怕长尾停顿而不是极致吞吐时,
G1往往是 JDK8 里更平衡的选择。
十一、如何区分“内存不够”和“代码泄漏”
这是 JVM 调优里最容易走偏的地方。
| 现象 | 更像堆太小 | 更像内存泄漏 |
|---|---|---|
| 高峰过去后堆占用明显回落 | 是 | 否 |
| 回收后老年代长期贴近上限 | 否 | 是 |
扩大 -Xmx 后故障显著推迟但仍会复发 |
可能 | 很可能 |
| dump 里少数对象链条持有大量 Retained Heap | 否 | 是 |
判断原则可以概括成两句:
- 如果回收后能降下来,优先怀疑容量与峰值不匹配。
- 如果回收后降不下来,优先怀疑对象根本不该活这么久。
因此下面这些问题,严格说都不是“单纯 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 failure、promotion failed。- 出现
Metadata GC Threshold,说明可能是元空间压力而不只是堆问题。 - Full GC 后老年代占用几乎不下降,说明更像对象长期存活或泄漏。
推荐排查动作
- 先看
Full GC前后Old、Metaspace占用是否回落。 - 再判断是堆容量不足、
CMS碎片、G1 大对象,还是元空间压力。 - 如果回收后占用仍然很高,优先抓
heap dump,不要先换 GC。 - 只有在确认业务目标和当前收集器目标不匹配时,才考虑迁移收集器。
补充知识点
可以先把 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 后堆仍然居高不下,说明不是简单的容量不足。
推荐排查动作
- 先做总内存预算,不要只算堆。
- 观察 GC 次数和单次停顿是否在做不划算的交换。
- 核对线程数、直接内存、元空间使用,而不是只盯
heap usage。 - 如果增大堆后问题只是推迟出现,优先怀疑泄漏或对象滞留。
补充知识点
更适合记住的是“容量”和“成本”要一起看:
| 动作 | 好处 | 代价 |
|---|---|---|
| 增大堆 | 降低 GC 频率、增加缓冲空间 | 可能拉长单次停顿,抬高总内存占用 |
| 缩小堆 | 让问题更早暴露、回收更快 | 容量缓冲变小,峰值更容易触顶 |
真正稳的配置,不是堆尽量大,而是总内存预算、业务峰值和收集器成本之间达到平衡。
误区三:G1 一定全面优于 CMS / Parallel
正确认知
GC 选型从来不是“新版本一定全面替代旧版本”,而是不同收集器在吞吐、停顿、内存占用和调优复杂度之间做不同取舍。
底层机制
Parallel、CMS、G1 的设计目标并不一样:
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 开销,说明它也不是无脑收益。
推荐排查动作
- 先定义业务目标是吞吐、低停顿还是平衡。
- 再判断当前收集器的目标函数是否和业务目标一致。
- 迁移到
G1时,先清理旧时代参数,不要原样搬运CMS / Parallel配置。 - 用真实日志对比迁移前后的停顿分布、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,但总成本已经不可忽视。
推荐排查动作
- 先看单次 Young GC 停顿是否真的影响
P99 / P999。 - 再看晋升速率,而不是只看 Young GC 次数。
- 检查对象分配速率是否异常,比如大批临时集合或过度装箱拆箱。
- 必要时结合
SurvivorRatio、MaxTenuringThreshold与年龄分布做判断。
补充知识点
可以把 Young GC 问题分成两类:
| 现象 | 更可能是正常分配模型 | 更可能是问题 |
|---|---|---|
| 次数高,但单次停顿短 | 是 | 否 |
次数高,且 Old 稳定 |
是 | 否 |
| 次数高,同时晋升快 | 否 | 是 |
| 次数高,同时总 GC 时间占比高 | 否 | 是 |
所以更准确的说法是:
Young GC 频繁不是异常,Young GC 高频且成本失控才是异常。
误区五:调优只看 JVM,不看代码
正确认知
JVM 只能决定“如何回收不可达对象”,不能决定“业务代码为什么一直持有这些对象”。真正的调优闭环,必须同时看 JVM 行为和代码对象生命周期。
底层机制
GC 的核心判断标准是可达性,不是“这个对象是否应该存在”。只要对象还在引用链上,哪怕业务上它早就不该继续保留,GC 也不能把它回收。
这就是很多线上问题最终回到代码层的原因:
ThreadLocal没清理,导致线程长生命周期持有大对象。- 本地缓存无上限,命中率没提升,但 retained heap 持续放大。
- 消息积压、批量查询、一次性加载大结果集,导致对象生命周期被人为拉长。
- 动态代理、脚本编译、热部署形成类加载器泄漏。
参数只能改变回收节奏,不能修复错误的引用关系。
典型日志信号
- Full GC 很频繁,但回收后占用始终偏高。
- 扩堆后故障延后,但最终仍然复发。
- 老年代曲线持续抬升,看不到明显回落。
- Heap dump 中少数对象链条占据大量 retained heap。
推荐排查动作
- 用
GC 日志判断问题是在年轻代、老年代还是元空间。 - 用 dump 找“谁在持有对象”,而不是只看“对象多不多”。
- 回查代码中的缓存、
ThreadLocal、批处理和异步堆积链路。 - 调参数前先确认对象生命周期是否合理。
补充知识点
可以把调优分成两条并行主线:
| 视角 | 主要回答的问题 |
|---|---|
| JVM 视角 | 什么时候回收、为什么停顿、为什么晋升、为什么 Full GC |
| 代码视角 | 对象为什么活着、谁在引用、为什么生命周期变长 |
只有两条线合并,调优才不是“参数碰运气”。
误区六:出现 OOM 就一定是堆太小
正确认知
OutOfMemoryError 只是一个统称,真正的内存问题可能发生在堆、元空间、直接内存、线程栈甚至系统级资源上。
底层机制
JVM 抛出的 OOM 类型很多,常见的就包括:
Java heap spaceGC overhead limit exceededMetaspaceDirect buffer memoryunable to create new native thread
这些错误的根因完全不同。比如 Metaspace 更偏向类加载与元数据问题,Direct buffer memory 更偏向 NIO 或框架堆外缓冲使用,unable to create new native thread 往往是线程数量或操作系统限制问题。
典型日志信号
java.lang.OutOfMemoryError: Java heap spacejava.lang.OutOfMemoryError: Metaspacejava.lang.OutOfMemoryError: Direct buffer memoryjava.lang.OutOfMemoryError: unable to create new native thread
推荐排查动作
- 先精确看 OOM 错误类型,不要只看到
OutOfMemoryError就默认扩堆。 heap space优先看 dump,Metaspace优先看类加载器,Direct buffer memory优先看框架和堆外分配。- 如果是线程相关 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 行为仍然不可预测。
- 迁移到新收集器后,停顿反而更差。
- 线上参数和团队理解不一致,没人说得清哪些参数真正生效。
推荐排查动作
- 先保留少量核心参数:堆大小、收集器、必要日志。
- 每次只改一小批参数,保留前后证据。
- 迁移收集器时,优先删除旧时代遗留参数。
- 用
jcmd VM.flags或jinfo -flags确认最终生效配置。
补充知识点
好的调优参数集通常具备 3 个特征:
- 解释得清楚
- 证据能支撑
- 团队能维护
误区八:只看平均响应时间,不看停顿长尾
正确认知
GC 对业务最危险的地方,往往不是平均耗时,而是少量长停顿对交易、接口超时和线程池积压造成的放大效应。
底层机制
一次较长的 Stop-The-World,不会只影响当前线程,而是会同时冻结大量业务线程。对线上系统来说,这种停顿会形成连锁反应:
- 当前请求被拉长。
- 后续请求在连接池、线程池、队列里积压。
- 依赖调用开始级联超时。
这就是为什么 GC 调优不能只盯平均值。平均值很容易掩盖尾部风险,而真正打穿 SLA 的,通常是 P99 / P999。
典型日志信号
- 平均 RT 正常,但业务高峰偶发超时尖刺。
- GC 日志里偶尔出现特别长的停顿,和业务报警时间点能对上。
- Full GC 次数不多,但每次都足以造成线程池堆积。
推荐排查动作
- 把 GC 停顿时间和接口
P99 / P999、超时数对齐分析。 - 不只看平均值,也看最大值和分位数。
- 对延迟敏感业务优先评估长尾停顿,而不是只看总吞吐。
补充知识点
可以把指标理解成两层:
| 指标 | 更适合回答的问题 |
|---|---|
| 平均值 | 系统整体是否大致健康 |
P99 / P999 |
长尾停顿是否会打穿业务 SLA |
| 最大停顿 | 是否存在灾难性尖刺 |
十四、一个可复用的调优顺序
如果要把整篇文章压缩成一份操作顺序,可以直接记下面 8 步:
- 确认线上真实
JVM参数,不要凭配置文件想象。 - 补齐
GC 日志、heap dump、线程 dump。 - 先判断是停顿、吞吐、泄漏还是堆外内存问题。
- 再判断当前收集器目标是否和业务目标一致。
- 优先改最基础的容量参数和触发阈值,不要一口气改十几个选项。
- 每次只改一小批参数,并保留前后日志对比。
- 如果问题本质是对象生命周期异常,回到代码修复。
- 调优完成后,把监控和故障采集参数长期保留。
十五、总结
JDK8 下的 JVM 调优,可以概括成一张简单地图:
| 业务类型 | 优先考虑的收集器 | 调优关键词 |
|---|---|---|
| 小服务、小机器、低并发 | Serial |
低开销、别过度设计 |
| 批处理、离线计算、吞吐优先 | Parallel |
吞吐、GC 线程、堆峰值 |
| 低延迟接口、交易链路 | CMS |
触发阈值、碎片、晋升失败 |
| 中大型堆、混合负载、平衡型诉求 | G1 |
停顿目标、并发标记、Humongous |
真正有效的经验并不是“记住了多少参数”,而是形成下面这条判断路径:
先搞清问题是什么,再决定是否需要换收集器;先采到证据,再决定参数怎么改;先让对象生命周期合理,再期待 GC 表现稳定。
只有这样,JVM 调优才不会停留在“背参数”和“撞运气”。