Go 里的 string、slice、map 和 channel 用起来很简单,出问题时却往往和它们的底层存储有关:两个 slice 共享了数组,append 换了底层数组,nil map 被写入,或者多个 goroutine 同时读写 map。

本文以 Go 1.26.1 为准。语言层的结论通常比较稳定,Swiss Table、slice 扩容公式等 runtime 细节只对这个版本负责。

new 和 make 各做什么

new(T)T 分配零值,返回 *Tmake 只能用于 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.StringDataunsafe.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 slicelen/cap 为 0,可 append,range 零次
nil map不可读取返回零值,delete 和 range 安全
nil channel阻塞阻塞收发永久阻塞,close 会 panic
nil pointer不可不可解引用通常 panic,但不解引用时可以比较和传递
nil interface--只有动态类型和动态值都为 nil 时才等于 nil

nil slice 与非 nil 空 slice 在 lencapappend 和 range 方面很像,但 JSON 输出通常分别是 null[]。API 需要稳定返回空数组时,应该显式初始化。

nil channel 的“永久阻塞”在 select 中反而很有用。把某个 case 的 channel 设为 nil,就能动态关闭该分支。

我会遵守的几条规则

  1. 默认把 slice 当作共享视图,确实需要所有权时才 clone。
  2. 接住每一次 append 的返回值,不猜测容量增长倍数。
  3. map 顺序需要稳定时显式排序,并发读写时显式同步。
  4. channel 的缓冲大小要来自流量和反压设计,不是随手写一个足够大的数。

参考源码