Full GC每10分钟一次、STW超2秒:我从CMS换到G1再到ZGC,STW降到了200ms

19 阅读15分钟

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 的、锁着的"

  1. 虚拟机栈(栈帧中的局部变量表)引用的对象
  2. 方法区中静态变量引用的对象
  3. 运行时常量池引用的对象
  4. JNI 引用的对象
  5. 被 synchronized 持有的对象
从每个 Root 出发,沿着引用链一路找下去:
​
  GC Root ──→ 对象A ──→ 对象B ──→ 对象C   ← 活(可以从 Root 到达)
                               对象D     ← 死(无人能到达)
                              对象E      ← 死

核心优势:天然免疫循环引用——从 GC Roots 出发谁也到不了 a 和 b,双双判定为死。

维度引用计数法可达性分析
原理每个对象计数,归零回收GC Roots 出发,不可达 = 可回收
实时性即时释放,无 STW需等 GC 触发,有 STW
循环引用❌ 需额外机制✓ 天然免疫
性能开销每次赋值更新计数只在 GC 时扫描,平时零开销
代表语言Python, C++, SwiftJava, 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) = 死,可回收

规则:不能直接从白变黑(必经灰色);灰色队列清空 = 标记结束。

并发标记漏标问题

两个条件同时满足 → 漏标(活对象被错误回收):

  1. 黑色对象新增了对白色对象的引用
  2. 灰色对象删除了对该白色对象的引用
收集器解决机制原理
CMS增量更新(Incremental Update)黑色对象新增引用时重新记灰 → 并发标记后重新扫描这些对象
G1SATB(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 GCEden 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:MaxGCPauseMillis200期望最大 STW(ms),非硬保证接口 RT 敏感时调低(100ms)
-XX:G1HeapWastePercent5允许浪费的堆空间 %内存紧张时调低(3%)
-XX:G1MixedGCLiveThresholdPercent85Region 存活对象超此比例→不回收老年代晋升快时调高(90%),更积极回收
-XX:G1NewSizePercent5Young Gen 占堆最小%年轻对象多时调大(10%),减少过早晋升
-XX:InitiatingHeapOccupancyPercent45老年代占比超此→触发并发标记调低(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 调优黄金原则

  1. 先优化代码,再调 GC——GC 参数是补丁不是根治。优化顺序:内存泄漏 → 对象生命周期 → GC 参数
  2. 一次只改一个参数——改多个分不清谁起效
  3. 压测验证——生产级流量压测至少 30 分钟,jstat 监控 GC 频率和 STW
  4. G1 的 MaxGCPauseMillis 不是硬保证——对象分配速度超过回收速度,G1 还是退化为 Full GC
  5. 记录 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合集