<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Java on 止语Lab</title>
        <link>https://www.wujiachen.com.cn/tags/java/</link>
        <description>Recent content in Java on 止语Lab</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh-cn</language>
        <lastBuildDate>Wed, 15 Apr 2026 21:11:12 +0800</lastBuildDate><atom:link href="https://www.wujiachen.com.cn/tags/java/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>Go vs Java GC：同一场延迟战争的两条路</title>
            <link>https://www.wujiachen.com.cn/posts/go-vs-java-gc/</link>
            <pubDate>Wed, 15 Apr 2026 21:11:07 +0800</pubDate>
            <guid>https://www.wujiachen.com.cn/posts/go-vs-java-gc/</guid>
            <description>&lt;img src=&#34;https://www.wujiachen.com.cn/&#34; alt=&#34;Featured image of post Go vs Java GC：同一场延迟战争的两条路&#34; /&gt;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/cover.png&#34; alt=&#34;封面&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;Go 和 Java 面对同一个敌人——STW 停顿，在同一个分叉口走了相反的路。一个赌确定性，一个赌灵活性。&lt;/p&gt;&#xA;&lt;p&gt;上一篇讲了 Go GC 如何用十年把 STW 从 300ms 压到亚毫秒。这篇补另一半故事：Java 那条路走的是什么？两条路又是怎么在 2025 年碰面的？&lt;/p&gt;&#xA;&lt;h2 id=&#34;分叉口2014&#34;&gt;&lt;a href=&#34;#%e5%88%86%e5%8f%89%e5%8f%a32014&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;分叉口：2014&#xA;&lt;/h2&gt;&lt;p&gt;2014 年，Go 的垃圾回收器还在用 STW 标记清除。每次 GC，整个程序停 300 毫秒。&lt;/p&gt;&#xA;&lt;p&gt;同年，Java 已经迭代了三代回收器。最早的 Serial 是单线程全暂停。CMS 从 JDK 1.4.2 开始做并发标记，把停顿压到几十毫秒。G1 在 JDK 7u4 登场，把堆切成 Region，做选择性回收。&lt;/p&gt;&#xA;&lt;p&gt;起跑线就不一样。Java 在 GC 这条路上已经走了十五年，Go 才刚动手。&lt;/p&gt;&#xA;&lt;p&gt;但 Go 团队做了一个关键决定：不走 Java 的路。&lt;/p&gt;&#xA;&lt;p&gt;Java 选了分代——基于&amp;quot;大多数对象朝生夕死&amp;quot;的假说，把堆分成新生代和老年代，频繁回收新生代。代价是写屏障（write barrier）始终在线：每次指针写入都要检查是否跨代引用，无论 GC 是否在运行。&lt;/p&gt;&#xA;&lt;p&gt;Go 的判断是（这在 Rick Hudson 2018 年的 ISMM 演讲中有迹可循）：写屏障的常驻开销会拖慢所有应用代码。对于 Go 的目标场景——高并发、低延迟的网络服务——这笔税不可接受。&lt;/p&gt;&#xA;&lt;p&gt;Go 选了另一条路：不分代，每次扫全堆，但用并发标记把 STW 压到极致。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/ch0-fork-2014.png&#34; alt=&#34;2014 年分叉点&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;两条路，同一个目标。接下来的十年，各自打各自的仗。&lt;/p&gt;&#xA;&lt;h2 id=&#34;一go-的路不分代赌确定性&#34;&gt;&lt;a href=&#34;#%e4%b8%80go-%e7%9a%84%e8%b7%af%e4%b8%8d%e5%88%86%e4%bb%a3%e8%b5%8c%e7%a1%ae%e5%ae%9a%e6%80%a7&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;一、Go 的路：不分代，赌确定性&#xA;&lt;/h2&gt;&lt;p&gt;Go 1.5 引入并发三色标记。STW 从 300 毫秒降到 100-300 微秒，三个数量级。&lt;/p&gt;&#xA;&lt;p&gt;标记过程和用户代码并发执行，GC 不再独占 CPU。STW 只剩初始标记和终止标记两个极短的阶段。代价是写屏障——GC 运行期间，每次指针写入都要通知 GC，防止漏标。和 Java 分代写屏障的&amp;quot;始终在线&amp;quot;不同，Go 的写屏障只在 GC 活跃期间工作，但 Go GC 频率高，活跃比例并不低。&lt;/p&gt;&#xA;&lt;p&gt;Go 1.8 进一步引入混合写屏障，消灭了栈重扫描。STW 不再与 goroutine 数量正相关。&lt;/p&gt;&#xA;&lt;p&gt;GOGC 控制 GC 触发频率，GOMEMLIMIT 给堆加硬上限。&lt;/p&gt;&#xA;&lt;p&gt;我在上篇跑过一组实测：同一个工作负载，GOGC=100 时 GC 跑 225 次，每次暂停 38 微秒，最大停顿 158 微秒。GC CPU 占比 3.45%。&lt;/p&gt;&#xA;&lt;p&gt;Go 的策略：频繁回收，每次极短，用 CPU 换延迟确定性。&lt;/p&gt;&#xA;&lt;p&gt;但这条路有代价——每次 GC 都扫全堆，堆大了 CPU 开销会飙升。Twitch 报告过大堆场景下 GC CPU 开销超过 20%。不做内存整理，碎片化是结构性问题。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/ch1-go-timeline.png&#34; alt=&#34;Go GC 演进时间线&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;Go 接受这些代价。它的判断是：对于大多数 Go 服务（堆 &amp;lt; 4GB），CPU 开销可控，碎片化可忍。简单比复杂好。&lt;/p&gt;&#xA;&lt;p&gt;同一时期，Java 正在走第三代回收器 G1，解决 CMS 遗留的碎片问题——走的是截然不同的一条路。&lt;/p&gt;&#xA;&lt;h2 id=&#34;二java-的路分代收集赌灵活性&#34;&gt;&lt;a href=&#34;#%e4%ba%8cjava-%e7%9a%84%e8%b7%af%e5%88%86%e4%bb%a3%e6%94%b6%e9%9b%86%e8%b5%8c%e7%81%b5%e6%b4%bb%e6%80%a7&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;二、Java 的路：分代收集，赌灵活性&#xA;&lt;/h2&gt;&lt;p&gt;Go 选了&amp;quot;一种 GC 打天下&amp;quot;，Java 选了&amp;quot;每一代回收器解决上一代的问题&amp;quot;。&lt;/p&gt;&#xA;&lt;p&gt;先说 ZGC 的结果：亚毫秒级 STW，几乎零停顿。但代价是堆占用 88MB（同样的工作负载 Go 用 3.3MB），默认堆下直接吃到 770MB。Java 怎么走到这一步的？&lt;/p&gt;&#xA;&lt;p&gt;回到起点。CMS（JDK 1.4.2）是 Java 早期的并发回收器。它把老年代的标记过程拆成并发阶段，STW 压到几十毫秒。但 CMS 不做内存整理。碎片积累到一定程度，触发一次 Full GC——STW 回到秒级。&lt;/p&gt;&#xA;&lt;p&gt;G1（JDK 7u4）换了思路。它把堆切成大小相等的 Region（默认 2048 个）。Region 不是固定分区，而是按需动态切换角色——同一个 Region 上一轮可能是 Eden，这一轮可能变成 Old。G1 选择性回收垃圾最多的 Region（Garbage-First，名字由此而来），用复制算法在回收的同时做内存整理。&lt;/p&gt;&#xA;&lt;p&gt;G1 解决了 CMS 的碎片问题，但 STW 仍然在毫秒级。你可以设 &lt;code&gt;-XX:MaxGCPauseMillis=200&lt;/code&gt;，G1 会尽量控制在 200 毫秒内——但这是&amp;quot;尽力而为&amp;quot;，不是硬保证。&lt;/p&gt;&#xA;&lt;p&gt;我用同一组工作负载（1000 万次 64B 对象分配，1 万活跃对象）跑了 Java G1：14 次 GC，总暂停 3 毫秒。比 Go 的 225 次少很多——分代收集高效，大部分短生命周期对象在 Young Generation 就被回收了，根本不进 Old。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/ch2-java-timeline.png&#34; alt=&#34;Java GC 演进时间线&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;ZGC（JDK 11 实验，JDK 15 正式）是 Java 对&amp;quot;亚毫秒延迟&amp;quot;的回答。它的核心武器是&lt;strong&gt;染色指针&lt;/strong&gt;（Colored Pointers）——就像把快递单号印在包裹上，不需要查表就知道这个包裹处于什么状态。GC 把标记、重定位、重映射的元数据直接编码到 64 位指针的高位中，全部并发完成。STW 只剩线程同步的固定开销，不随堆大小增长。&lt;/p&gt;&#xA;&lt;p&gt;Oracle 的官方目标：STW 不超过 1 毫秒。&lt;/p&gt;&#xA;&lt;p&gt;从 Serial 到 CMS 到 G1 到 ZGC，Java 一路升级回收器。每一代解决上一代的问题，同时带来新的工程复杂度。这是 Java 的选择——用持续迭代换性能和灵活性。&lt;/p&gt;&#xA;&lt;h2 id=&#34;三交汇green-tea-vs-zgc&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e4%ba%a4%e6%b1%87green-tea-vs-zgc&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三、交汇：Green Tea vs ZGC&#xA;&lt;/h2&gt;&lt;p&gt;2025 年，两条路碰面了。&lt;/p&gt;&#xA;&lt;p&gt;Go 的最新动作是 Green Tea GC（Go 1.25 实验性引入，Go 1.26 默认启用）。它改变了扫描的基本单位：从逐对象扫描改为按页（span）扫描，利用位图和 SIMD 指令批量处理。核心目标不是压 STW——那已经是亚毫秒了——而是降低 GC 的 CPU 开销，解决大堆场景下 Cache Miss 导致的性能瓶颈。&lt;/p&gt;&#xA;&lt;p&gt;Green Tea 还引入了弱分代优化，把堆分成&amp;quot;新&amp;quot;和&amp;quot;旧&amp;quot;两个代。这是一种让步——十年坚持不分代之后，大堆场景的 CPU 开销迫使 Go 在简单性上做了妥协。不是 Java 那种严格分代，但方向上，Go 开始向分代思想靠拢。&lt;/p&gt;&#xA;&lt;p&gt;Java 的对应动作是 Generational ZGC（JDK 21 正式）。在原版 ZGC 的基础上加入分代，年轻代频繁回收，老年代按需回收。Generational ZGC 有望在未来版本中成为 Java 的默认收集器。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/ch3-convergence.png&#34; alt=&#34;Green Tea vs ZGC 交汇&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;两边在靠拢。Go 开始接纳分代思想，Java 的 ZGC 用染色指针绕过了传统整理的 STW 开销。&lt;/p&gt;&#xA;&lt;p&gt;但路径的差异仍然巨大。Go 的 Green Tea 依然不做指针重定位，不用染色指针，不设加载屏障（Load Barrier）——Go 不愿意在每次指针加载时都付出检查颜色位的运行时开销。Java 的 ZGC 依然需要这个 Load Barrier，换来的是并发重定位的能力。&lt;/p&gt;&#xA;&lt;p&gt;目标趋同，手段仍然分叉。代价各不相同。&lt;/p&gt;&#xA;&lt;h2 id=&#34;四代价你选了什么就没选什么&#34;&gt;&lt;a href=&#34;#%e5%9b%9b%e4%bb%a3%e4%bb%b7%e4%bd%a0%e9%80%89%e4%ba%86%e4%bb%80%e4%b9%88%e5%b0%b1%e6%b2%a1%e9%80%89%e4%bb%80%e4%b9%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;四、代价：你选了什么，就没选什么&#xA;&lt;/h2&gt;&lt;p&gt;我分别用 Go 1.26 和 Java 21（G1 / ZGC）跑了同一组工作负载：1000 万次 64B 对象分配，保持 1 万个活跃对象。Go 编译为原生二进制直接运行；Java 侧未做 JIT 预热（首次运行），冷启动数据。&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th style=&#34;text-align: left&#34;&gt;&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Go (GOGC=100)&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Java G1 (-Xmx256m)&lt;/th&gt;&#xA;          &lt;th style=&#34;text-align: center&#34;&gt;Java ZGC (-Xmx256m)&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: left&#34;&gt;总耗时&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;131ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;47ms&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;79ms&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: left&#34;&gt;GC 次数&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;225&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;14&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;12&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: left&#34;&gt;平均暂停&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;38µs&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;~214µs&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;亚毫秒&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: left&#34;&gt;最大暂停&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;158µs&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;N/A*&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;N/A*&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: left&#34;&gt;堆占用&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;3.3MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;23MB&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;88MB&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td style=&#34;text-align: left&#34;&gt;GC CPU&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;3.45%&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;—&lt;/td&gt;&#xA;          &lt;td style=&#34;text-align: center&#34;&gt;—&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;* Java MXBean 不提供单次最大暂停数据。Go 的暂停数据来自 runtime.MemStats.PauseNs。&lt;/p&gt;&#xA;&lt;p&gt;堆占用口径说明：Go 报 HeapAlloc（用户对象），Java 报 JVM 堆占用（含运行时管理开销）。两者不直接可比，Java 的 JVM 基础开销约 30-50MB。&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/ch4-benchmark-data.png&#34; alt=&#34;实测数据对比&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;数字背后是三种不同的取舍。&lt;/p&gt;&#xA;&lt;p&gt;Go 用 CPU 换确定性。225 次 GC，对 Web 服务而言每次 38 微秒几乎感知不到，但 GC CPU 占比 3.45%。不分代意味着每次都扫全堆，堆一大 CPU 就贵。但你不需要选 GC 类型，不需要调参数，GOGC=100 开箱即用。&lt;/p&gt;&#xA;&lt;p&gt;Java G1 用调优换可预测性。只跑了 14 次 GC——分代收集的效率优势在这里一目了然。堆占用更大，但 Region 机制让碎片问题基本消失。你需要理解分代模型，需要调 &lt;code&gt;-XX:MaxGCPauseMillis&lt;/code&gt;，但换来的是大堆场景下远低于 Go 的 CPU 开销。&lt;/p&gt;&#xA;&lt;p&gt;Java ZGC 用内存换极致延迟。几乎零 STW，延迟维度最优。代价是堆占用远高于其他两者——ZGC 用染色指针实现并发重定位，每次指针加载都要过 Load Barrier，同时需要额外的内存空间支撑并发标记和重定位。&lt;/p&gt;&#xA;&lt;h2 id=&#34;尾声你愿意付出什么代价&#34;&gt;&lt;a href=&#34;#%e5%b0%be%e5%a3%b0%e4%bd%a0%e6%84%bf%e6%84%8f%e4%bb%98%e5%87%ba%e4%bb%80%e4%b9%88%e4%bb%a3%e4%bb%b7&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;尾声：你愿意付出什么代价&#xA;&lt;/h2&gt;&lt;p&gt;如果你在做技术选型，或者想理解自己服务的 GC 行为：&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;延迟敏感、堆不大（&amp;lt; 4GB）的网络服务&lt;/strong&gt;——Go 的并发标记+不分代是最简单的选择。默认 GOGC=100 就够用，P99 毛刺用 GOMEMLIMIT 兜底。不需要选 GC 类型，不需要调十几个参数。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;大堆、高吞吐的后端服务&lt;/strong&gt;——Java G1 或 Generational ZGC。分代收集在大堆场景效率更高，G1 的 Region 机制让碎片问题基本消失。你愿意付出学习成本和调优成本，换来更好的大堆表现。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;极端延迟要求（亚毫秒硬保证）&lt;/strong&gt;——Java ZGC 或 Go，看你能接受哪种代价。ZGC 给你硬保证但吃内存；Go 在小堆下延迟同样优秀，但大堆 CPU 开销是隐患。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&lt;img src=&#34;https://img.wujiachen.com.cn/go-vs-java-gc/ch5-decision-framework.png&#34; alt=&#34;场景决策框架&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;作为一个写 Go 的人，我个人的体感是：Go 的 GC 赢在&amp;quot;不用想&amp;quot;。对于我日常接触的服务（堆 &amp;lt; 2GB，P99 延迟敏感），Go 的默认配置已经足够好。但如果哪天我接手一个 16GB 堆的缓存服务，我会认真考虑 Java 的方案。&lt;/p&gt;&#xA;&lt;p&gt;工程决策最终要回答一个问题：你愿意付出什么代价。Go 选了简单，代价是大堆乏力。Java 选了灵活，代价是你得自己选对 GC。&lt;/p&gt;&#xA;&lt;p&gt;两条路，同一场延迟战争。&lt;/p&gt;&#xA;</description>
        </item></channel>
</rss>
