2.2 双栈对照:Go GC vs JVM GC
2.1 的 CPU 剖析给出一个反直觉的结论:一帧里近三成时间花在 runtime.madvise 和 GC 唤醒上,真正「算」的 aoiBroadcast 只占 10.2%。在分配密集的服务端,GC 才是那个不被点名的主角。 而 MMO 项目常常是双栈的:新模块用 Go,历史模块是 Java。同一份业务负载,两种运行时的 GC 表现差多少?这不能靠背参数,得在同机上量。
这一节设计一份最小但对称的工作负载——分配 N 个小对象、每轮结束后让它们变成垃圾、重复若干轮——在 Go 和 Java 21 上各跑一次。Go 侧读 runtime.MemStats 与 debug.GCStats,Java 侧读 ManagementFactory 的 MemoryMXBean 与 GarbageCollectorMXBean,量同一组事实:分配速率、GC 次数、GC 暂停、内存占用。
Go 侧:MemStats 与 GCStats
Go 的 GC 状态分两处:runtime.MemStats 给累计分配量(TotalAlloc)、对象数(Mallocs)、堆占用(HeapAlloc/HeapSys);runtime/debug.GCStats 给暂停统计(PauseTotal 与分位数)。两者都靠 ReadMemStats 一次性快照。下面这段是 L2:
// file: internal/profile/gcstats.go
// tier: L2
// verify: go test ./internal/profile/ -run TestGoGCSample -v
package profile
import (
"runtime"
"runtime/debug"
"time"
)
// AllocSample is one measurement of Go's allocator and GC after a workload.
// It is the Go half of the section 2.2 double-stack comparison: the JVM half
// reads the same four facts (bytes allocated, object count, GC cycles, pause
// time) out of ManagementFactory.
type AllocSample struct {
TotalAlloc uint64 // cumulative bytes allocated (monotonic)
Mallocs uint64 // cumulative object count allocated (monotonic)
HeapAlloc uint64 // live heap bytes after the run
HeapSys uint64 // heap bytes obtained from the OS
NumGC uint32 // completed GC cycles
PauseTotal time.Duration // cumulative stop-the-world pause
PauseMax time.Duration // largest observed pause
LastPause time.Duration // most recent pause
}
// ReadMemStats snapshots runtime.MemStats plus debug.GCStats in one call.
func ReadMemStats() AllocSample {
var m runtime.MemStats
runtime.ReadMemStats(&m)
var g debug.GCStats
g.PauseQuantiles = make([]time.Duration, 5)
debug.ReadGCStats(&g)
max := time.Duration(0)
for _, q := range g.PauseQuantiles {
if q > max {
max = q
}
}
last := time.Duration(0)
if len(g.Pause) > 0 {
last = g.Pause[0]
}
return AllocSample{
TotalAlloc: m.TotalAlloc,
Mallocs: m.Mallocs,
HeapAlloc: m.HeapAlloc,
HeapSys: m.HeapSys,
NumGC: m.NumGC,
PauseTotal: g.PauseTotal,
PauseMax: max,
LastPause: last,
}
}
// allocChunk is one small object of the kind a tick allocates: a broadcast
// envelope, a damage event, a timer entry. 32 bytes of payload keeps it in a
// small-object size class, which is what a real tick mostly allocates.
type allocChunk struct {
id uint64
seq uint32
pad [20]byte
}
// AllocObjects allocates n small objects and returns them so the compiler
// cannot elide the allocation and so the caller controls the live set. This is
// deliberately the same shape as the Java half's `new Chunk()` loop.
func AllocObjects(n int) []*allocChunk {
keep := make([]*allocChunk, n)
for i := range keep {
keep[i] = &allocChunk{id: uint64(i), seq: uint32(i)}
}
return keep
}
跨栈对照要跑的是独立程序,不能只靠测试里的 100 万对象。cmd/gcbench/main.go 把同一份负载做成可执行文件:每轮分配 500 万个对象,5 轮,每轮的对象在轮内保持存活、轮末变垃圾——这正是「一次广播洪峰」的形状。它先热身一轮再测量,避免把冷启动算进去:
// file: cmd/gcbench/main.go
// tier: L2
// verify: go run ./cmd/gcbench
// Command gcbench is the Go half of section 2.2's double-stack GC comparison.
// It allocates n fresh objects per round for a fixed number of rounds, holding
// each round's objects live for the duration of the round, then reports the
// allocator and GC counters that the Java half reads from ManagementFactory.
package main
import (
"flag"
"fmt"
"runtime"
"runtime/debug"
"time"
)
// chunk mirrors the Java half's Chunk: a small object with a 32-byte footprint
// (8 id + 4 seq + 20 pad), the size class a tick's broadcast envelope lands in.
type chunk struct {
id uint64
seq uint32
pad [20]byte
}
func alloc(n int) []*chunk {
keep := make([]*chunk, n)
for i := range keep {
keep[i] = &chunk{id: uint64(i), seq: uint32(i)}
}
return keep
}
func main() {
n := flag.Int("n", 5_000_000, "objects to allocate per round")
rounds := flag.Int("rounds", 5, "rounds; each allocates n fresh objects")
flag.Parse()
// Warm up the allocator and the GC so the measured window is steady state.
warm := alloc(*n)
runtime.KeepAlive(warm)
warm = nil
runtime.GC()
var m0, m1 runtime.MemStats
runtime.ReadMemStats(&m0)
t0 := time.Now()
for r := 0; r < *rounds; r++ {
s := alloc(*n)
runtime.KeepAlive(s)
}
elapsed := time.Since(t0)
runtime.ReadMemStats(&m1)
var g debug.GCStats
g.PauseQuantiles = make([]time.Duration, 5)
debug.ReadGCStats(&g)
allocs := m1.Mallocs - m0.Mallocs
bytes := m1.TotalAlloc - m0.TotalAlloc
gc := m1.NumGC - m0.NumGC
pauseMax := g.PauseQuantiles[len(g.PauseQuantiles)-1]
fmt.Printf("go rounds=%d objs/round=%d elapsed=%v\n", *rounds, *n, elapsed.Round(time.Millisecond))
fmt.Printf("go alloc=%d objects, %.1f MiB total, %.1f ns/obj\n",
allocs, float64(bytes)/(1<<20), float64(elapsed.Nanoseconds())/float64(allocs))
fmt.Printf("go gc=%d cycles, pauseTotal=%v, pauseMax=%v\n",
gc, g.PauseTotal.Round(time.Microsecond), pauseMax.Round(time.Microsecond))
fmt.Printf("go heapAlloc=%.1f MiB heapSys=%.1f MiB\n",
float64(m1.HeapAlloc)/(1<<20), float64(m1.HeapSys)/(1<<20))
}
Java 侧:ManagementFactory
Java 侧对称地读 ManagementFactory:MemoryMXBean.getHeapMemoryUsage() 给堆的 used/committed/max;GarbageCollectorMXBean 的 getCollectionCount() / getCollectionTime() 给 GC 次数与耗时(G1 会分别报 Young / Old / Concurrent)。工作负载与 Go 侧逐字对应:每轮 new 500 万个 Chunk 塞进 ArrayList,5 轮。下面这段是 L2J,经 Docker(maven:3.9-eclipse-temurin-21,JDK 21)真跑——本机 JDK 只有 8,不能用它冒充:
// file: src/main/java/dev/aegis/eng/GcBench.java
// tier: L2J
// verify: docker run --rm -v "$HOME/.m2-mmo:/root/.m2" -v "$HOME/.aegis-java/eng-ch02:/w" -w /w -e MAVEN_OPTS="-Xmx1g" maven:3.9-eclipse-temurin-21 sh -c "mvn -B -q compile exec:java"
package dev.aegis.eng;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.util.ArrayList;
import java.util.List;
/**
* GcBench is the Java half of section 2.2's double-stack GC comparison. It runs
* the same workload as the Go half (cmd/gcbench): allocate n fresh objects per
* round for a fixed number of rounds, holding each round's objects live for the
* duration of the round, then report the heap and GC counters that the Go half
* reads from runtime.MemStats / debug.GCStats.
*
* Run it under a bounded heap (-Xmx) so young collections are frequent enough
* to observe, the same way a real server runs with a fixed heap ceiling.
*/
public final class GcBench {
/** Mirrors the Go half's chunk: a small object with a ~32-byte footprint. */
static final class Chunk {
long id;
int seq;
// padding to roughly match the Go struct's 32-byte size class
long padA;
long padB;
}
static List<Chunk> alloc(int n) {
List<Chunk> keep = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Chunk c = new Chunk();
c.id = i;
c.seq = i;
keep.add(c);
}
return keep;
}
static String collectorNames(List<GarbageCollectorMXBean> gcs) {
StringBuilder sb = new StringBuilder();
for (GarbageCollectorMXBean g : gcs) {
if (sb.length() > 0) {
sb.append('+');
}
sb.append(g.getName());
}
return sb.toString();
}
public static void main(String[] args) {
int n = args.length > 0 ? Integer.parseInt(args[0]) : 5_000_000;
int rounds = args.length > 1 ? Integer.parseInt(args[1]) : 5;
// Warm up the allocator and the JIT so the measured window is steady.
List<Chunk> warm = alloc(n);
System.out.println("java warmup kept=" + warm.size());
warm = null;
System.gc();
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
List<GarbageCollectorMXBean> gcs = ManagementFactory.getGarbageCollectorMXBeans();
long gcCount0 = 0, gcTime0 = 0;
for (GarbageCollectorMXBean g : gcs) {
gcCount0 += g.getCollectionCount();
gcTime0 += g.getCollectionTime();
}
long t0 = System.nanoTime();
long total = 0;
for (int r = 0; r < rounds; r++) {
List<Chunk> keep = alloc(n);
total += keep.size();
}
long elapsed = System.nanoTime() - t0;
long gcCount1 = 0, gcTime1 = 0;
for (GarbageCollectorMXBean g : gcs) {
gcCount1 += g.getCollectionCount();
gcTime1 += g.getCollectionTime();
}
MemoryUsage heap = mem.getHeapMemoryUsage();
System.out.printf("java rounds=%d objs/round=%d elapsed=%d ms%n", rounds, n, elapsed / 1_000_000);
System.out.printf("java alloc=%d objects, %.1f ns/obj%n", total, (double) elapsed / total);
System.out.printf("java gc=%d cycles, pauseTotal=%d ms%n", gcCount1 - gcCount0, gcTime1 - gcTime0);
System.out.printf("java heapUsed=%.1f MiB heapMax=%.1f MiB committed=%.1f MiB%n",
heap.getUsed() / 1048576.0, heap.getMax() / 1048576.0, heap.getCommitted() / 1048576.0);
System.out.printf("java collectors=%s%n", collectorNames(gcs));
System.out.flush();
// exec:java runs in the Maven JVM; exit explicitly so no non-daemon
// thread (a metrics reporter, a pool) can hold the process open.
System.exit(0);
}
}
同机对照:分配速率、暂停、内存占用
两次真跑的结果摆在一起(Go 默认 GOGC=100,Java 用 -Xmx1g 限定堆上限,模拟真实服务器固定堆顶):
go rounds=5 objs/round=5000000 elapsed=448ms
go alloc=25000013 objects, 953.7 MiB total, 17.9 ns/obj
go gc=11 cycles, pauseTotal=995µs, pauseMax=107µs
go heapAlloc=191.0 MiB heapSys=319.5 MiB
java rounds=5 objs/round=5000000 elapsed=618 ms
java alloc=25000000 objects, 24.7 ns/obj
java gc=28 cycles, pauseTotal=346 ms
java heapUsed=461.7 MiB heapMax=1024.0 MiB committed=940.0 MiB
java collectors=G1 Young Generation+G1 Concurrent GC+G1 Old Generation
| 指标 | Go(go1.26.0) | Java 21(G1,-Xmx1g) |
|---|---|---|
| 工作负载 | 500 万对象 × 5 轮 | 500 万对象 × 5 轮 |
| 对象数 | 25,000,013 | 25,000,000 |
| 分配总量 | 953.7 MiB | 堆口径(见下) |
| 耗时 | 448ms | 618ms |
| 每对象 | 17.9 ns | 24.7 ns |
| GC 次数 | 11 | 28 |
| GC 时间合计 | 995µs | 346ms |
| 最长暂停 | 107µs | 未单列(合计含并发周期) |
| 内存占用 | HeapAlloc 191 MiB / HeapSys 319.5 MiB | used 461.7 MiB / max 1024 MiB |
三条读得出来的结论:分配更快——Go 每对象 17.9ns 对 Java 的 24.7ns;暂停短得多——Go 全程 GC 暂停合计不到 1ms、最长 107µs,而 G1 自报的 GC 时间合计高达 346ms(含 Young/Old 的 STW 与并发周期);占用更低——Go 的活跃堆 191 MiB、向 OS 要了 319.5 MiB,Java 在 1g 堆顶下 used 已达 461.7 MiB。Java 的 GC 次数更多(28 对 11),是因为 1g 堆顶被高频 young 分配逼得不断触发 G1 Young。
选型结论
把三条结论翻译成选型标准:
- 帧敏感的游戏逻辑服优先 Go。MMO 服务端最怕的是「一帧卡一下」,Go 亚毫秒级的暂停分布正好对上这个需求;它的代价是 GC 可调参数少、吞吐不如 JVM,且不做分代,长生命周期对象多的场景要自己控制分配。
- 大堆、长生命周期的历史服务留在 JVM。账号、计费、运营后台这类对吞吐和成熟工具链(JFR、GC 日志、各种 GC 选择)更敏感的场景,JVM 的 G1/ZGC 仍然是更稳的选择;真要压暂停,应显式选 ZGC 而不是默认 G1。
- 别把这份微基准当结论。两侧对象布局不同(Go 32 字节 size class vs Java 对象头 + 字段 +
ArrayList槽位)、JIT 预热窗口不同、堆顶一个无限一个限 1g,这是同机相对量级,绝对数值不可移植。要选型,应该拿你自己的业务负载在两侧各压一次。
本节小结 / 实测结论
- 双栈的隐藏成本是 GC:同一份「分配 500 万对象 × 5 轮」的负载,Go 与 Java 21 都真跑了,指标口径也对称(分配量、对象数、GC 次数、GC 时间、堆占用)。
- 本机实测:Go 分配更快、暂停更短、占用更低。Go 17.9ns/obj、GC 暂停合计 995µs、最长 107µs、活跃堆 191 MiB;Java 21 + G1(-Xmx1g)24.7ns/obj、GC 时间合计 346ms、used 461.7 MiB。
- 结论要落在场景上:帧敏感的游戏逻辑服偏 Go,大堆长生命周期的历史服务偏 JVM;且必须用真实业务负载复测,不能拿微基准下结论。
阅读导航:上一节:2.1 帧预算与性能剖析 · 下一节:2.3 热区隔离与背压 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。