《MMO 服务器开发》2.2 双栈对照:Go GC vs JVM GC

2.1 的剖析显示一帧的时间大头是分配与回收,不是算法。这一节把双栈项目的隐藏成本摊开:用同一份「分配 N 个对象并触发 GC」的工作负载,Go 侧读 runtime.MemStats 与 debug.GCStats,Java 侧读 ManagementFactory 的 MemoryMXBean 与 GarbageCollectorMXBean,在同机上真跑并对照,最后给出选型结论。

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,01325,000,000
分配总量953.7 MiB堆口径(见下)
耗时448ms618ms
每对象17.9 ns24.7 ns
GC 次数1128
GC 时间合计995µs346ms
最长暂停107µs未单列(合计含并发周期)
内存占用HeapAlloc 191 MiB / HeapSys 319.5 MiBused 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,这是同机相对量级,绝对数值不可移植。要选型,应该拿你自己的业务负载在两侧各压一次。

本节小结 / 实测结论

  1. 双栈的隐藏成本是 GC:同一份「分配 500 万对象 × 5 轮」的负载,Go 与 Java 21 都真跑了,指标口径也对称(分配量、对象数、GC 次数、GC 时间、堆占用)。
  2. 本机实测: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。
  3. 结论要落在场景上:帧敏感的游戏逻辑服偏 Go,大堆长生命周期的历史服务偏 JVM;且必须用真实业务负载复测,不能拿微基准下结论。

阅读导航:上一节:2.1 帧预算与性能剖析 · 下一节:2.3 热区隔离与背压 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标