切片、map 与字符串的底层实现
需要预先了解:
1. 切片、map 与字符串的底层实现
这一篇把三种最常用的类型拆到运行时层面:切片头到底存了什么、append 扩容的精确规则、map 的桶结构与渐进式搬迁、字符串为什么不可变。搞懂这些,前面几篇里"看起来反直觉"的行为就都有解释了。
1.1 切片头
切片在运行时是一个三元组:
type slice struct {
array unsafe.Pointer // 指向底层数组
len int // 长度
cap int // 容量
}
用 unsafe 观察:
s := make([]int, 3, 5)
fmt.Printf("切片: len=%d cap=%d 底层数组指针=%p 首元素地址=%p\n",
len(s), cap(s), unsafe.SliceData(s), &s[0])
fmt.Println("切片头大小:", unsafe.Sizeof(s))
切片: len=3 cap=5 底层数组指针=0x26fb60e5c5a0 首元素地址=0x26fb60e5c5a0
切片头大小: 24
unsafe.SliceData(Go 1.20+)直接返回底层数组的指针,和首元素地址一致。
注:
reflect.SliceHeader和reflect.StringHeader是历史上用来窥探这两个结构的类型,从 Go 1.20 起已标记为废弃——它们的字段是普通整数,GC 不会把它们当作指针,一旦被误用就会导致对象被提前回收。需要拿数据指针时用unsafe.SliceData/unsafe.StringData。
1.2 切片扩容策略
append 容量不足时调用 growslice,分两步:先算新容量,再向分配器申请内存(向上取整到 size class)。
容量计算规则(Go 1.18 起):
- 需要的新容量超过当前容量的两倍,直接取需要值;
- 当前容量小于 256,翻倍;
- 当前容量不小于 256,按
newcap += (newcap + 3*256) / 4增长,大约 1.25 倍。
第二步之后还要按元素大小做 size class 取整。实测 []int 从空切片开始 append 到 5000 个元素:
1 2 4 8 16 32 64 128 256 512 848 1280 1792 2560 3408 5120
- 256 之前是标准翻倍;
- 256 之后 512 → 848:公式算出 832,按 8 字节元素取整到 6784 字节的 size class,得到 848;
- 再往后每一步都在公式结果上做同样的取整,所以看起来是 1.25 倍左右的跳跃。
元素大小会改变起点:
[]int 容量序列: 1 2 4 8 16 32 64 128 256 512
[]byte 容量序列: 8 16 32 64 128 256 512
[]point(24B) 容量序列: 1 2 4 8 16 32 64 128 256 512
[]byte 第一次扩容就拿到 8 个字节(最小 size class),[]int 第一次拿到 1 个元素(8 字节),[]point 每个元素 24 字节,第一次也是 1 个。
实践结论:容量能预估就预分配,make([]T, 0, n) 一次分配胜过十几次扩容加拷贝。
1.3 map 的数据结构
注:下面描述的是 Go 1.23 及更早版本的实现。Go 1.24 起 map 换成了 Swiss table 实现,见后文——对外行为(增删改查复杂度、遍历随机化、value 不可取地址)完全一致,只是内部结构不同。
map 变量本身只是一个指针(8 字节),指向运行时的 hmap:
fmt.Println("map 变量大小:", unsafe.Sizeof(m)) // 8
hmap 的关键字段:
type hmap struct {
count int // 元素个数
flags uint8 // 状态标志(是否正在写、是否正在扩容)
B uint8 // 桶数量的对数:桶数 = 2^B
noverflow uint16 // 溢出桶的近似数量
hash0 uint32 // 哈希种子,每个 map 不同
buckets unsafe.Pointer // 桶数组
oldbuckets unsafe.Pointer // 扩容时的旧桶数组
nevacuate uintptr // 已搬迁的桶数量
}
每个桶(bmap)能装 8 个键值对:
+-------------------------------+
| tophash [8]uint8 | 每个槽的高 8 位哈希,用于快速筛选
+-------------------------------+
| keys [8]keyType |
+-------------------------------+
| values [8]elemType |
+-------------------------------+
| overflow *bmap | 溢出桶指针
+-------------------------------+
tophash 的作用是加速比较:查找时先比 8 位的哈希,只有哈希相同才去比完整的键,避免昂贵的键比较。
1.4 map 的定位流程
key ──hash(hash0)──> 64 位哈希
│
├── 低 B 位 ──> 桶编号
└── 高 8 位 ──> tophash
│
v
在桶内遍历 8 个槽:
tophash 相同 → 比较完整 key → 命中
8 个都没命中 → 沿 overflow 指针找下一个桶
平均情况下的查找代价是 O(1):一次哈希 + 一次桶内扫描(最多 8 次比较)。
NaN 的怪行为
float64 的 NaN 不等于任何值,包括它自己。map 的查找逻辑里"键相等"用的是 ==,所以用 NaN 写入的键永远查不到:
nan := math.NaN()
m := map[float64]int{nan: 1}
fmt.Println(len(m)) // 1,确实存进去了
fmt.Println(m[nan]) // 0,查不到
这也是为什么不要把浮点数当 map 的键。
1.5 map 的扩容
触发扩容的条件有两个:
| 条件 | 说明 | 扩容方式 |
|---|---|---|
| 元素数 / 桶数 > 6.5 | 负载因子过高,查找变慢 | 桶数翻倍(B+1) |
| 溢出桶过多 | 大量键落在同一个桶,说明哈希分布差 | 等量扩容(桶数不变) |
负载因子 6.5 是"空间"和"时间"的折中:桶装 8 个槽,装到 6.5 个左右就开始变慢。
渐进式搬迁
扩容不是一次性完成的。如果一次要搬迁几百万个元素,停顿会很长。Go 的做法是渐进式搬迁(incremental evacuation):
- 扩容时先分配新桶数组,
oldbuckets指向旧数组; - 之后每次读写都会顺手搬一两个旧桶到新数组;
- 直到所有旧桶搬完,
oldbuckets被置空。
这也解释了 map 遍历顺序的随机化:遍历时可能同时面对新旧两套桶,起点又被随机化,所以顺序不可预测(见 map 与字符串)。
1.6 Go 1.24+ 的 Swiss table
Go 1.24 把 map 的底层换成了 Swiss table(源自 Abseil 的哈希表设计):
- 数据按 group 组织,每组 8 个槽;
- 每组有一个控制字(8 个字节,每个字节对应一个槽),保存 7 位哈希片段或"空/删除"标记;
- 查找时先用 SIMD 指令一次性比较 8 个控制字节,快速筛出候选槽;
- 负载因子是 7/8(
maxAvgGroupLoad = 7),比 6.5 更高,内存利用率更好; - 元素较少时用"小 map"特化,只有一个 group,不额外分配表。
对外行为不变:平均 O(1)、扩容是均摊的、遍历顺序随机、value 不可取地址。
1.7 为什么 map 的 value 不能取地址
m := map[string]int{"a": 1}
p := &m["a"] // 编译错误
从底层看有两个原因:
- 元素会移动。扩容时键值对会被搬到新的桶里,如果允许取地址,这个地址随时可能失效;
- value 不是"变量"。map 的索引表达式返回的是一个值的副本,而不是一块可寻址的内存。
需要修改结构体字段时,要么整体替换(m[k] = v),要么把 value 类型改成指针(map[K]*V)。
1.8 字符串的底层结构
字符串是一个"指针 + 长度"的两元组:
type stringStruct struct {
str unsafe.Pointer // 指向只读的字节序列
len int // 字节长度
}
str := "hello"
fmt.Printf("字符串: 长度=%d 数据指针=%p\n", len(str), unsafe.StringData(str))
fmt.Println("字符串头大小:", unsafe.Sizeof(str))
字符串: 长度=5 数据指针=0x7ff756e181aa
字符串头大小: 16
几个推论:
len(s)是字节数,不是字符数;- 字符串不可变:底层内存是只读的,编译器禁止
s[0] = 'x'; - 字符串常量存在只读数据段,地址看起来像
0x7ff...;[]byte(s)转换出来的字符串则在堆上; - 切片和字符串可以共享底层数组:
string(bytes)会复制,unsafe.String不复制(见 反射与 unsafe)。
1.9 string 与 []byte 的转换成本
[]byte(s) 和 string(b) 都会复制一份数据。频繁转换是常见的性能陷阱:
BenchmarkStringToBytes-24 12358393 18.92 ns/op 40 B/op 2 allocs/op
一次转换就是 40 字节、2 次分配。在循环里做转换会迅速放大开销。需要反复读写时,先把数据转成 []byte 处理完再转回字符串,或者用 bytes.Buffer。
1.10 strings.Builder 的内部机制
strings.Builder 内部就是一个 []byte:
type Builder struct {
addr *Builder
buf []byte
}
WriteString 把字符串追加到 buf 上,String() 用 unsafe 把 buf 直接零拷贝地转成字符串(之后 Builder 会禁止继续写,防止字符串被偷偷改掉)。所以拼接 100 次的开销差别很大:
BenchmarkConcatPlus-24 18340 14219 ns/op 64024 B/op 99 allocs/op
BenchmarkConcatBuilder-24 1000000 524.4 ns/op 1280 B/op 1 allocs/op
用 + 拼接:99 次分配、64KB 内存、14µs;用 Builder 并 Grow 预分配:1 次分配、1.3KB、0.5µs。循环里拼接字符串一律用 Builder。
1.11 底层视角回顾共享陷阱
数组与切片 里的三个陷阱,现在可以从内存层面解释:
- 两个切片互相影响:它们的三元组里
array指向同一块内存; append后分裂:cap够时写进同一块内存,cap不够时growslice分配新数组,两个切片的array从此分道扬镳;- 小切片占住大数组:切片的
cap仍然指向原数组末尾,GC 通过array指针判断可达性,整块数组因此无法回收。
1.12 练习
- 用
unsafe.SliceData和&s[0]打印切片在不同append阶段的地址,验证"容量足够地址不变、容量不足地址改变"。 - 写一个程序打印
[]int64、[]string、[]struct{ A, B int }的容量增长序列,比较元素大小对首次容量的影响。 - 用
testing.B对比strings.Builder、bytes.Buffer和+三种拼接方式的ns/op与allocs/op。



