这篇文章解决一个长期困扰开发者的问题:同样是自动内存管理,为什么 Java 要做复杂的分代回收,而 Go 坚持不分代?读完你会明白,GC 没有绝对的优劣,只有场景的取舍——Java 赌的是灵活性,Go 赌的是确定性。有意思的是,走到 2026 年,这两条相反的路线在「亚毫秒停顿」这一个指标上会师了。

信息核验时点:2026-10-18(当前稳定版 Go 1.27,发布于 2026-08-19,最新补丁版本 go1.27.2;JDK 27 发布于 2026-09-15,最近一个 LTS 是 JDK 25,发布于 2025-09-16)


本文要点
  • 一句话定义:垃圾回收是编程语言运行时自动识别并回收不再被引用的内存对象的机制(Garbage Collection,简称 GC),它把开发者从手动释放内存中解放出来,代价是额外的 CPU 开销和周期性的停顿。
  • 核心结论:Java 从分代假说出发,用多款可插拔回收器换高吞吐与灵活性,代价是调优复杂;Go 从并发标记出发,用一套固定的三色标记+混合写屏障换确定性低延迟,代价是约 25% 的 CPU 预算和不做内存整理。
  • 2026 年的新变化:Go 1.26 把标记引擎换成了 Green Tea(按内存页批量扫描),官方预计 GC 的 CPU 开销降低 10% 到 40%,但 Go 至今不分代;Java 一侧,JDK 26 用 JEP 522 重写了 G1 的写屏障,JDK 27 用 JEP 523 把 G1 设成所有环境的默认回收器。
  • 适用场景:Java GC 适合批处理、大数据计算等追求总处理量、且需要按业务特征调优的场景;Go GC 适合微服务、API 网关、实时通信等对 P99 延迟敏感的在线服务。
  • 短板 / 边界:Java 参数众多,选错回收器可能出现秒级停顿;Go 不移动对象,长时间运行后碎片会积累,堆越大标记阶段吃掉的 CPU 越多。
  • 演进趋势:停顿时间已经不再是两者的分水岭,真正的分歧变成「谁为 GC 买单」——Java 用堆余量和吞吐买,Go 用固定的 CPU 预算买。

垃圾回收解决的到底是什么问题?

垃圾回收是编程语言运行时提供的自动内存管理能力。它通过追踪对象引用关系,识别程序不再使用的内存对象并回收其空间,让开发者不必再手动配对分配与释放。

在没有 GC 的 C/C++ 世界里,开发者必须精确控制每一块内存的生命周期。这带来两类极其隐蔽的错误:一类是忘记释放导致内存泄漏,程序运行越久占用越多;另一类是提前释放导致悬垂指针,后续访问时程序随机崩溃,且极难复现调试。这两类错误怎么发生、又怎么在堆与栈之间辨别,我在内存泄漏是怎么发生的:一场堆与栈的租房与酒店之争里专门写过;如果你更想看清手动管理那一侧的全貌,C 语言从入门到精通:从 C23 新标准到内存底层的 2026 实用指南里有一整章讲内存模型。

GC 不是新发明,但它是被两门语言带进主流工程视野的:Java 在 1996 年正式发布时就内置了自动内存管理,Go 则从 2012 年 4 月的 Go 1.0 起把并发 GC 当作卖点。两者的取舍从第一天就不同,而这个不同一直延续到今天。

GC 的代价是运行时需要额外 CPU 时间扫描和回收对象,并且在特定阶段必须暂停所有用户线程——这就是臭名昭著的 STW(Stop-The-World)问题。不同 GC 算法与实现的全部努力,本质上都是在吞吐量、停顿延迟、内存占用三者之间做权衡。

三大基础算法分别解决了什么痛点?

现代 GC 的所有复杂实现,都建立在三种基础算法之上。理解这三者的优缺点,才能看懂后续所有演进的逻辑。

标记-清除算法

标记-清除(Mark-Sweep)是最基础的 GC 算法,分为两个阶段:标记阶段从 GC Roots(栈上局部变量、全局变量、寄存器引用等)出发,遍历所有可达对象并打上存活标记;清除阶段遍历整个堆,回收所有未被标记的对象。

这种算法实现简单,不需要额外内存空间,但有两个致命缺陷:一是标记和清除过程效率都不高,存活对象多时遍历开销大;二是回收后会产生大量内存碎片,当需要分配大对象时,即使总空闲空间足够,也可能找不到连续内存而提前触发下一次 GC。

复制算法

复制算法(Copying)为解决碎片问题而生。它将内存划分为大小相等的两块,每次只使用其中一块。回收时将存活对象全部复制到另一块空白区域,然后一次性清空原来的整块内存。

复制算法彻底消除了碎片,分配内存时只需移动指针,效率极高。但缺点也很明显:内存利用率只有 50%,一半空间始终闲置。不过研究发现,绝大多数对象都是「朝生夕死」的——新创建的对象 90% 以上在第一次 GC 时就会变成垃圾。基于这个发现,现代商用虚拟机都采用改良版复制算法:将新生代划分为一块较大的 Eden 区和两块较小的 Survivor 区(默认比例 8:1:1),内存利用率提升到 90%。

标记-整理算法

标记-整理(Mark-Compact)结合了前两者的优点,主要用于存活对象较多的老年代。标记阶段与标记-清除相同,但清除前多了一步整理:将所有存活对象向内存一端移动,然后直接清理边界外的内存。

这种算法既解决了碎片问题,又不像复制算法那样浪费空间。代价是移动对象需要更新所有指向它的指针,开销显著高于前两种算法。

分代回收为什么成为 Java 的核心选择?

分代收集(Generational Collection)不是一种具体算法,而是一种基于经验假说的工程分层策略。它基于两个观察:弱分代假说认为绝大多数对象朝生夕死;强分代假说认为熬过越多次 GC 的对象越难以死亡。

基于这两个假说,JVM 把堆划分为新生代和老年代。新生代存放新创建的对象,采用复制算法,因为每次存活对象少、复制成本低;老年代存放长期存活的对象,采用标记-整理或标记-清除,因为存活对象多、复制成本高。跨代引用靠卡表(Card Table)记录,避免每次回收新生代都要扫全堆。

分代设计带来了显著的效率提升。新生代回收(Minor GC)频率高但速度快,常见量级在几毫秒到几十毫秒;老年代回收(Major GC/Full GC)频率低但停顿长,通常在几十毫秒到几百毫秒。对于 Java 最初瞄准的企业级应用与批处理任务,这种「少数几次长停顿换整体高吞吐」的取舍是可以接受的。

Go 为什么坚持不做分代回收?

Go 团队在设计 GC 时走了一条完全不同的路。从 Go 1.0 到 2026 年的 Go 1.27,runtime 中始终没有按年龄划分的年轻代/老年代区域,所有堆对象统一视作同质节点处理。

Go 官方给出的理由主要有三点。第一,Go 有编译期逃逸分析,大量短命对象根本不会进入堆,而是直接在栈上分配——栈上内存随函数返回自动回收,完全不需要 GC 介入。这使得「绝大多数对象朝生夕死」的分代假说在 Go 中被大幅削弱。第二,分代 GC 需要额外的写屏障来追踪跨代引用,这种写屏障是「始终在线」的,无论 GC 是否在运行,每次指针写入都要付出检查开销。第三,Go 的目标场景是高并发网络服务,goroutine 数量动辄数万甚至数十万,一旦分代,栈上对象的跨代引用追踪成本会随 goroutine 数量增长。

Go 团队的判断是:用低延迟的并发标记算法把全堆扫描做到足够快,比分代带来的理论收益更有工程价值。这个判断在实战里站住了脚——Twitch 的工程师在博客里记录过,Go 运行时从 1.4 演进到 1.6 期间,他们的 GC 停顿时间改善了大约 20 倍,而这期间 Go 的 GC 一行算法都没换分代。

Go 在 1.26 换了标记引擎,为什么还是不分代?

这是很多文章会漏掉的一步。Go 1.26(2026-02-10 发布)把默认的并发标记换成了代号为 Green Tea 的新实现,这是 Go GC 多年来最大的一次内部改动,但它的方向不是分代,而是让单次全堆标记更便宜。

区别在扫描的粒度。旧实现按对象逐个图上泛洪,对象大小不一、地址分散,标记线程在堆里跳来跳去,缓存命中率很低。Green Tea 改为按内存页为单位处理:一次取出一整段连续内存(8 KiB 的 span),在页内用每个对象槽两位的元数据(seen 与 scanned)批量推进,让访存变成顺序流水。官方还给它加了 AVX-512 向量加速,用宽寄存器一次处理整页的位图。

效果用官方的预计数字:GC 的 CPU 开销降低 10% 到 40%,多数应用落在 10% 附近,支持向量加速的机器再额外降约 10%。换算成业务视角,一个原本把 10% 时间花在 GC 上的应用,总 CPU 用量能省出 1% 到 4%。仍然想退回旧实现的话,可以在构建时用 GOEXPERIMENT=nogreenteagc 关掉。

值得强调的是它没有改的东西:依旧是三色标记,依旧是标记-清除,依旧不移动对象,依旧没有新生代和老年代。也就是说 Go 用一次大改强化了原来的取舍,而不是转向 Java 的路线。想系统了解这门语言的设计取向,可以看Go 语言从入门到精通:2026 完整学习路线图与实战指南。

Go 的亚毫秒停顿是怎么做到的?

Go GC 的基石是三色标记法。它把所有对象抽象成三种颜色:白色代表尚未扫描,GC 结束时仍为白色的就是垃圾;灰色代表自身已被发现,但它指向的对象还没扫描完;黑色代表自身和它指向的所有对象都已扫描完成,确认存活。

串行执行的三色标记是正确的,但要做到与用户代码并发执行,就必须解决「漏标」问题:标记过程中如果用户代码让一个已标黑的对象指向了白色对象,同时删掉了所有灰色对象到它的路径,这个活对象就会被误回收,程序随机崩溃。

写屏障就是为了解决这个问题。Go 1.8 引入的混合写屏障(Hybrid Write Barrier)结合了 Dijkstra 插入屏障和 Yuasa 删除屏障的优点:程序修改指针时,编译器提前注入的指令会把被覆盖的旧对象标灰;如果当前 goroutine 的栈还没扫描过,同时把新指向的对象也标灰。这个设计消灭了栈重扫描——栈一旦扫完就不再重复扫,STW 不再与 goroutine 数量正相关。所谓写屏障,本质就是编译器在每次指针赋值前后多塞几条机器指令,这部分开销的来源可以对照从 print(Hello) 到 CPU 里的电信号:一行代码的编译与执行之旅来看。

最终 Go GC 的 STW 只剩两个极短阶段:初始标记做根对象快照,以及标记终止时重新扫描少量遗漏的灰对象,两者都是亚毫秒量级;标记、清扫、重置全部与用户代码并发执行。代价是一条写进官方文档的硬约束:GC 并发阶段最多占用约 25% 的 CPU 资源。用 CPU 换确定性延迟,正是 Go 的核心取舍。

Java 如何在吞吐量与延迟间走平衡木?

与 Go「一种 GC 打天下」不同,Java 提供的是一整套回收器菜单,让开发者按业务场景自行选择。好处是灵活性极高,坏处是调优复杂度也极高。

Parallel GC(吞吐量优先) 是 JDK 8 在服务器配置下的默认回收器,也是 Java 高吞吐哲学的代表。它多线程并行回收,目标是最大化单位时间内的业务执行时间占比。Parallel GC 追求的是总停顿时间最短,而不是单次停顿最短——比如 1 秒内两次 200ms 停顿(合计 400ms)比五次 100ms 停顿(合计 500ms)更优。这对后台计算、批处理非常合适,但对在线服务来说,一次 200ms 停顿足以导致请求超时。

G1(Garbage-First) 从 JDK 9 起在服务器配置下成为默认回收器,试图在吞吐与延迟之间取平衡。它把堆划分成大小相等的 Region,不再固定分代边界,而是优先回收垃圾最多的 Region。G1 允许设置目标停顿时间(-XX:MaxGCPauseMillis,默认 200ms),但这只是尽力而为的目标,不是硬保证。JDK 27 的 JEP 523 进一步取消了「资源受限环境用 Serial GC」的例外,把 G1 设成所有环境的默认回收器——Java 也开始向「开箱即用」让步了。

JEP 522(JDK 26) 是对 G1 内部的一次重写:把共享的卡表拆成应用线程与 GC 线程各一份的双卡表(Dual Card Table),消除两者争用同一数据结构带来的同步开销,写屏障注入的指令数量从五十多条降到十二条左右。官方给出的吞吐提升区间是 5% 到 15%,多数负载在 5% 以内,停顿时间只有轻微改善。这个数字很关键:它说明 Java 一侧最近的优化目标已经是吞吐,而不是继续压停顿。

ZGC 是 Java 对低延迟场景的回应,时间线值得记准:JEP 377 在 JDK 15 让它转正,JEP 439 在 JDK 21 引入分代模式,JEP 474 在 JDK 23 把分代设为默认,JEP 490 在 JDK 24 直接删掉非分代模式。它的核心武器是染色指针(Colored Pointers)——把标记与重定位元数据编码进 64 位指针的高位,绝大多数阶段与用户线程并发执行,停顿不随堆大小增长,面向的是几十 MB 到十几 TB 的堆。代价是内存与吞吐:并发回收需要预留更多堆余量,同样的负载下吞吐通常不如 Parallel 与 G1。

顺带一提,JDK 25 这个 LTS 上的 GC 相关变化是 JEP 519 把对象头从 96 位压到 64 位(省堆、顺带减轻标记压力),以及 JEP 521 让 Shenandoah 也进入分代模式。JVM 与字节码执行的整体机制,可以对照一次编写,到处运行:Java 跨平台二十年的真相与代价和Java 从入门到精通:从 JDK 25 LTS 到现代 Java 的 2026 实用指南读。

Go 和 Java 的 GC 设计到底差在哪?

为了直观对比两种设计的差异,我整理了一张对比表:

对比维度Go GCJava GC(典型配置)
核心目标确定性低延迟高吞吐优先,可配置
分代策略非分代,统一全堆标记(2026 年仍如此)分代回收,新生代/老年代分离
STW 时间亚毫秒量级,与 goroutine 数量基本无关G1:数十至两百毫秒(目标可调);ZGC:亚毫秒
GC 的 CPU 预算并发阶段不超过约 25%Parallel:回收阶段接近满载;ZGC:并发回收额外吃 CPU,吞吐随之下降
内存整理不移动对象,有碎片风险复制/整理阶段移动对象,无碎片
可配置性只有 GOGC 与 GOMEMLIMIT 两个旋钮,开箱即用多款回收器可选,JVM 参数数十个,调优复杂
典型适用堆大小常见于数 GB 量级,堆越大标记吃掉的 CPU 越多ZGC 面向 TB 级大堆,停顿几乎不随堆增长
写屏障开销混合写屏障,主要在一次回收周期内生效卡表写屏障常驻,JDK 26 的 JEP 522 把它压到约 12 条指令

两者最根本的差异是世界观不同。Java 假设 GC 是一个复杂系统,应该给开发者提供丰富的工具和选项,让专家通过调优拿到最佳性能;Go 假设 GC 应该是沉默的基础设施,默认配置就足够好,不让开发者为它分心。Java 允许多种 GC 并存,是因为它面对的场景太广——从嵌入式设备到 TB 级内存的大数据集群,一个算法不可能包打天下;Go 坚持只做一种 GC,是因为它瞄准的就是云原生微服务这个特定场景,在这里简单可预测比灵活可配置更值钱。

常见问题

大堆场景下 Go GC 会不会崩?

会有性能问题,但不会「崩」。Go GC 每次都要做全堆标记,堆越大,标记阶段扫过的对象越多、占用的 CPU 越高,留给业务的部分就越少;同时因为不做对象移动,长时间运行后碎片会逐步积累。

真正被工程实践验证过的故事来自 Twitch。2019 年 4 月,他们的工程师公开了自己怎么治这个问题:给进程额外分配一大块只占位、几乎不使用的内存(称为 ballast),让活跃堆在一个更大的基数上稳定下来,从而把 GC 的触发节奏摊开。改完之后,他们的 API 前端服务 CPU 占用下降了约 30%,P99 延迟也显著改善。这段经验的真正价值在于它指出了 Go GC 的行为特性——回收时机跟的是「相对活跃堆的增长比例」,而不是绝对大小——这也直接促成 Go 1.19(2022 年 8 月)引入了 GOMEMLIMIT 软内存限制,把当年手搓 ballast 的做法变成了官方旋钮。

对大多数微服务来说,单实例堆通常在几 GB 以内,Go GC 在这个量级表现很好。但如果你的服务确实需要几十 GB 的堆,Java ZGC 或 C++ 是更合适的选择——这也意味着「Go 处理不了大堆」不是 bug,而是它选场景时早就划好的边界。

为什么现在 Java 也开始追求低延迟了?

因为部署环境变了。Java 诞生于 20 世纪 90 年代,最初瞄准桌面应用和企业级服务器,那时应用追求的是总处理量,几百毫秒的停顿用户感知不到。但到了微服务和云原生时代,单次请求的 P99 延迟成了核心指标,一次 200ms 的 GC 停顿就可能导致上游超时重试,进而引发雪崩。

所以 Java 一路把延迟压下来:G1 不断收敛停顿,ZGC 用染色指针直接瞄准亚毫秒,并在 JDK 21 到 24 之间完成了分代化改造。从结果看,在停顿这个单项上 ZGC 已经和 Go 处在同一量级。但实现路径依然相反:Java 是在保留分代、保留可插拔架构的前提下,用复杂的指针技巧做到低延迟;Go 从一开始就放弃了分代和压缩,用更简单的架构达到同样的观感。

生产环境到底该怎么选?

没有标准答案,看你的业务优先级。如果是离线批处理、大数据计算、Spark/Flink 这类吞吐优先的场景,Java 的 Parallel GC 或 G1 仍是最佳选择,它们的总处理量更高。如果是微服务、API 网关、实时通信、游戏服务器这类延迟敏感的在线服务,Go 的开箱即用和确定性低延迟会省掉大量调优工作。

如果团队有深厚的 JVM 调优经验,或者业务堆特别大、重度依赖 Java 生态,JDK 21 之后默认分代的 ZGC 同样是很好的低延迟选择。如果你两个都要——用 Go 写延迟敏感的入口,用 Java 扛吞吐重的计算,这在 2026 年的云原生架构里已经是常态。归根结底,GC 选型是工程取舍问题,不存在绝对优劣,只存在是否适合你的场景。

本文版本号与 JEP 编号核验于 2026-10,以 Go 官方 Release Notes 与 OpenJDK JEP 页面为准。

发表评论

验证码图片,点击可更换