Java GC 30年演进:从Serial到ZGC的停顿时间优化史
2026/4/30大约 11 分钟
Java GC 30年演进:从Serial到ZGC的停顿时间优化史
适读人群:想彻底理解JVM GC体系、做过GC调优的Java开发者 | 阅读时长:约25分钟
开篇故事
2019年,我们的交易系统有一个诡异的问题:每隔几分钟,接口P99延迟就会突刺到2-3秒,但P50一直很好。监控图上看起来像周期性的"尖刺"。
排查了两天,最后在JVM GC日志里找到了答案:
[GC pause (G1 Evacuation Pause) (young), 0.8234567 secs]
[GC pause (G1 Mixed, 2.1456789 secs]Full GC停了2秒,所有请求都被暂停了。
当时我们切换到了ZGC,将停顿时间从秒级降到了毫秒级,P99从最坏情况2秒降到了100ms以内。
但GC是怎么一步步演进到这个程度的?为什么Serial GC需要停顿,G1 GC怎么改进的,ZGC又做了什么才实现亚毫秒停顿?今天把这30年的演进史全部梳理清楚。
一、GC的基本原理和挑战
1.1 为什么需要GC?
Java内存模型(简化):
JVM堆
├── Young Generation(年轻代)
│ ├── Eden Space
│ └── Survivor Space (S0 + S1)
└── Old Generation(老年代/Tenured)
对象生命周期假设(弱代际假设):
- 大多数对象年轻时就死亡
- 存活久的对象倾向于继续存活
基于此假设的分代回收策略:
- 频繁回收年轻代(Minor GC)
- 偶尔回收整个堆(Full GC / Major GC)1.2 GC的三大挑战
挑战1:停顿时间(Latency)
GC通常需要暂停应用线程(Stop-the-World, STW)
停顿时间直接影响用户体验
挑战2:吞吐量(Throughput)
GC消耗CPU时间,减少应用可用CPU时间
GC效率越低,应用吞吐量越低
挑战3:内存占用(Footprint)
GC算法本身需要额外内存
不同算法有不同的内存开销
三者是矛盾的:
降低停顿 → 需要并发GC → 吞吐量略低 → 内存开销增加
提高吞吐 → 可以更长时间GC → 停顿可能更长
降低内存 → GC频率增加 → 停顿更频繁二、GC演进历史(1995-2024)
2.1 演进总览
┌──────────────────────────────────────────────────────────────────┐
│ Java GC 演进时间线 │
│ │
│ 1995 JDK1.0 Serial GC(单线程,世界暂停) │
│ │ │
│ 2002 JDK1.4 Parallel GC(多线程,依然STW) │
│ │ │
│ 2006 JDK6 CMS GC(并发标记清除,低停顿) │
│ │ │
│ 2012 JDK7u4 G1 GC(Region化,可预测停顿) │
│ │ │
│ 2014 JDK8 G1成为重要选项(JDK9默认) │
│ │ │
│ 2019 JDK11 ZGC(亚毫秒停顿,可伸缩) │
│ │ │
│ 2019 JDK12 Shenandoah GC(RedHat,低停顿) │
│ │ │
│ 2021 JDK15 ZGC生产就绪 │
│ │ │
│ 2024 JDK21 ZGC默认分代(JEP 439) │
└──────────────────────────────────────────────────────────────────┘2.2 各GC对比
三、各GC算法深度解析
3.1 Serial GC(1995)
原理:
- 单线程执行GC
- Minor GC:复制算法(Eden + S0 → S1)
- Major/Full GC:标记-整理(Mark-Compact)
- 执行时完全STW
停顿时间:
- Minor GC:几ms到几十ms
- Full GC:秒级(堆越大越慢)
配置:
-XX:+UseSerialGC
适用场景:
- 单CPU机器(嵌入式,极小内存应用)
- 堆内存 < 100MB的场景3.2 Parallel GC(JDK1.4/JDK8默认)
改进:
- 多线程并行执行GC(利用多核CPU)
- 但仍然完全STW
- 停顿时间与线程数成反比(吞吐量提升明显)
配置:
-XX:+UseParallelGC
-XX:ParallelGCThreads=N # 默认CPU核心数
停顿时间:
- Minor GC:几ms(多核下很快)
- Full GC:仍然秒级(大堆)
适用场景:
- 批处理作业(不关心停顿,关心吞吐量)
- 后台数据处理
JDK8默认,JDK9开始G1取代3.3 CMS GC(JDK1.4,JDK14废弃)
关键创新:并发标记(Concurrent Mark)
工作阶段:
1. Initial Mark(STW,很短):标记GC Roots直接可达对象
2. Concurrent Mark(并发):追踪存活对象(应用线程同时运行)
3. Remark(STW,较短):修正并发期间的变化
4. Concurrent Sweep(并发):清除不可达对象
优点:
- 大大减少了老年代GC的停顿时间
- 停顿时间:几十ms(比Parallel小10倍)
缺点:
- 内存碎片(清除算法不整理)
- 并发失败时退化为Serial Full GC(反而更慢)
- CPU占用高(并发GC消耗CPU)
- Floating Garbage问题
JDK14废弃,JDK15移除3.4 G1 GC(JDK7,JDK9默认)
关键创新:Region化设计
核心改变:
- 堆被划分为固定大小的Region(1-32MB)
- Region动态扮演Eden/Survivor/Old/Humongous角色
- 不需要连续的老年代空间(解决CMS碎片问题)
- 可以设置停顿时间目标(-XX:MaxGCPauseMillis)
G1 Region示意图:
┌──┬──┬──┬──┬──┬──┬──┬──┐
│ E│ E│ S│ O│ O│ H│ E│ O│
└──┴──┴──┴──┴──┴──┴──┴──┘
E=Eden, S=Survivor, O=Old, H=Humongous(大对象)
工作流程:
1. Young GC:回收所有Eden+Survivor Region(STW,多线程)
2. Concurrent Marking:并发标记Old Region的存活对象
3. Mixed GC:回收所有Eden + 部分Old Region(STW)
4. Full GC(备选):整堆回收(Serial方式)
停顿时间目标:
-XX:MaxGCPauseMillis=200 # 默认200ms
G1会动态调整回收的Region数量来达到目标
适用场景:
- 内存 4GB~100GB的应用
- 对停顿时间有要求(几十ms级别)
- 典型Web应用3.5 ZGC(JDK11实验,JDK15 GA)
关键创新:颜色指针(Colored Pointers)+ 读屏障(Load Barrier)
核心理念:
- 几乎所有GC工作都并发进行
- 只有极短的STW(统计约1ms以内)
- 停顿时间不随堆大小增长(O(1) STW)
颜色指针:
64位指针中有几个比特位表示对象状态
┌────────────────────────────────────────────────┐
│ ... │ Marked0 │ Marked1 │ Remapped │ Finalizable│ address │
└────────────────────────────────────────────────┘
每次GC阶段翻转这些标记位
读屏障:
每次读取对象引用时,检查颜色位
如果对象已经被移动,自动更新引用
代价:每次读取多几条指令(约5-10%的CPU开销)
停顿时间:
- JDK21实测:P99 < 1ms(典型场景)
- 与堆大小无关(TB级堆也是亚毫秒停顿)
适用场景:
- 对延迟极度敏感的应用(交易系统、游戏服务器)
- 超大堆(100GB~TB级)
- 需要低GC开销的CPU密集型应用
配置:
-XX:+UseZGC
JDK21: -XX:+UseZGC -XX:+ZGenerational # 分代ZGC(JEP 439)四、完整代码示例:GC行为观察
4.1 GC日志分析工具
import java.lang.management.*;
import java.util.*;
import java.util.concurrent.*;
/**
* GC行为观察和分析工具
*/
public class GCAnalysisTool {
// ===== 观察GC停顿时间 =====
static void observeGCPauses() throws Exception {
List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
// 打印所有GC收集器
for (GarbageCollectorMXBean gc : gcBeans) {
System.out.printf("GC: %s, Pools: %s%n",
gc.getName(), Arrays.toString(gc.getMemoryPoolNames()));
}
// 记录GC开始状态
long[] startCounts = gcBeans.stream()
.mapToLong(GarbageCollectorMXBean::getCollectionCount).toArray();
long[] startTimes = gcBeans.stream()
.mapToLong(GarbageCollectorMXBean::getCollectionTime).toArray();
// 触发一些GC
allocateGarbage(100);
Thread.sleep(2000);
// 记录GC结束状态
for (int i = 0; i < gcBeans.size(); i++) {
GarbageCollectorMXBean gc = gcBeans.get(i);
long count = gc.getCollectionCount() - startCounts[i];
long time = gc.getCollectionTime() - startTimes[i];
if (count > 0) {
System.out.printf("%s: %d次GC, 总停顿时间%dms, 平均%.1fms%n",
gc.getName(), count, time, (double) time / count);
}
}
}
// 分配大量短生命周期对象(触发Minor GC)
static void allocateGarbage(int megabytes) {
long target = megabytes * 1024 * 1024;
long allocated = 0;
List<byte[]> sink = new ArrayList<>();
while (allocated < target) {
byte[] data = new byte[1024]; // 1KB
// 90%的概率成为垃圾(模拟真实应用)
if (Math.random() > 0.1) {
// 不持有引用,成为垃圾
} else {
sink.add(data); // 少数存活
if (sink.size() > 100) sink.remove(0); // 老年代压力
}
allocated += data.length;
}
}
// ===== 内存池监控 =====
static void monitorMemoryPools() {
List<MemoryPoolMXBean> pools = ManagementFactory.getMemoryPoolMXBeans();
System.out.printf("%-30s %-10s %-15s %-15s%n",
"Pool", "Type", "Used", "Max");
System.out.println("-".repeat(75));
for (MemoryPoolMXBean pool : pools) {
MemoryUsage usage = pool.getUsage();
System.out.printf("%-30s %-10s %-15s %-15s%n",
pool.getName(),
pool.getType(),
formatBytes(usage.getUsed()),
usage.getMax() < 0 ? "unlimited" : formatBytes(usage.getMax())
);
}
}
static String formatBytes(long bytes) {
if (bytes < 1024) return bytes + "B";
if (bytes < 1024 * 1024) return bytes / 1024 + "KB";
return bytes / 1024 / 1024 + "MB";
}
// ===== GC通知监听 =====
static void listenGCNotifications() {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
if (gc instanceof NotificationEmitter emitter) {
emitter.addNotificationListener((notification, handback) -> {
if (notification.getType().equals(
com.sun.management.GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) {
var info = com.sun.management.GarbageCollectionNotificationInfo
.from((javax.management.openmbean.CompositeData) notification.getUserData());
System.out.printf("[GC] %s: %s, 停顿=%dms%n",
info.getGcName(),
info.getGcAction(),
info.getGcInfo().getDuration());
}
}, null, null);
}
}
}
public static void main(String[] args) throws Exception {
System.out.println("JVM: " + System.getProperty("java.vm.name"));
System.out.println("GC: " + ManagementFactory.getGarbageCollectorMXBeans()
.stream().map(GarbageCollectorMXBean::getName).toList());
System.out.println("\n=== 内存池 ===");
monitorMemoryPools();
System.out.println("\n=== GC停顿分析 ===");
observeGCPauses();
}
}4.2 大对象分配测试(对比各GC)
/**
* 不同GC算法下的行为测试
* 通过JVM参数切换:
* java -XX:+UseSerialGC -Xmx512m GCBehaviorTest
* java -XX:+UseG1GC -Xmx512m GCBehaviorTest
* java -XX:+UseZGC -Xmx512m GCBehaviorTest
*/
public class GCBehaviorTest {
static volatile Object sink; // 防止JIT消除
public static void main(String[] args) throws Exception {
String gcName = ManagementFactory.getGarbageCollectorMXBeans()
.stream().map(b -> b.getName()).toList().toString();
System.out.println("当前GC: " + gcName);
// 测试1:大量小对象分配(压测Minor GC)
System.out.println("\n=== 测试1:小对象分配(10M次)===");
testSmallObjectAllocation();
// 测试2:长停顿检测(P99延迟)
System.out.println("\n=== 测试2:停顿时间检测 ===");
testPauseDetection();
}
static void testSmallObjectAllocation() {
long start = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
sink = new byte[128]; // 128字节小对象
}
System.out.printf("10M次小对象分配耗时: %dms%n",
System.currentTimeMillis() - start);
}
static void testPauseDetection() throws Exception {
long[] pauses = new long[1000];
int pauseCount = 0;
long maxPause = 0;
// 后台分配垃圾
Thread gcThread = Thread.ofVirtual().start(() -> {
try {
for (int i = 0; i < 50_000_000; i++) {
sink = new byte[new Random().nextInt(1024)];
if (i % 10000 == 0) Thread.sleep(1);
}
} catch (InterruptedException e) {}
});
// 前台检测停顿
long testStart = System.currentTimeMillis();
while (System.currentTimeMillis() - testStart < 10000) { // 测10秒
long before = System.nanoTime();
Thread.sleep(1); // 1ms睡眠
long elapsed = (System.nanoTime() - before) / 1_000_000;
if (elapsed > 10) { // 超过10ms视为停顿
if (pauseCount < pauses.length) {
pauses[pauseCount++] = elapsed;
}
maxPause = Math.max(maxPause, elapsed);
}
}
gcThread.interrupt();
gcThread.join();
System.out.printf("检测到停顿次数: %d%n", pauseCount);
System.out.printf("最大停顿: %dms%n", maxPause);
if (pauseCount > 0) {
long total = 0;
for (int i = 0; i < pauseCount; i++) total += pauses[i];
System.out.printf("平均停顿: %.1fms%n", (double) total / pauseCount);
}
}
}五、踩坑实录
坑1:G1的Region大小设置不当
# 默认Region大小计算:堆大小 / 2048(约1-32MB)
# 问题:如果对象大小 > Region大小/2,会被放入Humongous Region
# Humongous对象回收开销大,频繁分配会有性能问题
# 例子:堆512MB,Region=256KB,对象>128KB就是Humongous
# 解决方案:
-XX:G1HeapRegionSize=8m # 手动设置Region大小
# 这样>4MB的对象才算Humongous
# 诊断命令:
# java -Xlog:gc+heap=debug -XX:+UseG1GC YourApp
# 观察Humongous Allocation的频率坑2:G1停顿时间目标不是硬性保证
# 误区:设了-XX:MaxGCPauseMillis=200就保证停顿<200ms
# 实际:这只是一个"目标",G1会尽力达到,但不保证
# 以下情况会超出目标:
# 1. Full GC(后备方案)
# 2. GC工作量太大(存活对象太多)
# 3. Mixed GC阶段的Humongous对象处理
# 解决方案:
# 1. 降低设置值(牺牲吞吐量换延迟)
-XX:MaxGCPauseMillis=100
# 2. 增大堆内存(减少GC频率)
-Xmx8g
# 3. 对停顿要求极高的场景,考虑ZGC
-XX:+UseZGC坑3:ZGC的CPU开销
# ZGC并发GC需要额外的CPU
# 读屏障(Load Barrier)增加约5-15%的CPU开销
# 并发GC线程消耗CPU
# 症状:CPU使用率比G1高,但停顿时间短
# 如果应用CPU受限(已经90%+),ZGC可能会让情况更糟
# 调整ZGC线程数:
-XX:ConcGCThreads=2 # 默认CPU核数/4
# 权衡:
# - IO密集型应用(CPU空闲):ZGC效果很好
# - CPU密集型应用:G1或Parallel可能更好坑4:CMS的Concurrent Mode Failure
# CMS问题:如果并发GC来不及,会退化为Serial Full GC
# 症状:偶发性的几秒停顿
# 日志:[concurrent mode failure]
# 原因:
# 老年代空间不足,但并发GC还没完成
# 此时必须STW进行Full GC
# 解决方案:
-XX:CMSInitiatingOccupancyFraction=70 # 70%时触发CMS(默认68%)
-XX:+UseCMSInitiatingOccupancyOnly # 只用上面的阈值
# 根本解决:升级到G1或ZGC(CMS已废弃)坑5:忘记开启GC日志导致无法排查
# JDK8的GC日志配置
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100m
# JDK9+的统一日志配置(JEP 158)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
# 推荐最低配置(生产必开):
-Xlog:gc:file=/var/log/app/gc.log:time,uptime
# 详细配置(排查问题时):
-Xlog:gc*:file=/var/log/app/gc-detail.log:time,uptime,level,tags
# 分析工具:
# GCeasy(在线分析)
# GCViewer(开源图形工具)
# JDK自带:jhsdb, jstat
jstat -gcutil <pid> 1000 # 每秒输出GC统计六、总结与延伸
6.1 GC选择指南
场景 推荐GC 关键参数
─────────────────────────────────────────────────────
内存 < 1GB,单核 Serial -XX:+UseSerialGC
批处理,吞吐优先 Parallel -XX:+UseParallelGC
通用Web应用,中等延迟 G1(默认) -XX:MaxGCPauseMillis=200
低延迟(<10ms),大堆 ZGC -XX:+UseZGC
容器/微服务(小内存) Shenandoah/ZGC 根据JDK版本
停顿时间 吞吐量 内存开销 最大堆
Serial 秒级 低 低 <100MB推荐
Parallel 秒级 高 低 中等堆
CMS 百ms 中 中 已废弃
G1 百ms 中高 中 4~100GB
ZGC <1ms 中 高 任意6.2 版本对应关系
| JDK版本 | 默认GC | 新增GC |
|---|---|---|
| JDK8 | Parallel GC | — |
| JDK9 | G1 GC | — |
| JDK11 | G1 GC | ZGC实验 |
| JDK12 | G1 GC | Shenandoah |
| JDK15 | G1 GC | ZGC GA |
| JDK21 | G1 GC | ZGC分代版 |
6.3 延伸阅读
下一篇(第401期)专讲G1 GC调优实战;第402期讲ZGC的颜色指针技术原理。
