Go vs Java GC:同一场延迟战争的两条路

Go 和 Java 面对 STW 停顿走了相反的路——Go 赌确定性(不分代+并发标记),Java 赌灵活性(分代+Region+染色指针)。同一工作负载实测:Go 225 次 GC 每次 38µs,Java G1 仅 14 次但暂停 214µs,ZGC 几乎零停顿却吃 88MB 堆。理解这个分叉,比选边站更有价值。

封面

Go 和 Java 面对同一个敌人——STW 停顿,在同一个分叉口走了相反的路。一个赌确定性,一个赌灵活性。

上一篇讲了 Go GC 如何用十年把 STW 从 300ms 压到亚毫秒。这篇补另一半故事:Java 那条路走的是什么?两条路又是怎么在 2025 年碰面的?

分叉口:2014

2014 年,Go 的垃圾回收器还在用 STW 标记清除。每次 GC,整个程序停 300 毫秒。

同年,Java 已经迭代了三代回收器。最早的 Serial 是单线程全暂停。CMS 从 JDK 1.4.2 开始做并发标记,把停顿压到几十毫秒。G1 在 JDK 7u4 登场,把堆切成 Region,做选择性回收。

起跑线就不一样。Java 在 GC 这条路上已经走了十五年,Go 才刚动手。

但 Go 团队做了一个关键决定:不走 Java 的路。

Java 选了分代——基于"大多数对象朝生夕死"的假说,把堆分成新生代和老年代,频繁回收新生代。代价是写屏障(write barrier)始终在线:每次指针写入都要检查是否跨代引用,无论 GC 是否在运行。

Go 的判断是(这在 Rick Hudson 2018 年的 ISMM 演讲中有迹可循):写屏障的常驻开销会拖慢所有应用代码。对于 Go 的目标场景——高并发、低延迟的网络服务——这笔税不可接受。

Go 选了另一条路:不分代,每次扫全堆,但用并发标记把 STW 压到极致。

2014 年分叉点

两条路,同一个目标。接下来的十年,各自打各自的仗。

一、Go 的路:不分代,赌确定性

Go 1.5 引入并发三色标记。STW 从 300 毫秒降到 100-300 微秒,三个数量级。

标记过程和用户代码并发执行,GC 不再独占 CPU。STW 只剩初始标记和终止标记两个极短的阶段。代价是写屏障——GC 运行期间,每次指针写入都要通知 GC,防止漏标。和 Java 分代写屏障的"始终在线"不同,Go 的写屏障只在 GC 活跃期间工作,但 Go GC 频率高,活跃比例并不低。

Go 1.8 进一步引入混合写屏障,消灭了栈重扫描。STW 不再与 goroutine 数量正相关。

GOGC 控制 GC 触发频率,GOMEMLIMIT 给堆加硬上限。

我在上篇跑过一组实测:同一个工作负载,GOGC=100 时 GC 跑 225 次,每次暂停 38 微秒,最大停顿 158 微秒。GC CPU 占比 3.45%。

Go 的策略:频繁回收,每次极短,用 CPU 换延迟确定性。

但这条路有代价——每次 GC 都扫全堆,堆大了 CPU 开销会飙升。Twitch 报告过大堆场景下 GC CPU 开销超过 20%。不做内存整理,碎片化是结构性问题。

Go GC 演进时间线

Go 接受这些代价。它的判断是:对于大多数 Go 服务(堆 < 4GB),CPU 开销可控,碎片化可忍。简单比复杂好。

同一时期,Java 正在走第三代回收器 G1,解决 CMS 遗留的碎片问题——走的是截然不同的一条路。

二、Java 的路:分代收集,赌灵活性

Go 选了"一种 GC 打天下",Java 选了"每一代回收器解决上一代的问题"。

先说 ZGC 的结果:亚毫秒级 STW,几乎零停顿。但代价是堆占用 88MB(同样的工作负载 Go 用 3.3MB),默认堆下直接吃到 770MB。Java 怎么走到这一步的?

回到起点。CMS(JDK 1.4.2)是 Java 早期的并发回收器。它把老年代的标记过程拆成并发阶段,STW 压到几十毫秒。但 CMS 不做内存整理。碎片积累到一定程度,触发一次 Full GC——STW 回到秒级。

G1(JDK 7u4)换了思路。它把堆切成大小相等的 Region(默认 2048 个)。Region 不是固定分区,而是按需动态切换角色——同一个 Region 上一轮可能是 Eden,这一轮可能变成 Old。G1 选择性回收垃圾最多的 Region(Garbage-First,名字由此而来),用复制算法在回收的同时做内存整理。

G1 解决了 CMS 的碎片问题,但 STW 仍然在毫秒级。你可以设 -XX:MaxGCPauseMillis=200,G1 会尽量控制在 200 毫秒内——但这是"尽力而为",不是硬保证。

我用同一组工作负载(1000 万次 64B 对象分配,1 万活跃对象)跑了 Java G1:14 次 GC,总暂停 3 毫秒。比 Go 的 225 次少很多——分代收集高效,大部分短生命周期对象在 Young Generation 就被回收了,根本不进 Old。

Java GC 演进时间线

ZGC(JDK 11 实验,JDK 15 正式)是 Java 对"亚毫秒延迟"的回答。它的核心武器是染色指针(Colored Pointers)——就像把快递单号印在包裹上,不需要查表就知道这个包裹处于什么状态。GC 把标记、重定位、重映射的元数据直接编码到 64 位指针的高位中,全部并发完成。STW 只剩线程同步的固定开销,不随堆大小增长。

Oracle 的官方目标:STW 不超过 1 毫秒。

从 Serial 到 CMS 到 G1 到 ZGC,Java 一路升级回收器。每一代解决上一代的问题,同时带来新的工程复杂度。这是 Java 的选择——用持续迭代换性能和灵活性。

三、交汇:Green Tea vs ZGC

2025 年,两条路碰面了。

Go 的最新动作是 Green Tea GC(Go 1.25 实验性引入,Go 1.26 默认启用)。它改变了扫描的基本单位:从逐对象扫描改为按页(span)扫描,利用位图和 SIMD 指令批量处理。核心目标不是压 STW——那已经是亚毫秒了——而是降低 GC 的 CPU 开销,解决大堆场景下 Cache Miss 导致的性能瓶颈。

Green Tea 还引入了弱分代优化,把堆分成"新"和"旧"两个代。这是一种让步——十年坚持不分代之后,大堆场景的 CPU 开销迫使 Go 在简单性上做了妥协。不是 Java 那种严格分代,但方向上,Go 开始向分代思想靠拢。

Java 的对应动作是 Generational ZGC(JDK 21 正式)。在原版 ZGC 的基础上加入分代,年轻代频繁回收,老年代按需回收。Generational ZGC 有望在未来版本中成为 Java 的默认收集器。

Green Tea vs ZGC 交汇

两边在靠拢。Go 开始接纳分代思想,Java 的 ZGC 用染色指针绕过了传统整理的 STW 开销。

但路径的差异仍然巨大。Go 的 Green Tea 依然不做指针重定位,不用染色指针,不设加载屏障(Load Barrier)——Go 不愿意在每次指针加载时都付出检查颜色位的运行时开销。Java 的 ZGC 依然需要这个 Load Barrier,换来的是并发重定位的能力。

目标趋同,手段仍然分叉。代价各不相同。

四、代价:你选了什么,就没选什么

我分别用 Go 1.26 和 Java 21(G1 / ZGC)跑了同一组工作负载:1000 万次 64B 对象分配,保持 1 万个活跃对象。Go 编译为原生二进制直接运行;Java 侧未做 JIT 预热(首次运行),冷启动数据。

Go (GOGC=100) Java G1 (-Xmx256m) Java ZGC (-Xmx256m)
总耗时 131ms 47ms 79ms
GC 次数 225 14 12
平均暂停 38µs ~214µs 亚毫秒
最大暂停 158µs N/A* N/A*
堆占用 3.3MB 23MB 88MB
GC CPU 3.45%

* Java MXBean 不提供单次最大暂停数据。Go 的暂停数据来自 runtime.MemStats.PauseNs。

堆占用口径说明:Go 报 HeapAlloc(用户对象),Java 报 JVM 堆占用(含运行时管理开销)。两者不直接可比,Java 的 JVM 基础开销约 30-50MB。

实测数据对比

数字背后是三种不同的取舍。

Go 用 CPU 换确定性。225 次 GC,对 Web 服务而言每次 38 微秒几乎感知不到,但 GC CPU 占比 3.45%。不分代意味着每次都扫全堆,堆一大 CPU 就贵。但你不需要选 GC 类型,不需要调参数,GOGC=100 开箱即用。

Java G1 用调优换可预测性。只跑了 14 次 GC——分代收集的效率优势在这里一目了然。堆占用更大,但 Region 机制让碎片问题基本消失。你需要理解分代模型,需要调 -XX:MaxGCPauseMillis,但换来的是大堆场景下远低于 Go 的 CPU 开销。

Java ZGC 用内存换极致延迟。几乎零 STW,延迟维度最优。代价是堆占用远高于其他两者——ZGC 用染色指针实现并发重定位,每次指针加载都要过 Load Barrier,同时需要额外的内存空间支撑并发标记和重定位。

尾声:你愿意付出什么代价

如果你在做技术选型,或者想理解自己服务的 GC 行为:

延迟敏感、堆不大(< 4GB)的网络服务——Go 的并发标记+不分代是最简单的选择。默认 GOGC=100 就够用,P99 毛刺用 GOMEMLIMIT 兜底。不需要选 GC 类型,不需要调十几个参数。

大堆、高吞吐的后端服务——Java G1 或 Generational ZGC。分代收集在大堆场景效率更高,G1 的 Region 机制让碎片问题基本消失。你愿意付出学习成本和调优成本,换来更好的大堆表现。

极端延迟要求(亚毫秒硬保证)——Java ZGC 或 Go,看你能接受哪种代价。ZGC 给你硬保证但吃内存;Go 在小堆下延迟同样优秀,但大堆 CPU 开销是隐患。

场景决策框架

作为一个写 Go 的人,我个人的体感是:Go 的 GC 赢在"不用想"。对于我日常接触的服务(堆 < 2GB,P99 延迟敏感),Go 的默认配置已经足够好。但如果哪天我接手一个 16GB 堆的缓存服务,我会认真考虑 Java 的方案。

工程决策最终要回答一个问题:你愿意付出什么代价。Go 选了简单,代价是大堆乏力。Java 选了灵活,代价是你得自己选对 GC。

两条路,同一场延迟战争。