Go 里的 string、slice、map 和 channel 用起来很简单,出问题时却往往和它们的底层存储有关:两个 slice 共享了数组,append 换了底层数组,nil map 被写入,或者多个 goroutine 同时读写 map。
本文以 Go 1.26.1 为准。语言层的结论通常比较稳定,Swiss Table、slice 扩容公式等 runtime 细节只对这个版本负责。
new 和 make 各做什么
new(T) 为 T 分配零值,返回 *T。make 只能用于 slice、map 和 channel,返回已初始化的值。
p := new(int) // *int,*p == 0
s := make([]int, 0, 16) // len=0, cap=16
m := make(map[string]int, 16) // 容量参数是 hint
ch := make(chan int, 8) // 有缓冲 channel
new(map[string]int) 虽然能编译,但得到的是指向 nil map 的指针,写入仍然会 panic。通常没有必要这样写。
pm := new(map[string]int)
// (*pm)["answer"] = 42 // panic: assignment to entry in nil map
*pm = make(map[string]int)
(*pm)["answer"] = 42
string:只读的字节序列
string 的 runtime 表示可以概括为数据指针加长度。它没有容量字段,也不允许修改单个字节。
type stringHeader struct {
Data unsafe.Pointer
Len int
}
这是解释模型,不应该复制到业务代码里操作 string。Go 已经提供 unsafe.StringData 和 unsafe.String,但它们仍然属于 unsafe API。
string 的索引单位是 byte,range 则按 UTF-8 解码 rune:
s := "Go语言"
fmt.Println(len(s)) // 8,字节数
for i, r := range s {
fmt.Printf("byte=%d rune=%c\n", i, r)
}
[]byte(s) 和 string(b) 在语义上会产生独立的值。编译器可以在某些不逃逸、不修改的场景中省掉拷贝,业务代码不应依赖这个优化才能正确工作。
拼接少量字符串直接用 + 最清楚。循环拼接或结果较大时,用 strings.Builder:
var b strings.Builder
b.Grow(64)
for _, part := range parts {
b.WriteString(part)
}
result := b.String()
slice:对底层数组的窗口
slice 由数据指针、长度和容量组成。复制 slice 只会复制这三个字段,底层数组仍然共享。
a := []int{1, 2, 3}
b := a[:2]
b[0] = 9
fmt.Println(a) // [9 2 3]
需要独立数据时要显式复制:
b := slices.Clone(a)
// 或者
b := append([]int(nil), a...)
append 何时更换数组
容量足够时,append 会直接复用原数组;容量不足时,runtime 分配新数组并复制数据。所以必须接住 append 的返回值。
s = append(s, value)
Go 1.26.1 的 nextslicecap 大致按下面的规则计算新容量:
- 一次追加超过两倍旧容量时,直接从新长度开始。
- 旧容量小于 256 时,候选容量翻倍。
- 更大的 slice 通过一个平滑公式逐渐接近 1.25 倍增长。
最终容量还会受分配器大小级对齐影响,不要写依赖某个精确扩容倍数的代码。
用三索引切片限制 append
子 slice 如果保留了多余容量,它的 append 可能覆盖原 slice 后面的元素。三索引切片可以把容量限制在当前长度:
a := []int{1, 2, 3, 4}
b := a[:2:2]
b = append(b, 9) // 必须分配新数组
fmt.Println(a) // [1 2 3 4]
fmt.Println(b) // [1 2 9]
另一个常见问题是小 slice 长期引用一个巨大数组。仅保留少量数据时,用 slices.Clone 断开引用。
map:Go 1.26.1 的 Swiss Table
很多旧文章还在用 hmap 和溢出 bucket 解释 map。Go 1.26.1 的实现位于 internal/runtime/maps,已经换成基于 Swiss Table 的设计。
一个 group 包含 8 个 key/value slot 和 8 字节的 control word。哈希值分成两部分:
- H1 用来定位初始 group 并进行二次探测。
- H2 是低 7 位,写入 control byte。查找时可以一次比较 group 内 8 个 slot 的 H2,再对候选 key 做完整比较。
小 map 在不超过 8 个元素时可以只用一个 group。大 map 由 directory 定位多张 table,每张 table 独立增长。table 到达上限后会拆成两张,避免每次扩容都重排整张大 map。
map 的语言边界比底层结构更重要:
- 迭代顺序没有定义,不能用于稳定输出。
- key 必须可比较;slice、map 和 func 不能作为 key。
- 内置 map 不保证并发读写安全。一边写、一边读也不行。
make(map[K]V, n)的n是容量提示,不是长度和硬性预分配承诺。
要稳定输出时先排序 key:
keys := slices.Sorted(maps.Keys(m))
for _, key := range keys {
fmt.Println(key, m[key])
}
并发访问默认先考虑 map + sync.Mutex/RWMutex,因为类型安全,也方便在同一个临界区维护多个不变式。sync.Map 是特化工具,它的适用场景放在同步原语篇说。
channel:队列加等待者
channel 用于在 goroutine 之间传递值和同步。Go 1.26.1 的 runtime hchan 主要保存:
- 循环缓冲区的容量、当前元素数和收发下标。
- 等待发送和接收的 goroutine 队列。
- closed 状态和保护内部字段的锁。
无缓冲 channel 需要发送与接收直接交接。有缓冲 channel 允许发送方在队列未满时先继续执行。缓冲区只是改变阻塞时机,不会自动解决反压、丢消息和 goroutine 泄漏。
jobs := make(chan Job, 64)
select {
case jobs <- job:
// 已入队
case <-ctx.Done():
return ctx.Err()
}
nil 值的行为要分类记
| 类型 | 可读 | 可写 | 其他行为 |
|---|---|---|---|
| nil slice | 可 | 可 | len/cap 为 0,可 append,range 零次 |
| nil map | 可 | 不可 | 读取返回零值,delete 和 range 安全 |
| nil channel | 阻塞 | 阻塞 | 收发永久阻塞,close 会 panic |
| nil pointer | 不可 | 不可 | 解引用通常 panic,但不解引用时可以比较和传递 |
| nil interface | - | - | 只有动态类型和动态值都为 nil 时才等于 nil |
nil slice 与非 nil 空 slice 在 len、cap、append 和 range 方面很像,但 JSON 输出通常分别是 null 和 []。API 需要稳定返回空数组时,应该显式初始化。
nil channel 的“永久阻塞”在 select 中反而很有用。把某个 case 的 channel 设为 nil,就能动态关闭该分支。
我会遵守的几条规则
- 默认把 slice 当作共享视图,确实需要所有权时才 clone。
- 接住每一次
append的返回值,不猜测容量增长倍数。 - map 顺序需要稳定时显式排序,并发读写时显式同步。
- channel 的缓冲大小要来自流量和反压设计,不是随手写一个足够大的数。