
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 压到极致。

两条路,同一个目标。接下来的十年,各自打各自的仗。
一、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 接受这些代价。它的判断是:对于大多数 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。

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 的默认收集器。

两边在靠拢。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。
两条路,同一场延迟战争。