关于网关选型,现在有三种说法,各自都有真实的数字撑腰:
- "网关就该用 Go,Java 太重";
- "ZGC 停顿已经进微秒了,JVM 早就不慢";
- "上 GraalVM native image(下文简称 native),启动 68 毫秒,跟 Go 一样快"。
三句都没说错,只是各量各的:Go 那句量的是内存和启动,ZGC 那句量的是停顿,native 那句量的是启动。
所以我把三条路线放进同一批实验,用同一把尺子量七个维度,再把结果画成一张雷达图。
先给结论:七格里没有任何一条线全在圆外。 Go 拿下内存和启动,每请求 CPU 与 Java 打平;Java 拿下 p99 和 10 毫秒以上请求的占比;native 拿下最深的那段尾部,但付出了三成吞吐。
三条线谁都有明确输掉的那一格——所以严格说,谁都不能完胜,你只能选自己输得起的那格。
还有一个更硬的发现:Java 和 native 的最优 GC 都是 G1。 于是能做一个干净的对照:同一个 GC 算法,只差 JIT 在不在,代价可以逐项标出来(4.2 节)。
一、先定尺子:七个维度和它们的测法
"哪个网关快"没法直接回答。不先说清拿什么指标、怎么测,任何数字都能被解释成想要的结论。
所以先立七把尺子,每一把都对应一个真实的决策:
| 维度 | 指标 | 怎么测 | 它决定什么 |
|---|---|---|---|
| 吞吐 | rps | 2 核配额、并发 32、预热 20 s 后测 30 s | 单副本的容量上限 |
| p99 | 第 99 百分位延迟 | 同窗口约 20~30 万个请求,丢前 2 秒重算 | 绝大多数用户感受到的"慢" |
| p99.9 | 第 99.9 百分位延迟 | 同上 | 深处尾部是否可控 |
| 尾部占比 | >10 ms 请求占比 | 同窗口计数(max 只作参考,不参与排名) | 会被 SLO 罚钱的那部分 |
| 内存 | 满载 RSS | 负载期 /proc 稳态值 | 一台机器能放几个副本 |
| 冷启动 | 进程起到能返回 200 | 每种配置 3 次取中位 | 扩缩容与故障重建速度 |
| 每请求 CPU | cpu_s ÷ 完成请求数 | 配额打满时的单请求成本 | 同样的核算力能干多少活 |
三条原则先说清楚,后面所有数字都遵守:
一,不混口径。 要么"给同样的资源比服务"(吞吐、延迟、内存),要么"给同样的服务比资源"(每请求 CPU、冷启动)。两种口径的数字绝不放进同一句话。
二,每个数字都带前置条件。 不带并发数的吞吐、不带堆大小的内存、不带 CPU 配额的延迟,都不可比。这不是讲究措辞:同一个 JVM 网关、同一份负载,只改堆大小,RSS 从 351 MB 变到 4096 MB,中间几档吞吐几乎不动——同一条结论换个配置就能被读成两个故事。
三,落在噪声里的差异不叫差异。 同一份配置重复测,吞吐本身就有 ±7% 波动(基线见第二节)。所以 1.01 倍的吞吐差,本文读作"打平",不读作"Go 快 1%"。
二、让三条线量的是同一件事
先列公平性设计,这八条缺一条,后面的对比就站不住:
- 三层绑核:上游在核 2,被测网关在核 0-1,压测器在核 3,互不抢占。
- 活动处理器数对齐到 2:Go 侧
GOMAXPROCS=2,Java 侧-XX:ActiveProcessorCount=2并且加-Dreactor.netty.ioWorkerCount=2。 - 职责逐项对齐:同上游地址、同 header 注入集合、同 JSON 访问日志。
- 日志体量对齐:见下文,这一步不做,"带职责"组的对比是假的。
- GC 统计只取测量窗:按日志行号差值切片,排除启动与预热期的 GC。
- 预热 20 s + 测量 30 s,并发 32。
- 延迟数字丢前 2 秒重算:见下文,这是 JVM 的专属偏差。
- 三条路线同批:Go、JVM(两种 GC)、native(两种 GC)由同一个脚本、连续跑完,中间不换机器不换配置。
下面三件事,任何一件没处理,结论都会反过来。
第一件:只锁 ActiveProcessorCount 是压不住线程数的。
Reactor Netty 的 io worker 默认是 max(可用处理器数, 4),有个"最小 4"的下限。按 /proc/<pid>/task/*/comm 数 reactor-http-epoll-* 线程:
| JVM 参数 | event loop 线程数 |
|---|---|
| 默认 | 4 |
-XX:ActiveProcessorCount=2 | 4 |
-XX:ActiveProcessorCount=2 -Dreactor.netty.ioWorkerCount=2 | 2 |
给了 2 个核却起 4 个事件循环,测出来的是调度器抖动,不是网关本身的能力。这一步必须显式配。
第二件:日志体量先对齐,再比"带职责"。
第一轮实测发现 Traefik 的 JSON 访问日志是 1108 字节/请求,Java 侧只有 250 字节,差 4.4 倍。补齐 Java 侧字段后重跑,最终 1108 vs 997~1008 字节/请求。
第三件:JVM 的测量窗口头部必须丢。
JVM 的第 0 秒只处理了后续秒 57~65% 的请求,那一秒的 p99 是后续的 2.4~4.8 倍。这不是"慢 20%",是三分之一容量还没上线。
把这条修正回算到 132 个历史用例上:
| 实现 | 用例数 | p99 变化中位 | 变化超 5% 的用例 |
|---|---|---|---|
| Go | 42 | 0.0% | 0 / 42 |
| GraalVM native | 12 | +0.1% | 0 / 12 |
| 上游直连 | 4 | −0.1% | 0 / 4 |
| G1 | 35 | −17.9% | 31 / 35 |
| ZGC | 38 | −12.6% | 31 / 38 |
58 个 Go / native / 直连用例里 0 个受影响,74 个 JVM 用例里 63 个降幅超过 5%。 这是 JVM 的专属偏差,不是通用降噪——不改口径,JVM 在所有 p99 对比里系统性吃亏。
下面是噪声基线,它决定了多小的差异还能算数:
- 同一份 ZGC + 2g 配置三次独立测量:8438 / 8871 / 9066 rps,极差 7.1%
- 1 核配额下 Traefik 三次:7200 / 7203 / 7262 rps,极差 0.9%
max这种单样本极值:同配置三轮极差 106%~385% ,本文不用它做排名
最后交代一件事:本文用了三批数据,每批内部自成对照,绝不跨批比绝对值。
| 批次 | 内容 | 用途 |
|---|---|---|
| 主对照批 | Go / JVM+G1 / JVM+ZGC / native+G1 / native+Serial,五家同批,堆 512 MB | 雷达图与第四节主表 |
| 堆梯度批 | ZGC,堆从 128m 到 4g | 解释"内存是参数不是常数" |
| 专项批 | 裸转发、四种 GC 停顿、AOT 启动、1 核方差 | 解释某一条轴的成因 |
为什么主对照批用 512 MB 而不是社区常见的 2g?因为堆梯度批已经证明:512m 那一档吞吐是 2g 的 0.96 倍,落在噪声内。选 512m 是让三方在同一个堆口径下比较——按 2g 算,内存那一格会从 5.25 倍变成 20 倍。这不是 Java 的极限:G1 压到 256m 只要 413 MB,吞吐和 p99 都不动,4.4 节给完整梯度。
三、一张图:七个维度,三条路线
把七个维度画进同一个坐标系,需要先解决"量纲不同"的问题。做法是以 Go 为原点:每个维度的分数 = 100 + 30 × lg(该实现的优势 ÷ Go 的优势)。七条轴都以 Go 为基准,所以 Go 始终落在那条虚线圆上,另外两条线凸出或凹进多少,就是它们相对 Go 的强弱。
打分公式只是为了让 rps、毫秒、兆字节、微秒能放进同一个坐标系看形状,结论请以下表的原始数字为准:
| 维度 | Traefik(Go) | SCG + G1 | native + G1 |
|---|---|---|---|
| 吞吐(带职责,2 核,并发 32) | 10392 rps | 10538 rps | 7138 rps |
| p99(丢前 2 秒) | 10.370 ms | 5.100 ms | 7.622 ms |
| p99.9(丢前 2 秒) | 13.551 ms | 14.269 ms | 13.028 ms |
| >10 ms 占比(丢前 2 秒) | 1.265% | 0.254% | 0.398% |
| >25 ms 请求数(丢前 2 秒) | 32 个 | 13 个 | 0 个 |
| max 延迟(只作参考) | 69.08 ms | 28.17 ms | 17.10 ms |
| 内存(满载 RSS) | 106 MB | 557 MB | 219 MB |
| 冷启动(到能返回 200) | 63 ms | 5284 ms | 75 ms |
| 每请求 CPU(并发 32) | 188 µs | 187 µs(打平) | 277 µs |
读这张图只要三句话:
第一,每条线都有凹口,而且各自最深的那格互不相同。 Go 最深处是 p99(差 2.03 倍)和 >10 ms 占比(差 4.98 倍);Java 最深处是启动(83.9 倍)和内存(5.25 倍);native 最深处是吞吐(0.69 倍)。没有一个格子是三方都满意。
第二,native 的形状在三条里最接近正圆。 它在七格里没拉出过数量级优势,也没崩到 50 分以下——Java 的启动只有 42 分。这条线的形状说明:AOT 在交出"重"和"启动慢"的同时,也把"极端优势"一并交了出去。
第三,三家的 p99.9 只差 9.5%。 p99 那一格 Java 只有 Go 的一半,到 p99.9 却挤成 13.03 / 13.55 / 14.27。差距在更深的尾部被抹平——这条在 4.3 节展开。
四、逐条读数
4.1 吞吐:Go 和 Java 打平,native 掉三成
带完整职责(header 注入 + JSON 访问日志)时:Traefik 10392,SCG+G1 10538,SCG+ZGC 9847,native+G1 7138,native+Serial 7303。
Go 与 Java 差 1.4%,落在 ±7% 噪声内,读作打平。native 是 Go 的 0.69 倍。
但把职责摘掉,只做裸转发(专项批),差距立刻变成 18711 vs 11276 = 1.66 倍。差距去哪了?看日志这一项的成本:
| 网关 | 裸转发 | 带职责 | 日志成本 |
|---|---|---|---|
| Traefik(Go) | 104.8 µs | 205.1 µs | +100.3 µs |
| SCG(Java) | 176.7 µs | 224.3 µs | +47.6 µs |
这张表的"带职责"是专项批内的绝对值(205.1 / 224.3),与雷达图那批的 188 / 187 不同批——只用来看差值,不要跨批比大小。
Traefik 写一行 JSON 日志的成本是 Java 侧的 2.1 倍,而两者日志体量只差 10%。 原因是 Traefik 走 Go 标准库 encoding/json(反射序列化),Java 侧是我手写 StringBuilder 拼的。
这一格是偏袒 Java 的——真换成 Jackson 反射序列化,Java 会输得更多。
所以"网关快多少"这个问题的正确答案是:看你让它干多少活。 纯管道 1.66 倍,加了真实职责后收敛到噪声内。
4.2 p99:Java 赢了 2 倍,而且两边的最优 GC 都是 G1
这是全文最重要的一张表。同为"带职责、2 核、并发 32",五个实现,延迟数字全部丢前 2 秒重算:
| 实现 | GC | p50 | p99 | p99.9 | >10 ms 占比 | max | >25 ms |
|---|---|---|---|---|---|---|---|
| Traefik(Go) | GoGC | 2.643 | 10.370 | 13.551 | 1.265% | 69.08 | 32 |
| SCG(JVM) | G1 | 2.861 | 5.100 | 14.269 | 0.254% | 28.17 | 13 |
| SCG(JVM) | ZGC | 2.999 | 7.619 | 14.849 | 0.776% | 39.41 | 43 |
| native image | G1 | 4.371 | 7.622 | 13.028 | 0.398% | 17.10 | 0 |
| native image | Serial | 4.136 | 11.313 | 14.320 | 1.113% | 16.88 | 0 |
这张表里藏着两个结论。
第一,Java 侧换 GC 是真的有用,而且最好的是 G1,不是 ZGC。
G1 的 p99 是 5.100,ZGC 是 7.619,G1 低 33% ;>10 ms 占比 0.254% 对 0.776%,G1 只有 ZGC 的三分之一。这跟"ZGC 停顿更短所以延迟更低"的直觉相反(原因在 5.3 节)。
第二,native 侧的最优 GC 也是 G1,但它赢不了 JVM。
native+G1 的 p99 是 7.622,native+Serial 是 11.313——G1 低 33%,和 JVM 侧的幅度一模一样。但 native+G1 的 7.622 仍然比 JVM+G1 的 5.100 高 49% 。
于是我们有了一个干净的对照:同一个 GC 算法(G1),唯一的差别是 JIT 在不在。 这张定价表可以直接读出来:
| 项 | JVM + G1 | native + G1 | JIT 值多少 |
|---|---|---|---|
| p99 | 5.100 ms | 7.622 ms | JIT 让 p99 低 33% |
| 吞吐 | 10538 rps | 7138 rps | JIT 让吞吐高 48% |
| 每请求 CPU | 187 µs | 277 µs | JIT 让单请求省 32% |
| 内存 | 557 MB | 219 MB | JIT 要花 2.5 倍内存 |
| 冷启动 | 5284 ms | 75 ms | JIT 要花 70 倍启动时间 |
这就是 JIT 的账:买 33% 的 p99、48% 的吞吐、32% 的 CPU,付 2.5 倍内存和 70 倍启动。 后两条是物理成本,前三条是可优化的收益——所以这笔账没有标准答案,只有你的约束落在哪边。
再往下看曲线形状。Go 从 p99 到 p99.9 只涨 1.31 倍(10.370→13.551),JVM+G1 涨 2.80 倍(5.100→14.269),native+G1 涨 1.71 倍。曲线在 p99 与 p99.9 之间交叉:p99 处 JVM 比 Go 低一半,p99.9 处反而比 Go 高 5%。Go 是"肩宽尾短",JVM 是"肩窄尾长",native 两头都不极端。拿单个分位数下结论,等于用一个交点代表整条曲线。
4.3 尾部:Go 的 1.265% 对 Java 的 0.254%
同一个窗口超过 10 毫秒的请求占比:Go 1.265% ,Java+G1 0.254% ,native+G1 0.398%。Go 的慢请求占比是 Java 的 4.98 倍。
但换成"超过 25 毫秒"这个更狠的阈值,排序反过来了:Go 32 个,Java+G1 13 个,native 0 个。
再看 max(虽然不参与排名):Go 69.08,Java+G1 28.17,native 17.10。
三个数字拼出一个完整形状:
- Go 的尾部是"平的" :从 p99 的 10.37 到 max 的 69.08,中间没有断崖,也没有第二个峰。29 万个请求里 32 个越过 25 ms——不是没有,是均匀地撒在 30 秒里。
- Java 的尾部是"陡的" :p99 到 p99.9 涨 2.80 倍,到 max 涨 5.5 倍。中间更整齐,一旦掉出去就是几十毫秒。
- native 的尾部是"截断的" :max 只有 17.10,25 毫秒以上一个没有。它把尾部整个压住了——代价是每请求多花 89 µs 的 CPU。
对 SLO 来说,这三种形状的后果完全不同:如果你的阈值是 10 ms,Java 最好;如果是 25 ms,native 最好;如果你只关心"绝大多数人",Go 的 p99 是 10.37,比另外两家都差。
4.4 内存:5.25 倍,但 Java 的内存是参数,Go 是常数
带着 512 MB 堆时:Traefik 106 MB,SCG+G1 557 MB,native+G1 219 MB。
Java 是 Go 的 5.25 倍,native 是 2.07 倍。
先说一个必须交代的口径。按社区对 Java 网关的常规推荐值 -Xms2g -Xmx2g,Traefik 107 MB 对 SCG+ZGC 2150 MB,是 20 倍。但这个 20 倍是配置造成的,不是代码造成的。同一个 JAR、同一份负载,只改堆(堆梯度批):
| 堆 | 吞吐 rps | 相对 2g | 最大停顿 | 常驻内存 | 相对 2g |
|---|---|---|---|---|---|
| 128m | 7462 | 0.85× | 0.024 ms | 351 MB | 0.16× |
| 512m | 8455 | 0.96× | 0.016 ms | 726 MB | 0.34× |
| 2g | 8791 | 1.00× | 0.016 ms | 2165 MB | 1.00× |
| 4g | 7870 | 0.90× | 0.020 ms | 4096 MB | 1.89× |
512m 那一档吞吐是 2g 的 0.96 倍——落在 ±7% 噪声基线以内。把堆从 2 GB 砍到 512 MB,测不出吞吐损失,内存省 67%。
这张梯度表是 ZGC 的。换成 G1,同样 512m 堆的 RSS 是 557 MB(主对照批)——比 ZGC 少 170 MB。
G1 的梯度我另外单独测了一轮(NMT 开着,只在本轮内部对照):
| G1 堆 | RSS | rps | p99 |
|---|---|---|---|
| 128m | 322 / 330 | 10682 / 10912 | 9.16 / 8.27 |
| 256m | 413 | 10989 | 5.98 |
| 512m | 576 / 578 | 10778 / 11060 | 6.15 / 5.81 |
这一轮 512m 档的 576 比主对照批的 557 高 19 MB(3.4%),差额来自 NMT 自身的记账开销与批次噪声——档间相对关系不受影响,但别把这个 576 拿去覆盖主表的 557。
G1 的拐点在 256m,不在 512m:413 MB 拿到 10989 rps、p99 5.98——比 512m 省 165 MB,吞吐和尾延迟都不动。压到 128m 才开始付 p99 的代价(退化 45%),而吞吐仍然是满的。所以 G1 不像 ZGC 那样"堆小了就掉吞吐",它只在尾延迟上让步。
三个边界要记住:
- ZGC 的下界是 128m(吞吐掉 15%),4g 是纯浪费(吞吐掉 10%、内存翻倍)。
- 弹性堆省不了内存:
-Xms512m -Xmx2g实测 2147 MB,和固定 2g 的 2165 MB 几乎一样。持续高分配负载下,"按需扩展"的"需"就是满。想省只能真把 Xmx 调小。 - 堆不是全部:128m 堆那一档的 322 MB 里,只有 128 MB 是堆,剩下 194 MB 是与
-Xmx无关的固定开销。它的逐项构成在 5.2 节(元空间 36、代码缓存 30、GC 结构 39、JVM 二进制 36、符号表 9、分配器碎片 26、线程栈 1.75)。
native 的 219 MB 说明另一件事:AOT 把元空间和代码缓存的账清了,但没有清干净。 同一份 native 代码,Serial GC 版只要 95 MB,G1 版要 219 MB——多出来的 124 MB 挂在 G1 这侧(这块我没拆开,native 不支持 NMT,见 5.2)。也就是说,native 只省掉了"类元数据 + JIT 代码缓存",省不掉"堆 + GC 结构 + 线程栈 + 运行时映射"。
反过来看 Go:GOMEMLIMIT 设 32 / 64 / 128 MiB 或不设,四个点全部钉在 99~108 MB 这条竖线上,压到 32 MiB 吞吐反而掉 11%。它不是可调参数,而是一道压不动的下限。
所以这一格的正确说法不是"Java 费 5 倍内存",而是:Java 的内存是参数(3.020 倍,看你给多少堆、给哪个 GC),Go 是常数(约 105 MB),native 是半参数(95219 MB,看 GC)。
4.5 冷启动:Go 63 ms、native 75 ms、JVM 5284 ms
每种配置测 3 次,取中位:Traefik 63 ms,native+G1 75 ms,SCG+G1 5284 ms。
native 是 Go 的 1.19 倍,基本同档;JVM 是它们的 70~84 倍。
这个差距能不能补?我测了两条路。
第一条:Project Leyden AOT cache。 2 核配额下,JVM 基线启动 5496 ms,加 AOT cache 后 4915 ms(−10.6% ),再加懒加载 4717 ms。方向对,但幅度离 70 倍差得远。
第二条:GraalVM native image。 构建 6 分 29 秒、峰值内存 4.98 GB,产出 90.0 MiB 的静态二进制。启动 68 ms(Serial GC 版),同批 Go 是 63 ms。
但代价必须一起说,否则这个结论站不住:
| 项 | JVM + G1 | native + G1 |
|---|---|---|
| 冷启动 | 5284 ms | 75 ms |
| 吞吐(并发 32) | 10538 rps | 7138 rps(JVM 的 0.68×) |
| 每请求 CPU | 187 µs | 277 µs |
| 常驻内存 | 557 MB | 219 MB(Serial 版 95 MB) |
| p99 | 5.100 ms | 7.622 ms |
jcmd / jstack / jmap | 可用 | 全部 AttachNotSupportedException |
-Xmx64m | 生效 | 静默失效 |
两件事值得单独看一眼。
native 的尾延迟是全场最好的(max 17.10 ms,>25 ms 一个都没有,而 Go 有 32 个)。这符合机制:没有 JIT 就没有反优化,没有大堆就没有大停顿。它输的地方全在吞吐上。
-Xmx64m 在 native 上不报错、照常服务、RSS 82 MB——JVM 参数在 native 上是静默失效的,堆上限要改用 -XX:MaximumHeapSizePercent 或构建期参数。这类"以为配了其实没配"的坑,会一直留到线上。
换 AOT 能买回启动,但买不回吞吐,还会赔掉可观测性。 对基础设施组件来说,最后一条往往最难接受。
4.6 每请求 CPU:188 / 187 / 277
配额打满时,每请求的 CPU 成本:Traefik 188 µs,SCG+G1 187 µs,native+G1 277 µs。
Go 和 Java 打平,native 贵 47%。
这一格最值得琢磨的问题是:没有 JIT 的 native,为什么反而比有 JIT 的 JVM 更费 CPU?
先排除三个嫌疑(都用同批数据):
嫌疑一:GC。 JVM 侧只换 GC:G1 10538、ZGC 9847,差 6.6%,落在噪声边缘。native 侧只换 GC:G1 7138、Serial 7303,差 2.3%。两边换 GC 都动不了吞吐的量级,GC 不是主因。
嫌疑二:GC 的频率。 把 Traefik 的 GOGC 从 100 拉到 1000,GC 次数从 556 降到 60(降 89%),吞吐只涨 11%,内存却涨 2.3 倍。把 GC 砍掉近九成只换 11%,说明 Go 侧的 GC 总共也只占一成的 CPU。
嫌疑三:JIT 缺不缺失。 这条最反直觉:native 全是构建期编译好的机器码,没有 JIT,也没有 C2 的峰值优化。同批吞吐 7138,只有 JVM 版(10538)的 0.68 倍。所以 native 更慢,不是因为"JIT 还没热起来"——恰恰相反,把 JIT 整个拿走只会更慢。
三个嫌疑都排除后,剩下的只能是编译质量与每请求的固定工作两个来源:
- 编译质量:GraalVM 的 AOT 编译不可能做 C2 那样的激进内联与投机优化,热点路径上的指令数天生更多。这一条我没有做指令级对比,只作为机制解释。
- 固定工作:对象图的构造与销毁、Pipeline 的适配层、Filter 链的逐层传递、两次 HTTP 解析。这部分工作没有"热点"可优化,跑多少遍都是那么多活。
这一段我只给机制,没有逐项测量。 本文能确认的只有:它不是 GC,不是 GC 频率,也不是 JIT 缺失——三者都被上面的实验单独否掉了。
有一个反向旁证值得记住:日志那一格反过来了(专项批:Go 100.3 µs vs Java 47.6 µs)。同样是每请求的固定工作,一旦落到"反射 + 序列化"这种需要抽象的地方,Go 反而更贵。Go 的优势区间是"最短路径",不是"每个环节都更快"。
4.7 一项没进雷达图:JVM 要爬十几秒才满速
就绪不等于能按满速接流量。把网关就绪后的头 12 秒单独量出来,和之后的稳态窗口比(专项批,同一进程、同一份代码、同一份负载):
| 用例 | 头 12 s 吞吐 | 稳态吞吐 | 倍数 |
|---|---|---|---|
| Traefik 裸转发 | 18686.6 | 18710.9 | 1.00× |
| Traefik 带职责 | 9645.7 | 9644.8 | 1.00× |
| SCG + G1 带职责 | 2089.9 | 9312.5 | 4.46× |
| SCG + ZGC 带职责 | 2225.7 | 8870.6 | 3.99× |
Go 从第一个请求就是满速;JVM 要爬十几秒,爬升期的平均吞吐只有稳态的 1/4。 native 这一段我没有单独测——它的启动只有 75 ms,按同样口径截头 12 秒,窗口里已经没有"爬坡"可言。所以这一项我只对 Go 和 JVM 下结论。
这一项没有进雷达图:它只对 JVM 一家成立,放进去等于给另外两家各送一格满分,重复计分。但它对"扩容救火"这个场景是决定性的:流量涨上来的头十几秒,JVM 只能接住四分之一。
低配下更糟。0.5 核配额,把预热从 25 秒拉到 60 秒:
| 预热时长 | SCG+ZGC @0.5 核 | Traefik @0.5 核 |
|---|---|---|
| 25 s | 774 rps | 3360 rps |
| 60 s | 2616 rps | 3326 rps |
同样拉长预热,Go 只动了 1%,Java 涨了 3.4 倍;而且即便给足 60 秒,Java 在 0.5 核下也只有 Go 的 79%。低配额意味着 JIT 编译本身也在抢那点配额——"预热不够"和"配额不够"在低配下是同一件事。
4.8 雷达图上没有的一格:重复测量的方差
1 核配额下各测三次(专项批):
| 方案 | 三次 rps | 三次 p99 |
|---|---|---|
| Traefik | 7200 / 7203 / 7262(±0.9% ) | 12.79 / 12.77 / 12.73(±0.2%) |
| SCG + ZGC | 6405 / 6279 / 6723(±7%) | 56.28 / 57.85 / 18.35(3.1 倍) |
同一个二进制、同一份负载、同一台机器,Java 的 p99 在 18 ms 和 58 ms 之间跳。这一条在雷达图上没有轴,因为它不是一个"维度",而是一种性质:同样配置重复跑,Go 像一台机器,Java 像抽奖。
五、原理:这个形状是怎么长出来的
前面量的是"是什么"。这一节回答"为什么",而且只写能指向某一次测量的话;指不到的,我会明说没测。
5.1 启动的三段账,AOT 只能还掉第一段
JVM 启动慢常被归给"类加载"。这个解释只对了一小部分:
- 加 Leyden AOT cache(把类加载、链接、验证的产物缓存下来)只省了 10.6% (5496 → 4915 ms)。说明类加载那一段只占一成。
- 换成 native image(把整个应用在构建期编译成机器码,运行时没有类加载、也没有 JIT)直接到 75 ms,是 Go 的 1.19 倍。
剩下的 89% 里,"Spring 上下文装配"和"JIT 把热点方法编译到 C2"各占多少,本文没有分开测。但有一条可以确定:它不是 GC 的账——JVM 侧换 GC,启动时间落在 5103~5514 ms 之间,动不了。
这也解释了为什么 AOT 不是一个"调参"选项:你要么保留 JIT 并继续付启动与爬坡的钱,要么上 native 并把 JIT 的收益一起交出去(吞吐 0.68×)。
5.2 内存的差:堆能调,固定开销调不动
把 Xmx 从 2g 移到 512m,RSS 从 2165 掉到 726,吞吐不动——大头确实在堆上,所以它是一条可平移的线。
但这条线平移不到 Go 那里去。"平移不动的那一截"到底是什么,之前只能拿 RSS 减堆来估;这次我用 NMT(-XX:NativeMemoryTracking=summary)加 /proc/<pid>/smaps,在压测过程中直接采了一遍。
G1、-Xms = -Xmx、并发 32、带职责,128m 堆下的 322 MB 是这么构成的:
| 项 | MB | 是什么 |
|---|---|---|
| 堆 | 128 | 压测期间是满的(30 秒 508 次 GC) |
| 类元数据(Metaspace) | 35.9 | 10141 个类的字段布局、字节码、常量池 |
| JIT 代码缓存 | 30.1 | C1/C2 编译出来的机器码 |
| GC 自己的结构 | 39.2 | G1 的 region 元数据、记忆集、位图 |
| JVM 二进制与库 | 36 | libjvm.so 18.4 + CDS 归档 13.6 + 其它 |
| 符号表 | 8.6 | 11.9 万条方法名/类名 |
| 内部表 | 9.7 | perf 数据、哈希表、编译器 arena |
| malloc 碎片 | ~26 | 分配器未归还的空洞 |
| 线程栈 | 1.75 | 25 个线程,预留 25 MB,实际只提交 1.75 |
下面三条和直觉不一样,其中两条是我上一版写错的:
第一,线程栈根本不是大头。 25 个线程各预留 1 MB,但每个只提交约 70 KB——合计 1.75 MB,占 0.5%。我上一版把它和元空间、代码缓存并列成"三根支柱",是照着 Xss 的预留值想当然写下的。
第二,真正的大头我漏了:GC 自己的 39 MB。 G1 的 region 数组、记忆集、标记位图,不跟 JIT 有关,也不怎么跟堆大小有关(128m 时 39.2,512m 时 47.0)。
第三,libjvm.so 一个文件就 18.4 MB。 运行时自身的体积也是要付的。
所以"有 JIT 就得付"的钱是 类元数据 36 + 代码缓存 30 + 符号表 9 = 75 MB。剩下 119 MB 里,39 MB 是 GC 的账,36 MB 是 JVM 二进制的账,26 MB 是分配器碎片。(上一版写的是 219——那是 ZGC 在 128m 档的数,混进了 ZGC 自己的 149 MB。)
这 194 MB 与堆无关,是可复现的。 同一份代码换三档堆,NMT 记的堆外 committed 分别是 150 / 154 / 159 MB——几乎不动。
注意别用 RSS 减堆去算这块固定开销:这样算 128m 档是 194、256m 档是 157、512m 档只有 66。变小的不是固定开销,是堆的驻留率(100% / 86% / 75%)——region 分配了但没写过,就不占物理页。
再往下,两个 GC 在这 194 MB 上走向不同:
G1 到 256m 才见拐点。 128m 堆时 rps 10682 / 10912,和 512m 的 10778 / 11060 一样;但 p99 从 5.8 退化到 8.3~9.2。到 256m 就都正常了:413 MB、10989 rps、p99 5.98——比 512m 省 165 MB,吞吐和尾延迟都不动。
ZGC 要在这 194 MB 上再叠 149 MB,而且这笔钱是隐形的。 同为 512m 堆,ZGC 的 RSS 是 727,G1 是 578。可 NMT 报表里 ZGC 的 GC 类别只有 7.1 MB(G1 是 47.0),元空间、代码缓存、符号表三项逐 MB 相同,committed 总量 ZGC 的 642 还比 G1 的 671 少。
那 149 MB 去哪了?它落在 smaps 的匿名段里,NMT 没有一个类别肯认这笔账。它是 ZGC 的标记位图、转发表、多视图重映射的页表——JVM 不把它记在 GC 名下。 按内存给容器配额时,这块会被漏算。
native 也印证了这一点:AOT 清掉了类元数据和代码缓存,RSS 从 557 掉到 219,但掉不到 Go 的 106——堆、GC 结构、运行时映射都还在,跟 JIT 无关(完整数字见 4.4 节)。
同一份 native 代码,GC 从 Serial 换成 G1,RSS 从 95 涨到 219,多付 124 MB。这 124 MB 挂在 G1 这一侧,但我没有把它拆开——native image 不支持 NMT,jcmd VM.native_memory 直接报 AttachNotSupportedException。能说的是:同机 JVM 侧的 G1 结构实测只有 39~47 MB,124 MB 明显超出这个量级,说明 native 上的 G1 还有本文没测到的额外开销。
Go 那边反过来:静态链接、运行时不加载类元数据也不生成机器码,所以它的 105 MB 是一个常数。GOMEMLIMIT 只能在这个常数附近微调,压过头只会让 GC 更勤快(32 MiB 时吞吐掉 11%)。
一句话:Java 网关光固定开销就 194 MB,而 Go 整个进程才 106 MB——这一块比对手的全部还多 83%。 这个差距不是选错 GC 造成的,也不是堆给多了,它在你决定"要有类加载器、要 JIT、要 GC 元数据"的那一刻就定死了。
还有一个容易被忽略的差别:负载停了之后。 同一组实验里,Go 把 RSS 从 106 MB 还到 38 MB;ZGC 从 743 MB 只回到 723 MB,G1 从 542 MB 回到 517 MB。堆提交出去就不还——这条在容器里意味着 limit 只能按峰值设。
5.3 p99 的差距,不是 GC 停顿带来的
这是全文最反直觉的一格,先把四家的牌摊开。
专项批(堆 2g)里有四个实现,各自的最大单次 GC 停顿和 p99:
| 实现 | 最大单次停顿 | p99(丢前 2 秒) |
|---|---|---|
| Parallel | 7.452 ms | 7.292 |
| G1 | 24.569 ms | 7.637 |
| ZGC | 0.016 ms | 7.812 |
| Go | 0.244 ms | 10.798 |
停顿最长的 G1(24.569 ms)p99 是 7.637;停顿最短的 ZGC(0.016 ms)p99 是 7.812。两者停顿差 1536 倍,p99 只差 2.3%,而且方向反了。
p99 最低的一档是 Parallel——7.292,它的最大停顿是 ZGC 的 466 倍。
停顿和 p99 之间,没有单调关系。
主对照批(512m)复现了同样的形态:G1 的 p99 是 5.100,ZGC 是 7.619——停顿短的那个反而 p99 更高。
为什么?先算一个数:一次请求在网关里的平均驻留时间。
并发 ÷ 吞吐 = 32 ÷ 10392 rps ≈ 3.08 ms
也就是说,一个请求从进来到出去,平均要排 3 毫秒的队。而 Go 的 GC 停顿是 0.244 毫秒——占驻留时间的 8% 。
再看 p99 那一格的实际差距:10.370 − 5.100 = 5.27 ms。就算把 Go 那 0.244 ms 的停顿全额记到 p99 头上,也只够解释 4.6% 。
这里可以提炼出一条更一般的规律:停顿只在"分位阈值和停顿时长同量级"的地方留下痕迹。
- p99 的阈值在 5~10 ms,而 ZGC(0.016 ms)和 Go(0.244 ms)的停顿都小于 0.3 ms——差一到两个数量级,什么也看不见;
-
25 ms 这个阈值,和 G1 的 24.569 ms 停顿是同一量级——所以 G1 在那里露了头(专项批里 G1 有 373 个 >25 ms,Go 只有 1 个)。
换句话说,不是"停顿没用",是"停顿的量级要和你想改善的那个阈值对上"。 想改善 p99,得先有毫秒级的停顿;而毫秒级的停顿,恰恰是 ZGC 已经消灭掉的东西——它想改善的是 p99,可 p99 的阈值比它的停顿高了两个数量级。
5.4 p99 领先的那 5.27 ms 从哪来,我只测到一半
主对照批里,JVM+G1 的 p99 比 Go 低 5.27 ms,比 native+G1 低 2.52 ms。能排除的是:
- 不是 G1 的停顿:G1 的停顿(24.6 ms)比 Go(0.244 ms)长两个数量级,p99 却更低。停顿的方向和 p99 的方向是反的。
- 不是 cgroup 冻结:这一批用的是绑核(核 0-1),不是 cfs 配额,不存在周期性冻结。
- 不是内存压力:ZGC 30 秒内只有 37 次 GC、0 次 allocation stall。
剩下的候选方向是 JVM 的非 GC 停顿(safepoint、去优化、代码缓存、页错误)与运行时调度差异。要定论需要 JFR 的 safepoint 事件或 async-profiler 的 wall-clock 采样——那是下一轮实验的事。这一格我只报数,不报原因。
六、这张图怎么用
七个维度不是让你取平均分,而是让你找到自己的约束落在哪一格:
| 你的处境 | 该选谁 | 依据 |
|---|---|---|
| 网关是边缘入口/纯转发,副本几十上百 | Go | 内存 106 MB、启动 63 ms 都是第一,每请求 CPU 与 Java 并列 |
| 延迟 SLO 卡在 10 ms 以内,且扩容不频繁 | Java + G1 | p99 领先 2.03 倍,>10 ms 占比只有 Go 的 1/5 |
| 要秒级弹性、按内存计费、故障要能重建 | Go 或 native | 63 ms 与 75 ms,比 JVM 快 70 倍以上 |
| 又要快启动、又要深尾部可控,能接受吞吐降三成 | native + G1 | 启动 75 ms、max 17.1 ms、>25 ms 零个;代价是 rps 0.69× |
| 团队全栈 Java,要与 Spring 生态深度集成 | Java | 语言切换的隐性成本大于任何基准差异 |
| 网关里塞了大量业务逻辑(鉴权、聚合、灰度) | Java | 逻辑越重 JIT 越有用,本文结论越不适用 |
| 需要 JFR / APM 的持续可观测 | Java | native image 下 jcmd/jstack 全失效 |
还有一条雷达图量不到、但同样真实的差别:运维成本。 Go 网关是一个静态二进制加两个 GC 旋钮;JVM 网关要决定堆大小、选 GC、看元空间、考虑 GC 日志与 OOM dump,还要记住 -Dreactor.netty.ioWorkerCount 这个默认 4 的坑。native 则是第三种麻烦:构建 6 分半、要吃 5 GB 内存,参数还静默失效——它把运行时的复杂度搬到了构建期。
最后要说清楚本文的边界:这里测的是"转发型网关" ——请求进来、改几个头、写一行日志、出去。业务逻辑越重,Java 的长期优化能力越能发挥,本文的结论越不适用。这不是"Java 不适合做网关",而是"转发型负载下,三条路线各有一条明显短板,谁也没法全拿"。
七、带走三句话
第一,七格里没有一条线全在圆外,三处最深的凹口还落在不同的格子上。 Go 凹在 p99(2.03 倍)和 >10 ms 占比(4.98 倍);Java+G1 凹在启动(83.9 倍)和内存(5.25 倍);native+G1 凹在吞吐(0.69 倍)。选型就是选你愿意在哪一格认输。
第二,JIT 的账这次单独标了价。 同一个 G1,JIT 在不在,p99 差 33%、吞吐差 48%、CPU 差 32%、内存差 2.5 倍、启动差 70 倍。前三条买得到,后两条买不到——这就是为什么"用 native 换启动"不是免费的。
第三,停顿改善 p99 这件事,需要量级对得上。 停顿 0.016 ms 的 ZGC 和停顿 24.569 ms 的 G1,p99 分别是 7.812 和 7.637;主对照批里 G1 的 p99 甚至比 ZGC 低 33%。落到主对照批那 5.27 ms 的差距上(10.370 − 5.100),Go 自己的停顿最多只能解释 4.6%。想靠压停顿改善 p99,先看看你的停顿和阈值差几个数量级。
选型不是挑"哪个指标更漂亮",而是问一句"我的约束落在哪一格,那一格差多少倍"。七个维度量一遍,答案通常比直觉朴素得多。
测试环境:OpenCloudOS 8.10 / 4 vCPU / 7 GB / kernel 5.4.241,CPU 型号被虚拟化隐藏为 General Processors(支持 AVX2 + FMA,无 AVX-512)。
工具链:go1.27.1;Temurin JDK 25.0.4+7(编译目标 21);GraalVM JDK 25.0.4+7(native image 专项)。
被测:Traefik 3.7.14(GOMAXPROCS=2);Spring Cloud Gateway 4.3.5 + Spring Boot 3.5.16(G1 / 分代 ZGC / Parallel);GraalVM native image(Serial GC / G1 两个版本)。JVM 与 native 均设 -XX:ActiveProcessorCount=2 且 -Dreactor.netty.ioWorkerCount=2。
方法:三层绑核(上游=核 2、网关=核 0-1、压测器=核 3);两侧活动处理器数与 event loop 线程数均对齐到 2(按 /proc//task/ /comm 的 reactor-http-epoll- 线程名实测核对);职责逐项对齐(同上游、同 header 注入、同 JSON 访问日志,日志体量 1108 vs 9971008 字节/请求);预热 20 s + 测量 30 s,并发 32;GC 统计只取测量窗内新增日志;所有延迟数字丢前 2 秒重算(依据:132 个历史用例中 74 个 JVM 用例有 63 个降幅超 5%,58 个 Go/native/直连用例 0 个受影响);并发 32 的全部用例两侧 CPU 均打满 1.921.98 核,故 rps 比值即每请求 CPU 成本比值。
主对照批:五家(Traefik / SCG+ZGC / SCG+G1 / native+Serial / native+G1)同一脚本连续跑完,堆统一 -Xms512m -Xmx512m。
噪声基线:同配置三次 8438 / 8871 / 9066 rps(极差 7.1%),1 核配额三次 7200 / 7203 / 7262(0.9%),max 同配置三轮极差 106%~385%(故不用于排名)。
完整原始数据与复现脚本:drafts/gw-bench/(RESULTS.md · rungw.sh · run_native_cmp.sh · run_heap.sh · mem_decomp.sh · run_aot.sh · run_dim2.sh · baseline_noise.sh · recalc_head.py · analyze**.py · results/ · conf/ · scgw/ · upstream/)
参考:xpbob.site