观察 Go 的新垃圾回收器在堆中的移动
摘要
Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。
<p><a href="https://lobste.rs/s/u60zv9/watching_go_s_new_garbage_collector_move">评论</a></p>
查看缓存全文
缓存时间: 2026/07/24 21:06
# 观察 Go 新垃圾回收器在堆中的移动过程
来源:https://theconsensus.dev/p/2026/07/19/observing-gos-garbage-collector-old-and-new.html
The Consensus 标志 (https://theconsensus.dev/)
关于软件基础设施。
## 观察 Go 新垃圾回收器在堆中的移动过程
Go 1.26 将 Green Tea 设为默认垃圾回收器。我们通过 `perf` 观察其缓存友好性,可视化堆以了解 Go 如何分配内存,并探讨像 Go 这种非移动回收器难以处理的稀疏页问题。
作者:Phil Eaton
2026 年 7 月 19 日
聚焦 (https://theconsensus.dev/p/2026/07/19/observing-gos-garbage-collector-old-and-new.html#)
您作为订阅者正在提前阅读本文。您的支持使这类文章成为可能。感谢您。
去年发布的 Go 1.25 引入了一个新的垃圾回收器:Green Tea (https://go.dev/blog/greenteagc?from_theconsensus=1)。而在几个月前发布的 Go 1.26 中,Green Tea 成为默认回收器。那篇链接的文章非常出色。我们将简要回顾它,并研究几个受益最大的程序。我们还会看一些没有受益的程序,它们触发了 Go 残留的垃圾回收器 bugaboo:其非移动回收器无法回收稀疏页。
回顾一下,Go 通过将同一大小类(对象大小向上取整到最接近的大小类)的对象分配在一个或多个 8KiB *页* 的连续块(Go 术语中称为 *span*)中来管理内存。按大小隔离的分配在一些 malloc 实现中很常见(例如 tcmalloc,Go 的分配器源自它 (https://go.dev/doc/gc-guide?from_theconsensus=1))。让我们观察 Go 中发生的情况,然后与 C# 进行比较。我们将随机分配三种不同大小(小、中、大)的对象。然后检查它们的堆地址,遍历地址空间,并在遇到我们的对象时打印一个字符,每 32 字节打印一个字符。
首先安装 Go 和 C#。
```bash
sudo apt update -y
sudo apt-get install -y dotnet-sdk-10.0
curl -fsSL https://go.dev/dl/go1.26.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xz
export PATH=$PATH:/usr/local/go/bin
```
这是我们想要实现的伪代码。
```
struct Small { a [32]byte }
struct Medium { a [64]byte }
struct Large { a [128]byte }
constructors = [Small, Medium, Large]
live = [] # 阻止对象被回收
for i in range(100):
live.push(new constructors[rand() % len(constructors)])
for pass in [0, 1]:
if pass == 1:
runtime.gc() # 触发 GC
records = []
for obj in live:
records.push((runtime.addressof(obj), runtime.typeof(obj), runtime.sizeof(obj)))
records.sort(key = r -> r.address)
cell = 32
cursor = records[0].address
for (addr, typ, size) in records:
while cursor < addr: # 此处没有我们的对象
print(".");
cursor += cell
head = typ.name[0]
print(upper(head) + "-" * (size/cell - 1)) # "S" / "M-" / "L---"
cursor += size
```
让我们用 Go 构建它。
```go
package main
import (
"bytes"
"cmp"
"fmt"
"math/rand"
"reflect"
"runtime"
"slices"
)
type (
Small struct{ _ [32]byte } // 32 字节
Medium struct{ _ [64]byte } // 64 字节
Large struct{ _ [128]byte } // 128 字节
)
type object struct {
addr uintptr
size int
name byte // 'S' / 'M' / 'L'
}
func main() {
allocs := []func() any{
func() any { return new(Small) },
func() any { return new(Medium) },
func() any { return new(Large) },
}
live := make([]any, 100) // 保持引用,使 GC 无法回收,并且我们知道每个类型
for i := range live {
live[i] = allocs[rand.Intn(len(allocs))]()
}
for pass := 0; pass < 2; pass++ {
if pass == 1 {
runtime.GC() // Go 从不移动对象:第 1 次与第 0 次完全相同
}
objs := make([]object, len(live))
for i, o := range live {
t := reflect.TypeOf(o).Elem()
objs[i] = object{reflect.ValueOf(o).Pointer(), int(t.Size()), t.Name()[0]}
}
slices.SortFunc(objs, func(a, b object) int { return cmp.Compare(a.addr, b.addr) })
fmt.Printf("\n=== pass %d (base 0x%x) ===\n", pass, objs[0].addr)
draw(objs)
}
}
func draw(objs []object) {
const cell, width = 32, 60
last := objs[len(objs)-1]
base := objs[0].addr
grid := make([]byte, int((last.addr+uintptr(last.size)-base)/cell))
for i := range grid { grid[i] = '.' }
for _, o := range objs {
c0 := int((o.addr - base) / cell)
grid[c0] = o.name
for k := 1; k < o.size/cell; k++ { grid[c0+k] = '-' }
}
prev := -1
for off := 0; off < len(grid); off += width {
row := grid[off:min(off+width, len(grid))]
if len(bytes.Trim(row, ".")) == 0 { // 该行不包含我们的任何对象
continue
}
if prev >= 0 && off != prev+width { fmt.Println(" ...") }
fmt.Printf("0x%09x %s\n", base+uintptr(off)*cell, row)
prev = off
}
}
```
*heapwalk.go*
运行它,你会看到类似这样的输出。
```
$ go run heapwalk.go
=== pass 0 (base 0xba4841580c0) ===
0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xba484158840 M-M-M-M-....................................................
...
0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
...
0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-
0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L-
0xba4841aec40 --L---L---L---L---L---L---L---L---L---L---
=== pass 1 (base 0xba4841580c0) ===
0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xba484158840 M-M-M-M-....................................................
...
0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
...
0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-
0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L-
0xba4841aec40 --L---L---L---L---L---L---L---L---L---L---
```
所以即使我们在不同大小的对象之间随机分配,我们也能观察到 Go 运行时将每个大小的对象彼此相邻放置。而且即使我们运行了垃圾回收器,它也没有移动任何东西。
现在让我们看看 C#。
```csharp
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
var allocs = new Func[] {
() => new Small(),
() => new Medium(),
() => new Large()
};
var live = new object[100]; // 保持引用,使 GC 无法回收,并且我们知道每个类型
var rnd = new Random();
for (int i = 0; i < live.Length; i++)
live[i] = allocs[rnd.Next(allocs.Length)]();
// 在 64 位 .NET 上,引用是一个普通的 8 字节指针,因此使用
// Unsafe.As 重新解释它即可获得对象地址。对象大小通过堆测量:
// 分配多个对象,连续地址之间的最小间隙就是(对齐的)对象大小,包括头部。
var size = new Dictionary();
foreach (var make in allocs) {
var keep = new object[16];
var a = new nint[keep.Length];
for (int i = 0; i < keep.Length; i++) keep[i] = make();
for (int i = 0; i < keep.Length; i++) a[i] = Unsafe.As(ref keep[i]);
Array.Sort(a);
nint best = nint.MaxValue;
for (int i = 1; i < a.Length; i++)
if (a[i] - a[i - 1] > 0 && a[i] - a[i - 1] < best) best = a[i] - a[i - 1];
size[keep[0].GetType()] = (int)best;
}
for (int pass = 0; pass < 2; pass++) {
if (pass == 1) GC.Collect();
// 地址仅在下次回收前有效,因此在获取地址时暂停 GC。
var addrs = new nint[live.Length];
GC.TryStartNoGCRegion(1 << 20);
for (int i = 0; i < live.Length; i++) addrs[i] = Unsafe.As(ref live[i]);
GC.EndNoGCRegion();
var objs = new (nint Addr, int Size, char Name)[live.Length];
for (int i = 0; i < live.Length; i++)
objs[i] = (addrs[i], size[live[i].GetType()], live[i].GetType().Name[0]);
Array.Sort(objs, (x, y) => x.Addr.CompareTo(y.Addr));
Console.WriteLine($"\n=== pass {pass} (base 0x{(long)objs[0].Addr:x}) ===");
Draw(objs);
}
static void Draw((nint Addr, int Size, char Name)[] objs) {
const int cell = 32, width = 60; // cell = 最小对象的大小
var last = objs[^1];
nint b = objs[0].Addr;
var grid = new char[(last.Addr + last.Size - b) / cell];
Array.Fill(grid, '.');
foreach (var o in objs) {
int c0 = (int)((o.Addr - b) / cell);
grid[c0] = o.Name;
for (int k = 1; k < o.Size / cell; k++) grid[c0 + k] = '-';
}
int prev = -1;
for (int off = 0; off < grid.Length; off += width) {
var row = new string(grid, off, Math.Min(width, grid.Length - off));
if (row.Trim('.').Length == 0) continue; // 该行不包含我们的任何对象
if (prev >= 0 && off != prev + width) Console.WriteLine(" ...");
Console.WriteLine($"0x{(long)b + (long)off * cell:x9} {row}");
prev = off;
}
}
class Small { public long a, b; }
class Medium { public long a, b, c, d, e, f; }
class Large { public long a, b, c, d, e, f, g, h, i, j, k, l, m, n; }
```
*HeapWalk.cs*
构建并运行它。
```
$ dotnet run HeapWalk.cs
=== pass 0 (base 0x7aea1080a1e0) ===
0x7aea1080a1e0 L---L---L---M-M-SL---L---SM-M-M-M-SL---L---L---L---M-L---SSL
0x7aea1080a960 ---L---M-M-L---M-SL---L---SM-L---M-L---L---L---M-L---L---M-L
0x7aea1080b0e0 ---SL---SSL---L---M-L---L---M-L---L---L---SM-SL---L---SL---L
0x7aea1080b860 ---L---SSSL---L---M-SL---SM-L---SL---M-L---M-L---M-L---SSL--
0x7aea1080bfe0 -L---SSSM-SM-L---M-M-L---M-L---
=== pass 1 (base 0x7aea1080a1e0) ===
0x7aea1080a1e0 L---L---L---M-M-SL---L---SM-M-M-M-SL---L---L---L---M-L---SSL
0x7aea1080a960 ---L---M-M-L---M-SL---L---SM-L---M-L---L---L---M-L---L---M-L
0x7aea1080b0e0 ---SL---SSL---L---M-L---L---M-L---L---L---SM-SL---L---SL---L
0x7aea1080b860 ---L---SSSL---L---M-SL---SM-L---SL---M-L---M-L---M-L---SSL--
0x7aea1080bfe0 -L---SSSM-SM-L---M-M-L---M-L---
```
我们立即注意到,同一大小的对象并没有分组在一起。(稍后,在不同的工作负载中,我们还会注意到 C# 在内存中移动对象。)Go 和 C# 的文档都会告诉你这些行为,但我认为通过这种演示看到它也很不错。
既然我们已经看到了 Go 如何分配内存,现在来看看它是如何清理的。
## 标记和清除 (https://theconsensus.dev/p/2026/07/19/observing-gos-garbage-collector-old-and-new.html#mark-and-sweep)
垃圾回收器从特定的根(例如全局变量和局部变量)开始,并在 Go 的历史实现中,跟踪每个指针,直到 GC 访问所有可达的对象。*标记*阶段。然后,在第二轮中,GC 释放任何已分配但未被访问的对象。由于这些现在被释放的对象在标记阶段无法从根树访问,因此它们被认定是死的。*清除*阶段。
当你有一个对象 A 指向不同大小的对象 B/C/D 时,就会出现挑战。在 Go 中,不同大小的对象被分配在不同内存区域。或者,即使你有对象 A 指向在非常不同时间创建的其他对象 A,它们也会存在于内存的非常不同的部分。在这两种情况下,GC 跟踪指针现在引入了随机内存访问,这在缓存友好性上明显较差。
在 Green Tea 中,Go 现在扫描一个内存 span 中的对象和指针,并根据找到的指针对未来的 span 进行扫描排队,而不是大致在遇到每个指针时就跟踪它。虽然我们无法展示这种随机访问行为,除非对 Go 本身应用补丁(这样我们才能观察标记路径访问每个对象的过程),但我们可以通过 `perf` 观察到它减少了缓存未命中(每千条指令)并加快了整体程序运行。
这是我们工作负载的伪代码。
```
struct Node {a,b,c,d *Node}
mode = packed | scattered
nodes = new [2_000_000]*Node
for i in 0..nodes.len:
nodes[i] = Node{
a: nodes[(mode == packed ? i + 1 : rand()) % nodes.len],
b: nodes[(mode == packed ? i + 2 : rand()) % nodes.len],
c: nodes[(mode == packed ? i + 3 : rand()) % nodes.len],
d: nodes[(mode == packed ? i + 4 : rand()) % nodes.len]
}
for i in 0..100:
trigger_gc()
keepalive(nodes) # 阻止 `nodes` 被垃圾回收
```
为了使程序测量更公平一些(分散版本需要做大量生成随机数的工作),我们将节点索引偏移量的生成分开:
```python
import array
import random
import sys
n = 2_000_000
order = sys.argv[1] if len(sys.argv) > 1 else ""
if order == "packed":
a = array.array("I", ((i + k) % n for i in range(n) for k in (1, 2, 3, 4)))
elif order == "scattered":
r = random.Random(1)
a = array.array("I", (r.randrange(n) for _ in range(n * 4)))
else:
sys.exit("usage: gen.py packed|scattered")
assert a.itemsize == 4 and sys.byteorder == "little" # 匹配 Go 的 uint32 转换
with open(order+".idx", "wb") as f:
a.tofile(f)
```
*generate_indexes.py*
然后 Go 工作负载变成:
```go
package main
import (
"io"
"os"
"runtime"
"unsafe"
)
type Node struct {
a, b, c, d *Node
}
func main() {
n := 2_000_000
raw, err := io.ReadAll(os.Stdin)
if err != nil {
panic(err)
}
idx := unsafe.Slice((*uint32)(unsafe.Pointer(&raw[0])), n*4)
nodes := make([]*Node, n)
for i := range nodes {
nodes[i] = &Node{}
}
for i, nd := range nodes {
nd.a = nodes[idx[i*4]]
nd.b = nodes[idx[i*4+1]]
nd.c = nodes[idx[i*4+2]]
nd.d = nodes[idx[i*4+3]]
}
for i := 0; i < 100; i++ {
runtime.GC()
}
runtime.KeepAlive(nodes) // 避免 `nodes` 看起来超出作用域
}
```
*readorder.go*
现在从 Python 脚本生成索引文件。然后构建两个版本的 Go 工作负载:一个带 Green Tea,一个不带。
```bash
python3 generate_indexes.py scattered
python3 generate_indexes.py packed
go build -o readorder_greentea readorder.go
GOEXPERIMENT=nogreenteagc go build -o readorder_oldgc readorder.go
```
让我们用 `perf` 计时两个垃圾回收器和两个工作负载,同时收集缓存未命中信息。
```bash
$ for bin in readorder_oldgc readorder_greentea; do
for input in packed.idx scattered.idx; do
echo "=== $bin < $input ==="
perf stat -e cache-references,cache-misses -r 5 \
sh -c "exec ./$bin < $input" > /dev/null
done
done
=== readorder_oldgc < packed.idx ===
Performance counter stats for 'sh -c exec ./readorder_oldgc < packed.idx' (5 runs):
1,130,709,755 cache-references ( +- 0.70% )
290,434,782 cache-misses # 25.69% of all cache refs ( +- 1.02% )
4.230 +- 0.145 seconds time elapsed ( +- 3.44% )
=== readorder_oldgc < scattered.idx ===
Performance counter stats for 'sh -c exec ./readorder_oldgc < scattered.idx' (5 runs):
13,247,268,612 cache-references ( +- 0.38% )
2,325,799,796 cache-misses # 17.56% of all cache refs ( +- 0.15% )
11.052 +- 0.154 seconds time elapsed ( +- 1.39% )
=== readorder_greentea < packed.idx ===
Performance counter stats for 'sh -c exec ./readorder_greentea < packed.idx' (5 runs):
481,414,281 cache-references ( +- 0.27% )
257,894,055 cache-misses # 53.57% of all cache refs ( +- 0.04% )
2.69560 +- 0.00385 seconds time elapsed ( +- 0.14% )
=== readorder_greentea < scattered.idx ===
Performance counter stats for 'sh -c exec ./readorder_greentea < scattered.idx' (5 runs):
3,398,491,016 cache-references ( +- 1.02% )
2,195,902,796 cache-misses # 64.61% of all cache refs ( +- 0.10% )
6.9610 +- 0.0108 seconds time elapsed ( +- 0.16% )
```
我们看到新 GC 对每个工作负载都有非常明显的改进。但看起来新 GC 的缓存未命中也增加了?这出乎我们的意料。这里涉及两件事。首先,`cache-references` 和 `cache-misses` 在 perf 中通常对应于 L3 缓存。因此,虽然新 GC 中 L3 缓存未命中的百分比可能上升,但这并不能告诉我们 L1 或 L2 缓存级别的任何行为。而且,我们自己也看到了加速。所以我们遗漏了一些东西。
其次,程序运行时间在新旧 GC 之间发生了很大变化,我们还没有将其归一化。`perf` 有一个 `instructions` 指标,我们可以用它来计算标准归一化指标:每千条指令的缓存未命中数(MPKI)。所以让我们再次运行 `perf`,同时请求 `instructions`,然后自己计算 MPKI。
```python
import json, subprocess
for binary in ["readorder_oldgc", "readorder_greentea"]:
for inp in ["packed.idx", "scattered.idx"]:
out = subprocess.run(
["perf", "stat", "-j", "-e", "instructions,cache-misses", "-r", "5", "sh", "-c", f"exec ./{binary} < {inp}"],
相似文章
垃圾回收的实际成本
一篇技术文章解释了垃圾回收的真实性能成本,对比了 Go、Java、Rust、Swift 和 Python 等语言中的跟踪式 GC、引用计数和编译期内存管理。
Go 泛型中的 GC shape stenciling
深入解释 Go 编译器如何使用 GC shape stenciling 实现泛型,并与 Rust 的 full monomorphization 和 Java 的 type erasure 进行比较。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
JDK 27 G1/Parallel/Serial GC 变更
JDK 27 引入了对 HotSpot 的 stop-the-world 垃圾收集器的显著更改,最重要的是 JEP 523 使 G1 在所有环境中成为默认 GC,同时还包含各种改进、重构和错误修复。
Go 1.27 交互式导览
Go 1.27 新功能的实践性交互式导览,重点介绍泛型方法、结构体字面量字段选择器等,并提供基于官方发布说明的可运行示例。