JVM GC 与调优:7 种垃圾收集器选型 + 参数调优实战
GC 调优第一个要搞清楚的不是参数——是选型。堆 <4G→Parallel,4-32G→G1,>32G 且要求 <1ms STW→ZGC。选错了收集器,参数怎么调都是错的。这篇文章把 7 种 GC 的适用边界、STW/吞吐量对比、生产参数模板全整理好了——下次选型直接对照。
阅读约 16 分钟 | 系列第 5/17 篇
一、判断对象存活:两种算法
算法一:引用计数法
每个对象维护一个计数器,被引用一次 +1,引用失效 -1,归零即回收。
Person p1 = new Person(); // 对象#001: 引用计数 = 1
Person p2 = p1; // 对象#001: 引用计数 = 2
p1 = null; // 对象#001: 引用计数 = 1
p2 = null; // 对象#001: 引用计数 = 0 → 回收 ✓
- 优点:实时回收,无 STW——引用归零立即释放
- 缺点:循环引用无法解决,每次赋值都要更新计数(高并发下性能差)
循环引用——引用计数的致命缺陷:
class Node { Node next; }
Node a = new Node();
Node b = new Node();
a.next = b; // b 计数 = 2
b.next = a; // a 计数 = 2
a = null; // a 计数 = 1(仍被 b.next 引用)
b = null; // b 计数 = 1(仍被 a.next 引用)
// a 和 b 互相指着,计数器永远=1,从 GC Roots 出发谁也到不了 → 垃圾永存
谁在用引用计数? Python(引用计数 + 标记清除检测循环)、C++(shared_ptr/weak_ptr,weak_ptr 不增加计数打破循环)、Swift(ARC,weak 引用打破循环)。为什么这些语言用?实时释放、无 STW——C++ 没 GC,iOS 内存小不能容忍 GC 暂停。
算法二:可达性分析(Java 使用)
从 GC Roots 出发沿引用链搜索,能到达的 = 活的,到不了的 = 可回收。
GC Roots 有哪些? 口诀:"栈上的、静态的、常量的、JNI 的、锁着的"
- 虚拟机栈(栈帧中的局部变量表)引用的对象
- 方法区中静态变量引用的对象
- 运行时常量池引用的对象
- JNI 引用的对象
- 被 synchronized 持有的对象
从每个 Root 出发,沿着引用链一路找下去:
GC Root ──→ 对象A ──→ 对象B ──→ 对象C ← 活(可以从 Root 到达)
对象D ← 死(无人能到达)
对象E ← 死
核心优势:天然免疫循环引用——从 GC Roots 出发谁也到不了 a 和 b,双双判定为死。
| 维度 | 引用计数法 | 可达性分析 |
|---|---|---|
| 原理 | 每个对象计数,归零回收 | GC Roots 出发,不可达 = 可回收 |
| 实时性 | 即时释放,无 STW | 需等 GC 触发,有 STW |
| 循环引用 | ❌ 需额外机制 | ✓ 天然免疫 |
| 性能开销 | 每次赋值更新计数 | 只在 GC 时扫描,平时零开销 |
| 代表语言 | Python, C++, Swift | Java, Go, C# |
二、四种引用类型
| 引用类型 | 回收时机 | 适用场景 |
|---|---|---|
| 强引用(Strong) | 永不回收 | 正常 Object o = new Object() |
| 软引用(Soft) | 内存不够时回收 | 缓存——OOM 前自动释放 |
| 弱引用(Weak) | GC 时无论内存是否够都回收 | WeakHashMap、ThreadLocal 的 key(防内存泄漏) |
| 虚引用(Phantom) | get() 永远返回 null | 对象回收时收到通知,配合 ReferenceQueue 做堆外内存释放(NIO Cleaner) |
三、分代收集理论
两个统计假设(弱分代假设)
- 弱分代假说:IBM 研究——98% 的对象朝生夕死,活不过第一次 GC
- 强分代假说:熬过越多次 GC 的对象越不容易死亡
分代的好处:新生代用复制算法(存活少,复制成本低),老年代用标记-清除/整理算法(存活多,移动成本高),各用最适合的算法。
堆分代结构
堆内存 = 新生代 + 老年代
新生代 = Eden + Survivor0 + Survivor1
默认比例:新生代:老年代 = 1:2
默认比例:Eden:S0:S1 = 8:1:1
为什么 8:1:1?98% 对象朝生夕死,Minor GC 后约 2% 存活,10% Survivor 足够容纳
为什么两个 Survivor?复制算法要求"一块已用 + 一块空",交替使用,极端情况由老年代兜底(分配担保)
对象流转过程
新对象 → Eden 分配(优先用 TLAB 无锁分配)
↓ (Eden 满,Minor GC)
存活对象 → 复制到空闲 Survivor,年龄+1,Eden + 旧 Survivor 清空
↓ (每次 Minor GC,两个 Survivor 交替,年龄+1)
年龄 ≥ 15 → 晋升老年代
↓ (老年代满)
触发 Full GC
晋升老年代的五种途径
| 途径 | 条件 |
|---|---|
| 年龄达标 | Minor GC 每次年龄+1,到达 MaxTenuringThreshold=15(对象头 age 字段 4 bit,最大 15) |
| 动态年龄判断 | Survivor 中同龄对象总大小 > Survivor 一半 → 该年龄及以上全部晋升 |
| Survivor 放不下 | Minor GC 后目标 Survivor 不够用 → 多余对象直接进老年代(分配担保) |
| 大对象直接分配 | 超过 -XX:PretenureSizeThreshold → 直接在老年代分配,避免在 Eden/Survivor 间来回复制 |
| 空间分配担保 | Minor GC 前检查老年代连续空间。不够 → Full GC |
四、三色标记法 + 漏标问题
CMS 和 G1 的并发标记阶段使用三色标记:
三种颜色含义:
白色(White)→ 还没被 GC 碰过,未知死活(起始全白)
灰色(Gray) → GC 已碰到,但下游对象还没检查完
黑色(Black)→ 该对象 + 所有下游都检查完了
流转过程:
初始:所有对象 = ○(白色)
① 标记 GC Roots 直接引用的对象为灰色
●A ○B ○C 灰色队列 = [A]
│
○D
② 取出 A,检查 A 的引用 → D 变灰,A 变黑
◎A ○B ○C 灰色队列 = [D]
│
●D
③ 取出 D,D 无引用 → D 变黑
◎A ○B ○C 灰色队列 = 空 → 标记完成
│
◎D
结果:黑色(A、D) = 活;白色(B、C) = 死,可回收
规则:不能直接从白变黑(必经灰色);灰色队列清空 = 标记结束。
并发标记漏标问题
两个条件同时满足 → 漏标(活对象被错误回收):
- 黑色对象新增了对白色对象的引用
- 灰色对象删除了对该白色对象的引用
| 收集器 | 解决机制 | 原理 |
|---|---|---|
| CMS | 增量更新(Incremental Update) | 黑色对象新增引用时重新记灰 → 并发标记后重新扫描这些对象 |
| G1 | SATB(Snapshot At The Beginning) | GC 开始时做逻辑快照(假设都活着),删除引用时记录到 SATB 队列 → 事后处理 |
五、7 种垃圾收集器选型
分代收集器
(1)Serial / Serial Old
- 新生代 Serial:单线程、复制算法。老年代 Serial Old:单线程、标记-整理
- 适用:<100MB 堆、桌面应用、嵌入式。小堆场景反而是最快的(单线程无线程切换开销)
- 参数:
-XX:+UseSerialGC
(2)ParNew
- 新生代多线程 Serial,老年代配 CMS。JDK 9 废弃、JDK 14 移除,被 G1 替代
(3)Parallel Scavenge / Parallel Old(JDK 8 默认)
- 新生代 + 老年代都是多线程。核心目标:吞吐量优先(STW 时间/总运行时间最小化)
- 适用:后台批处理、科学计算等不关心单次停顿只关心总吞吐的场景
- 参数:
-XX:+UseParallelGC、-XX:MaxGCPauseMillis(停顿目标)vs-XX:GCTimeRatio(吞吐量目标,两者互斥)
(4)CMS(Concurrent Mark Sweep,JDK 14 移除)
- 老年代收集器,标记-清除算法。核心目标:最短 STW,GC 线程和用户线程并发执行
- 四阶段:① 初始标记(STW,极短)→ ② 并发标记(无 STW,长)→ ③ 重新标记(STW,较短)→ ④ 并发清除(无 STW,长)
- 致命缺陷:标记-清除产生内存碎片→ 碎片多到无法分配 → 退化为 Serial Old 单线程 Full GC(极慢);浮动垃圾(并发标记期间新产生的垃圾等下一轮);对 CPU 敏感
- 参数:
-XX:+UseConcMarkSweepGC、-XX:CMSInitiatingOccupancyFraction=70
不分代收集器
(5)G1(Garbage First,JDK 9+ 默认) ⭐
革命性设计——Region 化内存布局:
传统堆 = 两间固定房间(新生代 + 老年代)
G1 堆 = ~2048 个可移动隔断的格子,每个按需当 Eden/Survivor/Old/Humongous
类比停车场:传统是东半边临时车位、西半边长期车位——打扫西半边必须全扫。
G1 是每个车位可随时改类型——哪个脏了清理哪个,扫完清空,下次当临时车位用。
G1 如何筛选 Region——"Garbage First"名字的由来:
① 并发标记阶段统计每个 Region 的存活字节数(live_bytes)
② 计算性价比 = 垃圾量 / 存活对象量
③ 从性价比最高的 Region 开始回收,直到预测时间接近 MaxGCPauseMillis
④ 性价比低的 Region 留等下次(多攒点垃圾再收更划算)
三种 GC 模式:
| 模式 | 触发 | 回收范围 |
|---|---|---|
| Young GC | Eden Region 满 | 仅 Eden,STW |
| Mixed GC(G1 特有) | 堆占用达 IHOP(默认 45%) | 所有 Eden + 按性价比筛选的部分 Old Region |
| Full GC(最坏情况) | Mixed GC 来不及 / 碎片化 / Metaspace 满 | 全堆,退化为 Serial Old 单线程 → STW 极长 |
关键概念:
- RSet(Remembered Set) :每个 Region 维护"谁引用了我"。通过写屏障(Write Barrier)维护——每次
obj.field = newValue时 JIT 插入代码更新 RSet。Minor GC 时只需查 RSet,不用全堆扫描 - CSet(Collection Set) :本轮要回收的 Region 集合,选性价比最高的
- SATB:并发标记阶段解决漏标,GC 开始时对堆做逻辑快照
参数:-XX:+UseG1GC、-XX:MaxGCPauseMillis=200(期望最大停顿,不硬保证)、-XX:InitiatingHeapOccupancyPercent=45
(6)Shenandoah(JDK 15 生产)
- 基于 Region,与 G1 的核心区别:并发回收——复制对象的同时用户线程继续运行(通过 Brooks Pointer 转发指针实现)
- 核心目标:停顿与堆大小无关,不管 10GB 还是 100GB,停顿相近
- 适用:>16GB 大堆 + 低延迟
(7)ZGC(JDK 15 生产)
- 染色指针(Colored Pointers) 技术——在 64 位指针上直接编码 GC 状态,不需额外元数据
- 核心目标:亚毫秒级停顿(<1ms) ,无论堆大小,TB 级堆也保持低延迟
- 适用:TB 级堆 + 超低延迟(游戏服务器、金融交易系统)
- 参数:
-XX:+UseZGC
选型速查
| GC | 目标 | 堆大小 | STW | 适用场景 |
|---|---|---|---|---|
| Serial | 单线程简单 | <100MB | 长 | 桌面应用、嵌入式 |
| Parallel | 吞吐量 | <4GB | 较长 | 批处理、后台计算 |
| CMS | 低延迟 | <8GB | 较短 | 已淘汰,遗留系统 |
| G1 | 平衡延迟/吞吐 | 4-64GB | 可控 | Web 服务首选(JDK 9+) |
| Shenandoah | 超低延迟 | >16GB | 极短 | 大堆低延迟 |
| ZGC | 亚毫秒延迟 | TB 级 | <1ms | 超大堆超低延迟 |
不同 GC 的核心区别在"并发程度"——哪些阶段 STW,哪些阶段并发。CMS(初始+重新标记 STW)→ G1(Young GC 仍 STW,但按 Region 分批回收老年代)→ ZGC(全阶段并发,只极短 STW 做根扫描)。
六、G1 生产调优实战
关键参数速查
| 参数 | 默认值 | 含义 | 何时调 |
|---|---|---|---|
-XX:MaxGCPauseMillis | 200 | 期望最大 STW(ms),非硬保证 | 接口 RT 敏感时调低(100ms) |
-XX:G1HeapWastePercent | 5 | 允许浪费的堆空间 % | 内存紧张时调低(3%) |
-XX:G1MixedGCLiveThresholdPercent | 85 | Region 存活对象超此比例→不回收 | 老年代晋升快时调高(90%),更积极回收 |
-XX:G1NewSizePercent | 5 | Young Gen 占堆最小% | 年轻对象多时调大(10%),减少过早晋升 |
-XX:InitiatingHeapOccupancyPercent | 45 | 老年代占比超此→触发并发标记 | 调低(35%)让 Mixed GC 更早介入 |
案例一:Full GC 每 3 分钟一次,接口 RT 从 50ms 飙升到 2s
症状:支付接口 RT 从平时 50ms 飙升到 2s,jstat 发现 Full GC 每 3 分钟一次,每次 STW 约 1.5s。
排查:
jstat -gc <pid> 1000 # 每秒看 GC
# 发现:Eden 1 秒从空到满 → 高频年轻对象
# Survivor 100% → 对象直接晋升老年代
# 老年代增长 80MB/分钟 → 3 分钟触发 Full GC
jmap -histo:live <pid> # 找到元凶
# 支付回调 Map 缓存未设上限,每次回调塞一个对象从不清理
临时 GC 参数调整(代码修复之前缓冲):
-XX:MaxGCPauseMillis=150 # 降低 STW 目标
-XX:G1NewSizePercent=10 # 增大 Young Gen → Survivor 更大 → 减少过早晋升
-XX:InitiatingHeapOccupancyPercent=35 # 更早开始并发标记 → 更早 Mixed GC
效果:Full GC 频率从每 3 分钟降到约 10 分钟一次。
根本修复:代码加 Map 容量上限 + LRU 淘汰(LinkedHashMap(10000, 0.75f, true) 重写 removeEldestEntry)。
案例二:CMS Concurrent Mode Failure → 数秒长停顿
症状:线上服务每隔一段时间出现几秒长停顿,GC 日志出现 Concurrent Mode Failure。
根因:CMS 并发回收时,对象分配+晋升速度 > CMS 回收速度 → 老年代满了 CMS 还没扫完 → 退化为 Serial Old 单线程整理几 GB 老年代 → STW 从几十 ms 跳到好几秒。
解决:
# ① 调低触发阈值,给 CMS 留更多时间
-XX:CMSInitiatingOccupancyFraction=60 # 从 70% 降到 60%
# ② 调大 Survivor,减少过早晋升
-XX:SurvivorRatio=6 # 从 8 调到 6
# ③ 终极方案:换 G1——按 Region 分批回收,不会"整块来不及扫"
-XX:+UseG1GC
GC 调优黄金原则
- 先优化代码,再调 GC——GC 参数是补丁不是根治。优化顺序:内存泄漏 → 对象生命周期 → GC 参数
- 一次只改一个参数——改多个分不清谁起效
- 压测验证——生产级流量压测至少 30 分钟,jstat 监控 GC 频率和 STW
- G1 的 MaxGCPauseMillis 不是硬保证——对象分配速度超过回收速度,G1 还是退化为 Full GC
- 记录 GC 日志:
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=10M,事后用 GCeasy 分析
七、知识串联:一口气讲完内存与 GC
"介绍 JVM 的内存模型"不是一个背概念题,而是考察能不能把内存 + GC + 收集器三个知识点串联起来。标准叙述分四段:
第一段 — 6 个运行时数据区:线程私有的三个(程序计数器、虚拟机栈、本地方法栈)+ 线程共享的三个(堆、方法区/Metaspace、运行时常量池)+ 直接内存。JDK 8 关键变化:PermGen→Metaspace(堆外),消除了固定大小的 PermGen OOM。
第二段 — 堆分代 + 对象流转:98% 对象朝生夕死 → 分代设计。新生代 Eden:S0:S1=8:1:1,复制算法。对象年龄满 15 或动态年龄判断晋升老年代。五种晋升途径要能说全。
第三段 — 可达性分析 + 三色标记:Java 用可达性分析(对比引用计数法——Python/Swift 用,循环引用是致命缺陷)。三色标记是并发标记的核心工具。漏标两个条件同时满足,CMS 用增量更新、G1 用 SATB 分别解决。
第四段 — 收集器选型:Serial(小堆)→ Parallel(吞吐量,JDK 8 默认)→ CMS(低延迟,已淘汰)→ G1(Region 化 + 可预测停顿,JDK 9+ 默认,Web 服务首选)→ ZGC(染色指针,亚毫秒停顿,TB 级堆)。
核心要点回顾
可达性分析 vs 引用计数——JVM 选择可达性分析的根源在于引用计数法无法处理循环引用(A↔B 互相引用但无外部可达时计数永不为零,造成内存泄漏),而可达性分析从 GC Roots 出发沿引用链遍历,不可达即垃圾,天然免疫此问题。三色标记法以白(未标记)、灰(已标记但子引用未扫描)、黑(已标记且子引用已扫描)三种状态驱动并发标记的推进。漏标发生的充要条件是"黑色对象新增了指向白色对象的引用,同时灰色对象删除了指向该白色对象的引用"——CMS 用增量更新(写屏障将新增引用记录到 mod-union table,remark 阶段重新扫描)解决,G1 用 SATB(写屏障在引用断裂前将旧值记录到 SATB 队列,remark 阶段重新扫描"曾经被引用过的对象")解决。
对象晋升有五条路径:年龄达到 15(默认 MaxTenuringThreshold)、动态年龄判定(同龄对象占用超过 Survivor 一半时该年龄及以上全部晋升)、Survivor 空间不足(Minor GC 时存活对象超出 Survivor 容量直接进老年代)、大对象(超过 G1HeapRegionSize/2 直接分配到 Humongous Region)、空间分配担保(老年代剩余空间不足以容纳可能晋升的全部对象时触发 Full GC)。G1 的核心设计是将堆划分为约 2048 个大小相等的 Region,每个 Region 可在逻辑上切换为 Eden/Survivor/Old/Humongous 四种角色,通过 RSet(记忆集——记录"谁引用了我",避免 GC 时全堆扫描)和 CSet(回收集合——Mixed GC 中除全部 Young Region 外还包含垃圾占比最高的部分 Old Region)实现 Garbage First——优先回收垃圾最多的 Region,性价比最大化。
GC 演进沿着"吞吐→延迟→超大堆"的维度推进:Serial(客户端小堆)→ Parallel(吞吐量优先,JDK 8 默认)→ CMS(并发低延迟但碎片化,已标记废弃)→ G1(Region 化 + 可预测停顿,JDK 9+ 默认,Web 服务首选)→ ZGC(染色指针 + 亚毫秒停顿,TB 级堆)。Web 服务默认使用 G1(-XX:+UseG1GC),调优的核心参数是 MaxGCPauseMillis(期望的最大停顿时间)和 InitiatingHeapOccupancyPercent(触发并发标记周期的堆占用阈值)。调优黄金原则:先优化代码(减少对象分配速率)> 分配合适的堆大小 > 选择合适的 GC > 微调 GC 参数,jstat 加 GC 日志是标配工具链。
GC 调优不是调大 Xmx 就完了——CMS、G1、ZGC 每个适用场景完全不同。收藏这份对比表,下次选型直接用。
上一篇:《JVM内存模型》 | 下一篇:《并发编程(一):volatile+synchronized》 系列合集:掘金Java合集