主题
Go 数据结构实现:值复制了,底下的数据复制了吗
把 slice、map、channel、interface 或 string 赋给另一个变量,复制的是这个值;至于值里面指向的数据是否共享,要看具体类型。很多“怎么连原来的数据也改了”“明明是 nil,判断却不等于 nil”的问题,就藏在这一层区别里。
本篇先讲语言保证的行为,再用 Go 1.27.2、Linux/amd64 的运行时表示解释原因。内部结构可以帮助推理,但字段、尺寸、扩容算法都不是稳定 ABI,不能照着图用 unsafe 去改运行时对象。map 使用当前 Swiss Table 实现,不把旧版 hmap/bmap 的溢出桶模型混进来。[1][2][3]
先看五种值各自带着什么
| 类型 | 当前实现中的值或入口 | 赋值后主要共享什么 | 应用最该记住的边界 |
|---|---|---|---|
| slice | 数组指针、长度、容量 | 底层数组 | 长度独立变化,元素可能互相影响 |
| map | 指向运行时 map 结构的入口 | 同一份键值映射 | 赋值不是克隆,不提供自动并发写保护 |
| channel | 指向 hchan 的入口 | 同一队列、等待者和关闭状态 | 复制 channel 不会得到另一条通道 |
| interface | 类型/方法表信息与数据部分 | 取决于动态值的表示与语义 | nil 接口与持有 nil 指针的接口不同 |
| string | 字节数据指针与字节长度 | 可以共享只读数据 | 内容不可变,长度按字节计 |
第一列行为有规范保障;中间的布局来自当前源码。文末程序用 unsafe.Sizeof 只读取值本身的尺寸,本机得到:
text
value sizes: 24 8 8 16 16顺序是 slice、map、channel、interface、string。这不表示“一张 map 只占 8 字节”:它的表、键和值没有被这个 Sizeof 一起算进去。这里也没有读取私有字段或依赖实际地址。[4][2:1]
slice:三个字段,解释共享与扩容
slice 的当前表示是 array / len / cap。长度决定当前可索引的元素范围,容量决定从起点出发,在不换底层数组的前提下还能延伸到哪里。复制 slice 描述符不会自动复制数组。[5][6]
正在绘制图示…
查看图源代码
flowchart LR
A["base:指针、len=4、cap=4"] --> X["底层数组<br/>1、2、3、4"]
B["base 的前两项视图<br/>len=2、cap=4"] --> X
C["限制容量的视图<br/>len=2、cap=2"] --> X
B -->|append 一项,有容量| X
C -->|append 一项,容量不足| Y["另一个数组<br/>复制已有两项,再写新项"]
这两种视图最开始都共享数组,区别出现在后续追加:
go
base := []int{1, 2, 3, 4}
shared := append(base[:2], 9)
detached := append(base[:2:2], 8)
fmt.Println("slice:", base, shared, detached)本次输出:
text
slice: [1 2 9 4] [1 2 9] [1 2 8]第一次 append 的容量够用,于是把原数组的第三项写成 9。第二次用了完整切片表达式,把容量限制为 2;再追加时就需要新的底层数组,所以 detached 的第三项是 8,原数组仍保留 9。限制容量只约束追加时的行为,不会让已有元素变成独立副本。 若在追加前修改受限视图的第一项,原数组仍会受影响。[6:1][7]
真正需要独立元素副本时,可以 slices.Clone:
go
cloned := slices.Clone(base)
cloned[0] = 7
fmt.Println("clone:", base, cloned)text
clone: [1 2 9 4] [7 2 9 4]本例元素是 int,元素复制后就互相独立。若元素是指针、slice 或 map,Clone 仍只是逐元素赋值,里面引用的数据不会递归复制。[8]
扩容还会影响性能。当前 growslice 分配新数组并搬运已有内容,nextslicecap 结合旧容量与新长度选择候选容量,分配器又可能按 size class 调整。源码中确实有从较小数组的快速增长向较大数组的较缓增长过渡的规则,但最终容量不适合背成“永远翻倍”或“永远乘 1.25”。[5:1]
提前给合适容量可以减少重复搬运,代价是可能多留空间。更重要的是,小 slice 也可能继续指向大数组,使大数组无法释放。要长期保留很小的一段,可以在衡量成本后复制出来;仅仅把 cap 改小,并不能切断原数组引用。[5:2][7:1]
map:Swiss Table 怎样少比较几个完整键
map 要把“按键找值”做快。哈希函数先把键变成一个数;发生哈希冲突时,再用键相等比较确认。Go 1.24 开始引入 Swiss Table 实现,本篇核对的是 Go 1.27.2 的 internal/runtime/maps。[3:1][9]
当前实现先把这些名字分清:
| 名字 | 装什么 | 起什么作用 |
|---|---|---|
| slot | 一组键和值 | 真正存条目的位置 |
| group | 8 个 slot 加一个控制字 | 一次筛选一组候选位置 |
| control byte | 对应槽的空/删除/占用状态;占用时带 7 位哈希信息 H2 | 先排除大部分不可能相等的键 |
| table | 若干 group 和扩容元数据 | 组织一段哈希空间内的探测 |
| directory | 指向 table 的目录 | 用哈希高位选择 table,让大 map 分段增长 |
这里的 group 是 8 槽,别直接照抄其他语言 Swiss Table 实现的组宽。对于当前 amd64 路径,哈希拆成 H1 和低 7 位 H2;先用 H1 等信息定位探测位置,利用控制字并行匹配 H2,再对候选槽执行真正的键比较。H2 相同只代表候选,不代表键一定相同。[9:1][10]
正在绘制图示…
查看图源代码
flowchart TD
Key["待查找的 key"] --> Hash["计算哈希"]
Hash --> D["哈希高位选择目录项"]
D --> T["选中的 table"]
T --> G["根据探测序列选择 group"]
Hash --> H2["提取低 7 位 H2"]
H2 --> Match["与 group 控制字批量匹配"]
G --> Match
Match --> Candidate["只对候选 slot 比较完整 key"]
Candidate --> Found{"找到相等键?"}
Found -->|是| Value["返回值"]
Found -->|否且组内有 empty| Missing["结束:键不存在"]
Found -->|否且需要继续探测| Next["按二次探测序列找下一组"]
Next --> G
它的优势是少读、少比较不相关的完整键,控制信息又集中放在一起;代价是控制字、探测和扩容逻辑更复杂。对于长字符串键,哈希和真正的键比较仍然有成本;不能把一次 map 查询说成与键长、冲突和负载无关的固定耗时。[9:2][10:1]
为什么删除需要墓碑,扩容需要目录
开放寻址的查找遇到真正的空槽可以停止。如果删除时把探测路径中间的占用槽一律改成 empty,后面因为冲突被放得更远的键就可能再也找不到。因此某些删除必须留下 deleted 状态,也叫墓碑,表示“这里曾经占过位置,查找要继续”。当前实现会在条件允许时直接清成 empty,并尽量复用墓碑。[9:3]
table 的组数变化会改变探测序列,所以增长一个 table 时要重新布置其条目。为了不让一次插入搬动整个大 map,Go 把 map 拆成多个 table,并用可扩展哈希目录选择它们:table 在一定范围内扩容,达到阈值后分裂。目录中的多个位置可以暂时指向同一 table。[9:4][11]
正在绘制图示…
查看图源代码
flowchart LR
D00["目录 00"] --> A["table A<br/>local depth = 1"]
D01["目录 01"] --> A
D10["目录 10"] --> B["table B<br/>local depth = 2"]
D11["目录 11"] --> C["table C<br/>local depth = 2"]
A --> AG["A 自己的 groups"]
B --> BG["B 自己的 groups"]
C --> CG["C 自己的 groups"]
这是目录深度为 2 的关系示意,不是从本次小 map 中窥探出的真实布局。单个 table 的增长范围受到实现限制,但分裂、目录增长和重哈希仍有成本;没有因此得到插入延迟的硬上限。很小的 map 还有直接使用单个 group 的路径,不能要求每张小 map 都先建立上图这些 table。[9:5][11:1]
实现变了,这些语言行为没有变
go
m := map[string]int{"x": 1}
alias := m
alias["x"] = 2
v, ok := m["missing"]
fmt.Println("map:", m["x"], v, ok)text
map: 2 0 false赋值共享映射,因此通过 alias 修改后,m 读到 2。键不存在时,单独的 v 是元素零值;用第二个返回值才区分“没这个键”与“值恰好为零”。[12]
| 行为 | 为什么应用要在意 |
|---|---|
| map 遍历顺序不保证稳定 | 需要稳定输出时,先提取并排序键 |
| map 元素不能取地址 | 不能把元素槽位当成永久稳定的地址 |
| nil map 可以读,写入会 panic | 初始化责任必须明确 |
| 赋值不会克隆 | 需要独立映射时显式复制;值的深浅复制仍要区分 |
| 无同步的并发读写不安全 | 加锁、建立所有权或使用合适的并发结构 |
这些行为分别来自规范与官方并发使用说明;元素可能因重哈希搬位置能帮助理解不可取址,但规则本身由语言规范定义,不是仅在触发扩容的那次操作才生效。文末反例实际编译失败:[12:1][13][14]
text
invalid operation: cannot take address of m["x"] (map index expression of type int)当前运行时会检测一部分并发 map 误用并终止程序,但它不是同步机制,也不是保证发现所有竞争的检测器。本文没有启动带数据竞争的 map 来碰运气触发错误。[9:6][15]
channel:缓冲队列之外,还要记录谁在等
当前 channel 值指向 hchan。其中有容量、当前排队元素数、环形缓冲区、读写位置、关闭标志,以及发送和接收等待队列;内部锁保护相关状态。channel 不是给一个 slice 加个长度字段就能实现的,因为它还要把数据传递与 G 的停放、唤醒配合起来。[16]
正在绘制图示…
查看图源代码
flowchart LR
S["发送方 G"] --> H["hchan<br/>容量、计数、读写位置、关闭状态"]
H -->|有接收者时可直接交付| R["接收方 G"]
H -->|可用缓冲槽| B["环形元素缓冲区"]
B --> R
H -->|发送暂不能完成| SQ["sendq:发送等待者"]
H -->|接收暂不能完成| RQ["recvq:接收等待者"]
SQ -->|条件满足后唤醒| S
RQ -->|条件满足后唤醒| R
图同时概括几种分支,不表示每次发送都会经过缓冲区。无缓冲 channel 要配对通信;有缓冲 channel 可以先把元素放进队列,缓冲满了还会阻塞。元素按值传递,因此发送 slice 时传递的是它的描述符,接收方仍可能访问同一底层数组。channel 的同步保证不自动把数组变成独占副本。[16:1][17][15:1]
关闭也不是清空队列:
go
ch := make(chan int, 1)
ch <- 7
close(ch)
v, ok := <-ch
fmt.Println("channel:", v, ok)
v, ok = <-ch
fmt.Println("channel:", v, ok)text
channel: 7 true
channel: 0 false先拿到缓冲里已经发送的 7,随后才读到零值与 false。向已关闭的 channel 发送会 panic;文末测试实际验证了这个反例。nil channel 的收发会一直阻塞,不能与“已经 close”的 channel 混在一起。[17:1][18]
channel 提供有文档定义的同步关系,所以很适合表达结果交付、任务退出和背压;代价包括同步、排队与可能的调度。容量是缓冲余量,不是“越大越快”的旋钮:它也会让更多尚未处理的数据和其中引用的对象继续存活。最后这条是由队列持有元素推得的容量规划建议,而不是本篇测出的吞吐结论。
interface:先看动态类型,再看动态值
语言层面,接口值可以用“动态类型、动态值”理解。nil 接口两者都没有。当前实现里,空接口 any 对应 eface 风格的类型指针与数据部分;有方法的接口对应 iface 风格的 itab 与数据部分。itab 记录具体类型、所实现的接口类型以及方法入口。[19][20]
正在绘制图示…
查看图源代码
flowchart LR
E["any 值"] --> ET["具体类型信息"]
E --> ED["数据部分"]
I["有方法的接口值"] --> Tab["itab"]
I --> ID["数据部分"]
Tab --> IT["接口类型 + 具体类型"]
Tab --> Methods["对应方法入口"]
数据部分不应该一概理解成“又堆分配了一个对象的地址”。有些类型使用直接表示,有些使用间接存储,编译器还会做装箱、去虚拟化、内联等处理。运行时布局解释动态分派如何实现,不给每次接口转换规定统一的分配数。[20:1][21]
最重要的实际问题是 typed nil:
go
var p *problem
var err error = p
fmt.Println("nil:", p == nil, err == nil)text
nil: true falsep 的值是 nil,但赋给 error 后,接口的动态类型是 *problem,所以 err 不是 nil 接口。错误返回函数如果本来想表达“没有错误”,应该返回 nil 接口,而不是把一个 nil 的具体指针装进去。这是语言语义,不依赖当前两个机器字的布局。[19:1][22]
接口复制同样按动态值的语义来理解:动态值如果是一个指针,两个接口副本可以继续引用同一个对象。需要可变状态隔离时,选择接口并不会替你做深拷贝。
string:只读字节序列,不是 rune 数组
当前 string 描述符保存数据指针和字节长度,没有 slice 那个容量字段。语言规定字符串内容不可修改;运行时和编译器因此可以共享只读数据,但应用不应靠指针身份判断两个相等字符串是否共用存储。[23][24]
go
s := "中A"
b := []byte(s)
b[0] = 'X'
fmt.Println("string:", len(s), utf8.RuneCountInString(s), s)text
string: 4 2 中A“中”占三个 UTF-8 字节,A 占一个,故 len 为 4,解码出的 rune 数为 2。修改转换后的字节切片没有改变原字符串;这是转换结果应保持的语义。编译器能在特定只读、非逃逸场景优化转换,不代表应用可以把可修改字节切片与 string 任意零拷贝互换。[24:1][25][26]
string 可以包含任意字节,不保证有效 UTF-8。s[i] 得到字节;for range 解码 rune;处理协议字节偏移和处理字符时,必须知道自己选的是哪层。子串还可能共享较大字符串的底层存储,要长期保留很小片段时可考虑 strings.Clone,但应先证实留存问题,而不是每次切片都多复制一次。[24:2][23:1][27]
把布局知识用在代码判断上
| 要判断的事 | 先问一句 |
|---|---|
| 修改是否会影响另一份变量 | 两份值是否仍然指向同一份可变数据? |
| 一次复制是否足够 | 元素里有没有指针、slice、map 等进一步引用? |
| 内存为何没释放 | 是否只保留了一个小视图,却留住了大数组或大字符串? |
| 某个操作会不会阻塞 | channel 的缓冲和等待者当前满足条件吗?不要用瞬时 len 代替同步 |
| nil 判断为什么不符预期 | 判断的是接口本身,还是它里面的具体指针? |
| 资料里的内部字段还能不能用 | 文档说的是哪个 Go 版本,哪些是规范,哪些只是实现? |
会读结构体定义只是起点。真正有用的是把共享关系、生命周期和并发协议画清楚,再用一个小程序核对具体行为。
依据与验证边界
本文运行了所有正文行为程序、map 不可取址的编译反例、关闭 channel 后发送的测试,以及普通测试、race、vet。运行时内部关系来自固定源码,不是通过 unsafe 猜字段。没有基准测量 Swiss Table、channel 或接口的性能,也没有把单次输出中未出现的 map 顺序当成顺序保证。
完整代码与复现
下面包含普通程序、测试和故意编译失败的反例。反例保留为 .txt,运行时临时复制成 Go 文件,结束后删除,避免干扰正常测试。
go.mod
go.mod
module example.com/runtime/structures
go 1.27main.go
代码块 · 41 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"slices"
"unicode/utf8"
"unsafe"
)
type problem struct{}
func (*problem) Error() string { return "problem" }
func main() {
base := []int{1, 2, 3, 4}
shared := append(base[:2], 9)
detached := append(base[:2:2], 8)
fmt.Println("slice:", base, shared, detached)
cloned := slices.Clone(base)
cloned[0] = 7
fmt.Println("clone:", base, cloned)
m := map[string]int{"x": 1}
alias := m
alias["x"] = 2
v, ok := m["missing"]
fmt.Println("map:", m["x"], v, ok)
ch := make(chan int, 1)
ch <- 7
close(ch)
v, ok = <-ch
fmt.Println("channel:", v, ok)
v, ok = <-ch
fmt.Println("channel:", v, ok)
var p *problem
var err error = p
fmt.Println("nil:", p == nil, err == nil)
s := "中A"
b := []byte(s)
b[0] = 'X'
fmt.Println("string:", len(s), utf8.RuneCountInString(s), s)
fmt.Println("value sizes:", unsafe.Sizeof(base), unsafe.Sizeof(m), unsafe.Sizeof(ch), unsafe.Sizeof(err), unsafe.Sizeof(s))
}main_test.go
go
package main
import "testing"
func TestSendClosedPanics(t *testing.T) {
c := make(chan int)
close(c)
defer func() {
if recover() == nil {
t.Fatal("send did not panic")
}
}()
c <- 1
}
func TestMapAssignmentAliases(t *testing.T) {
a := map[string]int{"x": 1}
b := a
b["x"] = 2
if a["x"] != 2 {
t.Fatal("map assignment did not share map")
}
}negative/mapaddr.go.txt
go
package main
func main(){m:=map[string]int{"x":1};_=&m["x"]}执行命令
bash
go version
go run .
go test -count=1 -v ./...
go test -count=1 -race -v ./...
go vet ./...
# 独立反例,预期编译失败。不要把它算作正常构建失败。
cp negative/mapaddr.go.txt negative/mapaddr.go
go build -o invalid-map negative/mapaddr.go
echo "$?"
rm negative/mapaddr.goGo 1.27.2 runtime/slice.go、runtime/chan.go、runtime/runtime2.go、runtime/string.go,本文所讨论的表示。 ↩︎ ↩︎
Go 官方:Faster Go maps with Swiss Tables,Go 1.24 引入的背景。 ↩︎ ↩︎
Go 1.27.2 unsafe.Sizeof,只计值本身,不计引用内容。 ↩︎
Go 1.27.2 runtime/slice.go,
slice、growslice、nextslicecap。 ↩︎ ↩︎ ↩︎Go 规范:Appending to and copying slices,容量够用时复用数组,不够时申请新数组。 ↩︎ ↩︎
Go 1.27.2 slices.Clone,逐元素赋值的浅拷贝。 ↩︎
Go 1.27.2 internal/runtime/maps/map.go,术语、探测、墓碑、目录增长、小 map 路径和并发写检查。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 internal/runtime/maps/group.go,控制字与
matchH2。 ↩︎ ↩︎Go 1.27.2 internal/runtime/maps/table.go,
rehash、grow、split与单表容量限制。 ↩︎ ↩︎Go 规范:Address operators,可取址表达式范围不含 map 元素。 ↩︎
Go 官方 maps 说明:Concurrency,并发访问规则。 ↩︎
Go 1.27.2 runtime/chan.go,
hchan、chansend、chanrecv与closechan。 ↩︎ ↩︎Go 1.27.2 internal/abi/iface.go、runtime/runtime2.go、internal/abi/type.go,接口与直接表示。 ↩︎ ↩︎
Go 1.27.2 runtime/iface.go,接口转换;编译器 devirtualize,去虚拟化。 ↩︎
Go 1.27.2 runtime/string.go,
stringStruct与转换实现。 ↩︎ ↩︎Go 1.27.2 unicode/utf8,UTF-8 解码与 rune 计数。 ↩︎
Go 1.27.2 strings.Clone,避免小子串保留大字符串的用途和成本。 ↩︎