2017年,某高频交易公司的Java系统在关键交易时段出现了一次3.2秒的停顿。这不是网络故障,也不是数据库锁死——仅仅是垃圾回收器在清理一个16GB的堆。在金融交易的世界里,3.2秒意味着数百万美元的损失。
这个故事在技术圈并不罕见。垃圾回收器自诞生之日起,就面临着一个看似不可调和的矛盾:回收效率与停顿时间的博弈。要彻底清理垃圾,必须知道哪些对象是活的;要准确知道哪些对象是活的,通常需要暂停所有应用线程——这个"暂停",就是困扰了Java开发者二十多年的"Stop-The-World"(STW)问题。
但过去十年,垃圾回收技术经历了前所未有的技术突破。从G1的Region化设计,到ZGC的着色指针,再到Shenandoah的并发压缩,停顿时间从秒级压缩到毫秒级,再到亚毫秒级。2023年,Netflix宣布将超过一半的核心流媒体服务迁移到Generational ZGC,观察到P99延迟下降的同时,CPU利用率不增反降——这打破了"低延迟必然牺牲吞吐量"的传统认知。
为什么垃圾回收需要停顿?
要理解现代GC的技术突破,必须先理解为什么停顿会存在。
垃圾回收的核心任务是识别并回收不再使用的对象。现代JVM采用追踪式垃圾回收(Tracing GC),基本思路是:从一组根对象(栈变量、静态变量、JNI引用等)出发,遍历所有可达对象,不可达的就是垃圾。
问题在于:应用程序和GC线程在同时操作同一块内存。如果GC在遍历对象图时,应用程序正在修改引用关系,就可能出现严重的错误——比如把一个已经死亡的对象错误地标记为存活,或者把一个存活的对象误判为垃圾。
考虑这个经典场景:GC正在扫描对象A的引用字段,发现A指向B。此时,应用程序执行了A.field = C,然后让B变得不可达。如果GC按照"扫描时看到的状态"判断,B会被标记为存活——但B实际上已经变成垃圾了。更危险的是反向情况:如果GC已经扫描过A,认为A没有指向B,但应用程序随后让A引用了B,那么B可能被错误地回收,导致程序崩溃。
为了安全,传统GC选择最简单的方案:暂停所有应用线程,在静止的世界里完成标记和清理。这就是STW的由来。
分代假说:降低停顿的第一步
早期的垃圾回收器(如Serial GC、Parallel GC)对整个堆进行统一回收。但人们很快发现一个规律:绝大多数对象的生命周期都很短。
1984年,Appel在论文中正式提出了分代假说(Generational Hypothesis):
- 弱分代假说:大多数对象在年轻时就会死亡
- 强分代假说:越老的对象越可能继续存活
这个看似简单的观察,催生了分代垃圾回收器的设计:将堆划分为年轻代(Young Generation)和老年代(Old Generation)。新对象在年轻代分配,年轻代满了就进行Minor GC;存活足够久的对象晋升到老年代,老年代满了才进行Major/Full GC。
分代设计的精妙之处在于:Minor GC只扫描年轻代,不需要扫描整个堆。由于年轻代通常很小(几百MB到几GB),且大部分对象都是垃圾,回收速度很快。只有老年代满时才需要Full GC——而老年代的增长速度远低于年轻代的回收频率。
但这里有一个技术难题:老年代对象可能引用年轻代对象。如果只扫描年轻代,如何知道哪些对象是活的?
卡表与写屏障
答案是卡表(Card Table)和写屏障(Write Barrier)。
卡表是一个字节数组,每个字节对应堆中的一块内存(通常是512字节)。当老年代对象引用年轻代对象时,JVM通过写屏障在卡表中标记对应的字节。Minor GC时,只需扫描卡表中标记的区域,就能找到所有老年代到年轻代的引用。
graph LR
subgraph Old[老年代]
O1[对象A]
O2[对象B]
end
subgraph Young[年轻代]
Y1[对象C]
Y2[对象D]
end
subgraph CardTable[卡表]
CT[标记字节]
end
O1 -->|引用| Y1
O1 -.->|写屏障触发| CT
CT -.->|扫描时识别| O1
style CT fill:#ffeb3b
写屏障是一段在每次引用赋值时执行的代码。虽然增加了开销,但避免了Minor GC时扫描整个老年代,是一个关键的权衡。
G1:Region化设计的革命
2009年,JDK 7引入了G1(Garbage-First)垃圾回收器,这是Java垃圾回收史上的一个里程碑。G1的核心创新是Region化的堆设计。
传统分代GC的堆是连续的:年轻代是一整块,老年代也是一整块。G1将堆划分为2048个左右的等大小Region(1MB-32MB),每个Region可以是Eden、Survivor、Old或Humongous(大对象)。
为什么Region化很重要?
Region化带来两个关键优势:
第一,增量回收老年代。传统GC回收老年代必须暂停整个应用,G1可以选择性地回收一部分Old Region。通过并发标记识别哪些Region垃圾最多,优先回收它们——这就是"Garbage-First"名字的由来。
第二,可预测的停顿时间。G1允许用户设置目标停顿时间(-XX:MaxGCPauseMillis,默认200ms),然后根据历史数据计算在目标时间内能回收多少Region。
Remembered Set:跨Region引用追踪
Region化的代价是跨Region引用的追踪开销。每个Region需要维护一个Remembered Set(RSet),记录哪些外部Region引用了自己。RSet的实现使用三种数据结构:
- Sparse:存储少量引用,使用哈希表
- Fine:存储较多引用,使用位图
- Coarse:存储大量引用,使用粗粒度位图
RSet的维护由写屏障完成。每次引用赋值时,写屏障会更新目标对象所在Region的RSet。这带来约5%-10%的运行时开销,但避免了GC时扫描整个堆。
SATB:并发标记的正确性保证
G1的并发标记使用Snapshot-At-The-Beginning(SATB)算法。SATB的核心思想是:在标记开始时拍摄对象图的快照,标记过程中新创建的对象都视为活的。
SATB通过写屏障实现:当应用程序修改引用时,写屏障会记录原来的引用值。这样,即使对象图在标记过程中被修改,GC仍然按照"快照"状态完成标记。
但SATB有一个问题:会产生浮动垃圾(Floating Garbage)——标记开始时存活、但标记结束时已经死亡的对象。这些对象需要等到下一次GC才能回收。这是正确性和效率的权衡。
并发压缩:停顿时间的最后堡垒
G1虽然实现了并发标记,但对象移动(Evacuation)仍然需要STW。原因很简单:移动对象意味着修改所有指向它的引用,这在并发环境下极其复杂——应用程序可能正在读取一个对象,而GC线程同时把它搬走了。
这正是ZGC和Shenandoah要解决的核心问题。
Shenandoah的Brooks转发指针
Shenandoah由Red Hat开发,其核心创新是Brooks转发指针(Brooks Forwarding Pointer)。
每个对象头部额外存储一个转发指针,正常情况下指向自己。当对象被移动时,转发指针指向新地址。应用程序每次访问对象时,都要先读取转发指针——这就是读屏障(Load Barrier)。
对象移动前:
┌─────────────────┐
│ Forwarding Ptr ─┼──┐
│ Mark Word │ │
│ Class Pointer │ │
│ Fields... │ ←┘
└─────────────────┘
对象移动后:
┌─────────────────┐ ┌─────────────────┐
│ Forwarding Ptr ─┼──┐ │ Mark Word │
│ (from-space) │ └─→│ Class Pointer │
└─────────────────┘ │ Fields... │
└─────────────────┘
(to-space)
JDK 13之后,Shenandoah进一步优化,消除了额外的转发指针字,改用对象头中的Mark Word存储转发信息,减少了5%-10%的内存开销。
ZGC的着色指针
ZGC由Oracle开发,采用了更激进的方案:着色指针(Colored Pointer)。
在64位系统中,指针实际上只使用48位进行寻址,高位有16位是空闲的。ZGC利用其中的4位存储GC状态:
- Finalizable:标识需要执行finalize方法的对象
- Remapped:引用已被重定向到新地址
- Marked0/Marked1:标记状态(两个标记位用于区分连续GC周期)
63 47 46 44 43 0
┌──────────────┬────────┬──────────────────┐
│ Unused │ Color │ Object Address │
│ (16 bits) │(4 bits)│ (44 bits) │
└──────────────┴────────┴──────────────────┘
着色指针的精妙之处在于:状态信息直接嵌入指针本身,无需额外的内存访问。当应用程序读取引用时,读屏障只需检查指针的颜色位,就能知道对象是否需要处理。
多映射:x86的妥协方案
着色指针有一个问题:CPU不认识这些颜色位。ARM架构支持Pointer Masking,可以忽略指定位;但x86不支持。
ZGC的解决方案是多映射(Multi-Mapping):将同一块物理内存映射到三个虚拟地址区间,分别对应Marked0、Marked1、Remapped三种视图。
虚拟地址空间:
┌─────────────────────────────┐ 0x0000140000000000 (20TB)
│ Remapped View │
├─────────────────────────────┤ 0x0000100000000000 (16TB)
│ (Reserved) │
├─────────────────────────────┤ 0x00000c0000000000 (12TB)
│ Marked1 View │
├─────────────────────────────┤ 0x0000080000000000 (8TB)
│ Marked0 View │
└─────────────────────────────┘ 0x0000040000000000 (4TB)
│
│ 都映射到同一块物理内存
▼
┌─────────────────────────────┐
│ Physical Memory │
└─────────────────────────────┘
多映射的代价是:虚拟地址空间开销增加3倍。对于4TB的堆,需要12TB的虚拟地址空间——好消息是,64位系统的虚拟地址空间足够大(128TB+),这通常不是问题。
Generational ZGC:回归分代的智慧
ZGC最初是单代(非分代)设计,JDK 21引入了Generational ZGC,JDK 23将其设为默认。
为什么回归分代?因为分代假说依然有效:大多数对象确实很短命。单代ZGC每次都要扫描整个堆,即使大部分对象都是刚分配的垃圾。分代设计让年轻代的频繁回收变得极其廉价。
Generational ZGC的关键挑战是:如何在保持读屏障简洁的同时,实现分代间的正确引用追踪?
解决方案是双层颜色编码:指针的颜色位同时编码两个信息——是哪个代(Young/Old),以及该代的标记状态。年轻代GC时,只需要检查年轻代颜色的指针;老年代GC时,检查老年代颜色的指针。
Netflix的实践数据表明:Generational ZGC相比非分代ZGC,吞吐量提升约10%,P99停顿时间减少10-20%。对于大量使用堆内缓存的服务(如Netflix的Hollow数据框架),这一提升尤为显著。
生产环境的真实经验
Netflix:超过一半核心服务已迁移
2024年,Netflix技术博客分享了他们从G1迁移到Generational ZGC的经验:
延迟改善:在GRPC和DGS Framework服务中,GC停顿是尾延迟的主要来源。迁移后,最大停顿时间从数百毫秒降到亚毫秒级,请求超时和重试大幅减少。
意外的吞吐量提升:最初预期ZGC会因读屏障开销降低吞吐量,但实测发现:在相同CPU利用率目标下,ZGC的平均和P99延迟都优于或等于G1。原因在于:ZGC消除了长时间停顿导致的请求积压,整体处理效率反而提高。
内存开销:ZGC有固定3%的堆大小开销(用于多映射等)。但由于停顿时间极短,Netflix得以移除之前的数组池化缓解措施,反而释放了数百MB内存。
透明大页的重要性:ZGC使用共享内存实现堆,Linux默认的shmem_enabled=never会阻止ZGC使用大页。正确配置后,CPU利用率显著下降。
# Netflix推荐的透明大页配置
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo advise | sudo tee /sys/kernel/mm/transparent_hugepage/shmem_enabled
echo defer | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
Uber:大型服务的GC调优
Uber在HDFS NameNode(堆大小200GB+)上的调优经验揭示了另一个维度:
年轻代大小很关键:当总堆从120GB增加到160GB但年轻代不变时,ParNew GC时间反而增加35%。原因是老年代增大后,扫描老年代引用年轻代的开销增加。增加年轻代到16GB后,RPC队列平均时间从500ms降到400ms,最大GC时间从22秒降到1.5秒。
对象创建率的影响:一次Hive Metastore延迟飙升事件中,发现GC每分钟触发2258次,平均停顿177ms。根因是一个后台线程的调度间隔错误设置为1ms而非1秒,导致每秒创建大量临时对象。修复后,GC次数降到143次/分钟,延迟恢复正常。
不适合的场景
Netflix也发现了ZGC不适合的场景:高分配率波动 + 长生命周期对象难以预测的工作负载。这类场景下,G1的停顿时间目标和老年代收集启发式算法反而更有效,能避免ZGC在一些周期中做"无用功"。
不同语言的GC哲学对比
Java不是唯一有GC的语言。对比不同语言的GC设计,能更清晰地理解设计权衡。
Go:极简主义的代价
Go采用非分代的并发三色标记清除GC,设计哲学是"简单优先":
- 优点:实现简单,停顿时间通常在亚毫秒级,适合网络服务
- 缺点:没有压缩,堆会碎片化;每次GC需要扫描整个堆,堆越大GC开销越大
Go的设计者认为:与其花精力优化GC,不如让开发者更容易控制对象生命周期。Go提供了值类型、逃逸分析等工具,鼓励减少堆分配。
.NET:工作站与服务器模式
.NET的GC也是分代设计,但有独特的工作站/服务器模式:
- 工作站模式:适合客户端应用,停顿时间优先
- 服务器模式:适合服务端应用,每个CPU核心一个GC线程,吞吐量优先
.NET还引入了后台GC(Background GC),在并发标记和清除时,允许年轻代GC穿插执行,进一步减少停顿。
Rust:无GC的另一种可能
Rust选择了完全不同的路径:所有权系统 + 编译时内存管理。没有GC,也就没有停顿——但代价是更陡的学习曲线和更复杂的编码约束。
有趣的是,Rust的"零成本抽象"理念与Java的"低停顿GC"追求的是同一个目标:让开发者不必关心内存管理,同时不牺牲性能。只是它们选择了相反的方向:Rust把复杂性推给编译器,Java把复杂性推给运行时。
如何选择垃圾回收器?
没有最好的GC,只有最适合的权衡。以下是选择指南:
| 场景 | 推荐GC | 理由 |
|---|---|---|
| 批处理/ETL | Parallel GC | 吞吐量优先,停顿无影响 |
| 通用服务(<32GB堆) | G1 | 平衡吞吐量和延迟,成熟稳定 |
| 低延迟服务 | Generational ZGC | 亚毫秒停顿,Netflix验证 |
| 超大堆(>64GB) | ZGC/Shenandoah | 停顿时间与堆大小解耦 |
| 容器环境 | ZGC/Shenandoah | 快速启动,低RSS |
核心原则:
- 测量,不要猜测:启用GC日志(
-Xlog:gc*),用GCEasy等工具分析 - 关注P99延迟:平均停顿时间意义不大,看最大值和分布
- 监控分配率:高分配率会放大任何GC的开销
- 测试真实负载:JMH基准测试无法反映生产环境的复杂性
停顿时间还能更低吗?
2023年,ZGC的停顿时间已经稳定在1ms以下,Shenandoah在1-3ms。还能更低吗?
理论上,ZGC和Shenandoah的停顿时间主要由线程握手(Thread Handshake)决定——让所有线程到达安全点(Safepoint)的时间。这部分通常在0.1-0.3ms,已经接近操作系统调度和CPU缓存行为的物理极限。
更激进的方案正在研究中:无安全点GC(Safepoint-less GC),通过更精细的内存屏障和硬件支持,避免全局线程暂停。但这类方案目前仍在学术研究阶段。
垃圾回收技术的演进,本质上是一场与物理限制的博弈。从Serial GC的简单停顿,到G1的增量回收,再到ZGC/Shenandoah的并发压缩,每一次突破都是在"正确性、效率、复杂度"三角中寻找新的平衡点。
Netflix工程师在博客结尾写了一句话:“如果不去质疑假设,我们可能错过十年来最具影响力的变更之一。” 这句话不仅适用于GC选择,也适用于整个技术决策——当你认为某个权衡"不可避免"时,也许有人已经找到了打破它的方法。
参考资料
- OpenJDK Wiki: ZGC Main Page. https://wiki.openjdk.org/spaces/zgc/pages/34668579/Main
- Datadog Blog: A deep dive into Java garbage collectors. https://www.datadoghq.com/blog/understanding-java-gc/
- Netflix Tech Blog: Bending pause times to your will with Generational ZGC. https://netflixtechblog.com/bending-pause-times-to-your-will-with-generational-zgc-256629c9386b
- Uber Engineering Blog: Tricks of the Trade: Tuning JVM Memory for Large-scale Services. https://www.uber.com/blog/jvm-tuning-garbage-collection/
- HubSpot Product Blog: G1GC Fundamentals: Lessons from Taming Garbage Collection. https://product.hubspot.com/blog/g1gc-fundamentals-lessons-from-taming-garbage-collection
- Red Hat Developers: A beginner’s guide to the Shenandoah garbage collector. https://developers.redhat.com/articles/2024/05/28/beginners-guide-shenandoah-garbage-collector
- OpenJDK JEP 439: Generational ZGC. https://openjdk.org/jeps/439
- The Java Engineer: Java Garbage Collector Evolution (1995 – 2025). https://www.thejavaengineer.in/2025/11/java-garbage-collector-evolution-1995.html
- Flaneur’s Blog: Notes on ZGC: Colored Pointers. http://flaneur2020.github.io/posts/2020-08-26-notes-zgc/
- Go Documentation: A Guide to the Go Garbage Collector. https://go.dev/doc/gc-guide
- Microsoft Learn: Fundamentals of garbage collection. https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals