这篇笔记的目标,不是把
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 支持多个收集器部件,但生产调优一般按“组合”来理解,而不是把每个部件拆开单独选。
| 收集器组合 | 开启参数 | 核心目标 | 优势 | 典型短板 | 典型场景 |
|---|---|---|---|---|---|
Serial |
-XX:+UseSerialGC |
小堆、低开销 | 实现简单、单核环境成本低 | 停顿时间随堆增大而明显上升 | 小工具、单核容器、开发测试环境 |
Parallel |
-XX:+UseParallelGC |
吞吐优先 | 多线程回收、单位时间处理量高 | 高峰停顿通常不够平滑 | 批处理、离线任务、报表服务 |
CMS |
-XX:+UseConcMarkSweepGC |
低停顿优先 | 老年代并发回收,适合响应时间敏感业务 | 会产生碎片,参数较多,容易因晋升失败触发 Full GC | 交易、订单、实时接口服务 |
G1 |
-XX:+UseG1GC |
在吞吐和停顿间折中 | Region 化、可按暂停目标调节、适合大堆 | 参数误用时容易吞吐下降,Humongous 对象要重点关注 | 中大型堆、混合负载、逐步替换 CMS |
再换一个更偏实战的理解方式:
| 业务诉求 | 优先考虑 | 不要先做的事 |
|---|---|---|
| 单机资源很少,服务也不大 | 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 调优才不会停留在“背参数”和“撞运气”。