1.2 Swiss Table map 与运行时改进
Go 的 map 从 1.0 到 1.23 一直用的是同一种实现:一个由若干 bucket 组成的哈希表,每个 bucket 装 8 个键值对,靠线性探测在 bucket 内找空位。这套实现在 runtime 里活了十几年,稳、够用、但上限摆在那里——查找要在 bucket 里逐个比对键。
Go 1.24 把它换成了 Swiss Table(源自 Google 的 Abseil 库,C++ 里叫 absl::flat_hash_map)。这是运行时层面少见的大改:数据结构本身换了,而 map 的语言语义一个字没变。
本节要回答:Swiss Table 在 1.24–1.27 的开关状态是什么?它到底快了多少?哪些行为被它改变了、哪些没有?结论:1.24/1.25 默认启用且可用
noswissmap关闭,1.26 起开关被移除成为无条件实现;本机基准显示整型键约快 1.36 倍、字符串键约快 1.62 倍;而迭代顺序随机这一契约没有变。
1.2.1 Swiss Table 改了什么
旧实现在 bucket 内是「逐个槽位比对键」。Swiss Table 的核心改进是给每个槽位配一个 1 字节的控制字节(control byte),存键的哈希高 7 位,外加一个「空/已删」标记。查找时先并行比对这 8 个控制字节(用 SIMD 风格的位运算),只有控制字节命中才去比对真正的键。
| 维度 | 旧 map(bucket 线性探测) | Swiss Table |
|---|---|---|
| 槽位元数据 | 无,直接存键值 | 每槽 1 字节控制字节 |
| 查找第一步 | 逐槽比对键 | 并行比对 8 个控制字节 |
| 键比对次数 | 平均等于探测长度 | 只有控制字节命中才比对 |
| 扩容方式 | 逐个 bucket 搬迁 | 按 group 搬迁 |
| 语言语义 | —— | 完全相同 |
用一句话概括:Swiss Table 让「不匹配」的键尽快被排除掉,从而减少昂贵的键比对(尤其对字符串键)。
1.2.2 版本归属与开关状态
这是本节最需要钉死的部分,因为网上关于「Swiss Table 哪个版本默认开」的说法相当混乱。本机可用证据是各工具链的 buildcfg/exp.go 基线块,加上 GOEXPERIMENT 的接受情况。
| 事实 | 结论 | 证据 |
|---|---|---|
| 默认启用 | Go 1.24 起 | go1.24.0 基线块含 SwissMap: true |
| 1.25 状态 | 仍默认启用 | go1.25.0 基线块含 SwissMap: true |
| 可显式关闭 | 1.24/1.25 | GOEXPERIMENT=noswissmap 被接受 |
| 开关被移除 | Go 1.26 起 | go1.26.3 GOEXPERIMENT=noswissmap → unknown GOEXPERIMENT swissmap |
| 1.27 状态 | 无条件实现 | 同上报错,且 go list std 正常 |
实测命令与输出:
$ GOTOOLCHAIN=go1.25.0 GOEXPERIMENT=noswissmap go env GOEXPERIMENT
noswissmap
$ GOTOOLCHAIN=go1.26.3 GOEXPERIMENT=noswissmap go env GOEXPERIMENT
go: unknown GOEXPERIMENT swissmap
$ GOTOOLCHAIN=go1.27.0 GOEXPERIMENT=noswissmap go env GOEXPERIMENT
go: unknown GOEXPERIMENT swissmap
换句话说,1.26 及以后你无法再关掉 Swiss Table。这意味着 1.26+ 的 map 行为是固定的,不再有「两台机器开关不一致」的风险。
基线块的直接证据(用正则从源码里读):
1.24.0 SwissMap: True | AliasTypeParams: True | GreenTeaGC: False
1.24.4 SwissMap: True | AliasTypeParams: True | GreenTeaGC: False
1.25.0 SwissMap: True | AliasTypeParams: True | GreenTeaGC: False
1.26.3 SwissMap: False | AliasTypeParams: False | GreenTeaGC: True
1.27.0 SwissMap: False | AliasTypeParams: False | GreenTeaGC: True
注意最后两行:SwissMap 从基线里消失了——不是被关掉,而是成为无条件行为,不再需要作为实验项列出。同一个表也顺带证实了 1.1 的 AliasTypeParams 和 3.3 的 GreenTeaGC。
1.2.3 实测:swissmap 与 noswissmap 的性能差
只有在 go1.25.0 上才能同时拿到两种实现。基准代码用「建 1024 个元素、再查 1024 次」的循环,分别测整型键与字符串键:
func BenchmarkMapIntKey(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
m := make(map[int]int, 1024)
for j := 0; j < 1024; j++ {
m[j] = j
}
var sum int
for j := 0; j < 1024; j++ {
sum += m[j]
}
_ = sum
}
}
BenchmarkMapStringKey 结构相同,只是键换成 itoa(j) 生成的字符串。实测(Apple M1 Pro,-benchtime=500x -count=3):
=== swissmap(默认) ===
BenchmarkMapIntKey-10 500 17655 ns/op 36944 B/op 5 allocs/op
BenchmarkMapIntKey-10 500 16210 ns/op 36954 B/op 5 allocs/op
BenchmarkMapIntKey-10 500 16036 ns/op 36944 B/op 5 allocs/op
BenchmarkMapStringKey-10 500 45173 ns/op 57765 B/op 1019 allocs/op
BenchmarkMapStringKey-10 500 45030 ns/op 57765 B/op 1019 allocs/op
BenchmarkMapStringKey-10 500 44212 ns/op 57776 B/op 1019 allocs/op
=== noswissmap(旧实现) ===
BenchmarkMapIntKey-10 500 20394 ns/op 40984 B/op 2 allocs/op
BenchmarkMapIntKey-10 500 22378 ns/op 40984 B/op 2 allocs/op
BenchmarkMapIntKey-10 500 21990 ns/op 40984 B/op 2 allocs/op
BenchmarkMapStringKey-10 500 86430 ns/op 60525 B/op 1016 allocs/op
BenchmarkMapStringKey-10 500 71958 ns/op 60536 B/op 1016 allocs/op
BenchmarkMapStringKey-10 500 73040 ns/op 60525 B/op 1016 allocs/op
取中位数对比:
| 场景 | 旧实现(noswissmap) | Swiss Table | 加速比 |
|---|---|---|---|
map[int]int 建 1024 + 查 1024 | 21990 ns/op | 16210 ns/op | 约 1.36× |
map[string]int 建 1024 + 查 1024 | 73040 ns/op | 45030 ns/op | 约 1.62× |
两个观察:
- 字符串键的收益明显大于整型键。这符合 Swiss Table 的设计预期——它省下的正是「键比对」的开销,而字符串键比对比整型键昂贵得多。
- 内存占用略有上升,分配次数变多。整型键场景
B/op从 40984 降到 36944(更省),但allocs/op从 2 升到 5;字符串键场景B/op基本持平、allocs/op从 1016 升到 1019。Swiss Table 用「多一点元数据/分配」换「少一点比对」,不是纯粹的「什么都更好」。
1.27 上重跑同一基准,结果与 1.25 的 Swiss Table 列一致(整型键 16874 ns/op,字符串键 41732 ns/op),说明实现已经稳定:
=== go1.27.0 默认 ===
BenchmarkMapIntKey-10 500 16874 ns/op 36944 B/op 5 allocs/op
BenchmarkMapStringKey-10 500 41732 ns/op 57776 B/op 1019 allocs/op
1.2.4 没变的东西:迭代顺序仍然随机
这是最容易被误传的一点:「Swiss Table 让 map 有序了吗?」——没有。Go 从未承诺过 map 迭代顺序,Swiss Table 也没有改变这一点。实测同一张表连续迭代三轮:
m := map[int]int{}
for i := 0; i < 8; i++ {
m[i] = i
}
for round := 0; round < 3; round++ {
fmt.Print("order", round, ":")
for k := range m {
fmt.Print(" ", k)
}
fmt.Println()
}
实测输出:
order0: 3 4 5 6 7 0 1 2
order1: 0 1 2 3 4 5 6 7
order2: 5 6 7 0 1 2 3 4
三轮顺序都不同,且看得出是「环形轮转 + 随机起点」的模式(这是运行时故意注入的随机化)。如果你的代码依赖 map 的迭代顺序,那它在 1.24 之前就是错的,1.24 之后依然是错的——只是错误可能换了一种表现方式,更难被测试抓到。
1.2.5 语言语义逐条核对
换实现最怕「顺手改了语义」。逐条核对 map 的基本操作,确认 Swiss Table 一个都没动:
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
delete(m, "b")
m["d"] = 4
fmt.Println("len:", len(m), "a:", m["a"], "b:", m["b"])
_, ok := m["b"]
fmt.Println("comma-ok for deleted:", ok)
var nilMap map[string]int
fmt.Println("nil map len:", len(nilMap), "read ok:", nilMap["x"])
clear(m)
fmt.Println("after clear len:", len(m))
}
实测输出:
len: 3 a: 1 b: 0
comma-ok for deleted: false
nil map len: 0 read ok: 0
after clear len: 0
四条语义都在:删除后 len 正确减少、读缺失键返回零值、comma-ok 返回 false、nil map 可读可 len、clear 清空。Swiss Table 是纯粹的实现替换,API 与语义零变化——这也正是它能作为默认实现在 1.24 直接上线的底气。
1.2.6 同一批运行时改动
Swiss Table 不是 Go 1.24 唯一的运行时改动。从 go1.24.0 的基线块能看到当时一并进入默认的一组实验特性:
| 实验名 | 内容 | 1.26 后状态 |
|---|---|---|
SwissMap | map 换 Swiss Table | 移除(无条件) |
AliasTypeParams | 泛型类型别名(见 1.1) | 移除(无条件) |
SyncHashTrieMap | sync.Map 换哈希 trie 实现 | 移除(无条件) |
SpinbitMutex | mutex 加自旋位优化 | 移除(无条件) |
CoverageRedesign | 覆盖率工具链重写 | 移除(无条件) |
go1.24.0 基线块原文:
baseline := goexperiment.Flags{
RegabiWrappers: regabiSupported,
RegabiArgs: regabiSupported,
CoverageRedesign: true,
AliasTypeParams: true,
SwissMap: true,
SpinbitMutex: haveXchg8,
SyncHashTrieMap: true,
}
对照 go1.26.3 的基线块,这五项全部消失——不是被关掉,而是「实验做完了,成为无条件行为,不再需要作为开关列出」。这解释了一个常见困惑:为什么在 1.26+ 上 GOEXPERIMENT=noswissmap 会报「unknown GOEXPERIMENT」。开关的生命周期结束,功能留下来。
顺带一提,SpinbitMutex 这个名字来自「mutex 上的自旋位」——它是 1.24 对 sync.Mutex 内部实现的优化,同样属于「实现变了、语义没变」这一类。本卷 5 章讲内存模型时会回到 sync.Mutex 的 happens-before 保证,那时你会看到:实现可以换,但内存模型给的保证必须一字不差。
1.2.7 对工程实践的几条判据
| 你的顾虑 | 结论 |
|---|---|
| 升级到 1.24+ 会不会让 map 语义变化 | 不会,语言语义完全不变 |
| 迭代顺序能不能依赖 | 从来不能,Swiss Table 之后仍不能 |
| 能不能在 1.26+ 回退旧实现 | 不能,开关已被移除 |
| 大 map 的查找热点要不要重新测 | 建议重测,字符串键收益可达 1.6× |
| 内存敏感的极端场景 | 注意 allocs/op 可能上升,需实测确认 |
一个容易忽略的推论:基准数字是「本机 + 本数据分布」的。上面测的是 1024 个元素的稠密整数键与短字符串键,如果你的键是长字符串或高冲突分布,收益会不同。引用任何 map 性能数字前,先在自己的键分布上重跑一遍。
1.2.8 小结
- Go 1.24 起 map 底层换成 Swiss Table,默认启用;1.24/1.25 可用
GOEXPERIMENT=noswissmap关闭,1.26 起开关被移除。 - 本机实测:整型键约快 1.36×,字符串键约快 1.62×,代价是分配次数略增。
- 语言语义零变化,迭代顺序仍然随机,依赖它依旧是 bug。
- 版本归属的完整证据链是「各工具链
buildcfg/exp.go基线块 +GOEXPERIMENT接受情况」,而不是任何二手文章。
下一节进入 Go 1.24 的另一组运行时能力:runtime.AddCleanup 与 weak 包——它们共同解决「对象不可达时如何收尾」和「如何持有一个不阻止回收的引用」。
阅读导航:上一节:1.1 泛型类型别名与语言精化 · 下一节:1.3 runtime.AddCleanup 与 weak 包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。