Golang集合
Go 常用集合包括数组、切片和 map。数组长度固定,切片长度可变,map 用于键值映射。实际项目中最常用的是 slice 和 map,但它们也最容易出坑:slice 共享底层数组、append 扩容、截取导致大数组不能释放、map 遍历无序、map 并发写 panic。
学习集合不能只会语法,要理解它们的内存结构和工程边界。
学习目标
学完本页你应该能回答:
- 数组和 slice 的区别是什么。
- slice 的指针、长度、容量分别表示什么。
append什么时候复用底层数组,什么时候扩容。- 为什么切片截取会互相影响。
- 为什么小切片可能导致大数组无法释放。
- map 的 key 有什么要求,为什么遍历无序。
- map 为什么不是并发写安全的。
- 生产中 slice/map 怎么避免内存和并发问题。
数组、slice、map 对比
| 类型 | 特点 | 是否常用 | 典型场景 |
|---|---|---|---|
| 数组 | 长度固定,长度是类型的一部分,值类型 | 较少直接用 | 固定长度缓冲、底层存储 |
| slice | 长度可变,是对底层数组的视图 | 非常常用 | 列表、批量数据、结果集 |
| map | key-value 映射,哈希表思想,无序 | 非常常用 | 字典、缓存、去重、索引 |
示例:
var arr [3]int = [3]int{1, 2, 3}
var nums []int = []int{1, 2, 3}
scores := map[string]int{"Tom": 90}数组 [3]int 和 [4]int 是不同类型。slice []int 不包含长度在类型里。
数组是值类型
数组赋值会复制整个数组。
a := [3]int{1, 2, 3}
b := a
b[0] = 100
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]因为数组是值类型,函数传参也会复制数组:
func change(a [3]int) {
a[0] = 100
}大数组复制成本高,所以实际项目里更常使用 slice。
slice 结构
slice 本身不是数组,它是一个很小的结构,包含:
- 指向底层数组的指针。
- 当前长度
len。 - 当前容量
cap。
flowchart TD
A["slice header"] --> B["array 指针"]
A --> C["len 长度"]
A --> D["cap 容量"]
B --> E["底层数组"]示例:
nums := make([]int, 2, 5)
fmt.Println(len(nums)) // 2
fmt.Println(cap(nums)) // 5len 表示当前可访问元素数量,cap 表示从起始位置到底层数组末尾还能容纳多少元素。
创建 slice
常见方式:
var a []int // nil slice
b := []int{} // 空 slice
c := []int{1, 2, 3} // 字面量
d := make([]int, 0, 10) // len=0 cap=10
e := make([]int, 3) // len=3 cap=3nil slice 和空 slice:
var a []int
b := []int{}
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(len(a), len(b)) // 0 0大多数情况下它们都可以正常 append 和 range。但 JSON 序列化时可能不同:nil slice 可能变成 null,空 slice 可能变成 []。
append 执行流程
append 必须接收返回值:
nums = append(nums, 4)原因是 append 可能返回一个指向新底层数组的 slice。
flowchart TD
A["append 元素"] --> B{"cap 是否足够"}
B -- "足够" --> C["复用原底层数组"]
C --> D["写入新元素"]
D --> E["返回 len 增加的新 slice"]
B -- "不足" --> F["分配更大数组"]
F --> G["复制旧元素"]
G --> H["写入新元素"]
H --> I["返回指向新数组的 slice"]示例:
nums := make([]int, 0, 2)
nums = append(nums, 1, 2)
fmt.Println(len(nums), cap(nums)) // 2 2
nums = append(nums, 3)
fmt.Println(len(nums), cap(nums)) // 3 可能是 4 或其他增长结果不要依赖具体扩容倍数。Go 运行时可能根据容量大小采用不同增长策略,版本之间也可能调整。工程上只需要知道:容量不够会分配新数组并复制旧数据。
slice 共享底层数组
切片表达式会产生新 slice,但底层数组可能共享。
nums := []int{1, 2, 3, 4}
sub := nums[1:3]
sub[0] = 20
fmt.Println(nums) // [1 20 3 4]
fmt.Println(sub) // [20 3]结构:
flowchart TD
A["nums slice"] --> C["底层数组 1 2 3 4"]
B["sub slice"] --> C如果不希望互相影响,要复制:
subCopy := make([]int, len(sub))
copy(subCopy, sub)Go 1.21+ 可以使用 slices.Clone,但如果要兼容旧版本,make + copy 最通用。
append 共享数组的坑
base := []int{1, 2, 3, 4}
a := base[:2]
b := append(a, 100)
fmt.Println(base) // [1 2 100 4]
fmt.Println(b) // [1 2 100]因为 a 的容量还够,append 复用了 base 的底层数组,所以修改了 base。
如果希望 append 不影响原数组,可以限制容量:
a := base[:2:2] // len=2 cap=2
b := append(a, 100)三下标切片 s[i:j:k] 中,长度是 j-i,容量是 k-i。
小切片导致大数组无法释放
常见问题:从一个大切片中截取一小段长期保存。
func getHeader(data []byte) []byte {
return data[:100]
}如果 data 是 100MB,返回的 100 字节小切片仍然引用同一个底层数组,导致 100MB 无法被 GC 回收。
正确做法:
func getHeader(data []byte) []byte {
header := make([]byte, 100)
copy(header, data[:100])
return header
}排查内存高时,要注意这种“看起来只保存了小切片,实际引用了大数组”的情况。
slice 删除元素
删除第 i 个元素:
nums = append(nums[:i], nums[i+1:]...)如果元素是指针或大对象,为了帮助 GC,可以清空尾部:
copy(nums[i:], nums[i+1:])
nums[len(nums)-1] = nil
nums = nums[:len(nums)-1]这适用于 []*User 这类切片。否则底层数组尾部仍可能引用对象,影响 GC。
map 基本使用
scores := map[string]int{
"Tom": 90,
"Jerry": 80,
}
scores["Bob"] = 70
score, ok := scores["Tom"]
if ok {
fmt.Println(score)
}
delete(scores, "Tom")读取不存在的 key 会返回 value 类型的零值:
score := scores["NotExist"] // 0所以需要用双返回值判断 key 是否存在:
score, ok := scores["NotExist"]map key 要求
map key 必须是可比较类型。
可以做 key:
- string。
- int、uint、float、bool。
- pointer。
- array。
- struct,前提是字段都可比较。
不能做 key:
- slice。
- map。
- function。
原因是 map 底层需要根据 key 计算哈希并判断相等,不可比较类型无法直接作为 key。
map 无序遍历
for k, v := range scores {
fmt.Println(k, v)
}map 遍历顺序不固定,不能依赖顺序。Go 运行时故意让遍历顺序不稳定,避免开发者写出依赖 map 顺序的代码。
如果需要稳定顺序:
keys := make([]string, 0, len(scores))
for k := range scores {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, scores[k])
}map 底层思想
map 可以理解成哈希表。它通过 key 的哈希值定位桶,再在桶中查找具体 key。
flowchart TD
A["key"] --> B["计算 hash"]
B --> C["定位 bucket"]
C --> D["在 bucket 中比较 key"]
D --> E["找到 value"]这也是为什么 map 平均查询很快,通常接近 O(1)。但哈希冲突、扩容、数据量大时,仍然可能有性能波动。
map 并发安全
普通 map 不是并发写安全的。
m := map[int]int{}
go func() { m[1] = 1 }()
go func() { m[2] = 2 }()可能出现:
fatal error: concurrent map writes判断规则:
flowchart TD
A["多个 goroutine 访问 map"] --> B{"是否有写操作"}
B -- "没有,纯只读" --> C["通常安全"]
B -- "有写操作" --> D["必须同步保护"]
D --> E["Mutex/RWMutex"]
D --> F["sync.Map"]
D --> G["channel 串行化"]Mutex 示例:
type SafeCounter struct {
mu sync.RWMutex
m map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key]++
}
func (c *SafeCounter) Get(key string) int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.m[key]
}sync.Map 适合读多写少、key 集合相对稳定、缓存类场景。普通业务 map 加锁通常更清晰。
商业场景:采集结果去重和聚合
采集 Agent 拉取医院数据时,常见需求:
- 用 slice 保存批次结果。
- 用 map 按资产编码去重。
- 用 map 统计来源系统数量。
type Asset struct {
Code string
Name string
SourceSystem string
}
func Deduplicate(assets []Asset) []Asset {
seen := make(map[string]struct{}, len(assets))
result := make([]Asset, 0, len(assets))
for _, asset := range assets {
if _, ok := seen[asset.Code]; ok {
continue
}
seen[asset.Code] = struct{}{}
result = append(result, asset)
}
return result
}map[string]struct{} 常用于集合,因为空结构体不占额外数据空间,表达“只关心是否存在”。
线上排查
内存高
重点看:
- 是否一次性把大文件读入 slice。
- 是否小 slice 长期引用大数组。
- 是否 map 只增不删。
- 是否缓存没有过期策略。
- pprof heap 中是否有大对象由 slice/map 持有。
数据竞争
运行:
go test -race ./...如果报告 map 或 slice 并发访问,要检查:
- 是否多个 goroutine 写 map。
- 是否一个 goroutine append slice,另一个 goroutine 读。
- 是否需要 Mutex、channel 或复制快照。
map 遍历顺序导致测试不稳定
如果测试依赖 map 输出顺序,可能本地偶尔通过、CI 偶尔失败。解决方式是排序 key 后再断言。
常见坑
| 问题 | 后果 | 正确做法 |
|---|---|---|
| append 后不接返回值 | 结果丢失或仍引用旧 header | s = append(s, x) |
| 子切片修改原切片 | 数据被意外修改 | 必要时 copy |
| 小切片引用大数组 | 内存无法释放 | copy 出独立切片 |
| 删除指针切片不清尾部 | 对象仍被引用 | 清 nil 后缩短 |
| 依赖 map 遍历顺序 | 输出不稳定 | 排序 key |
| 并发写 map | panic 或数据竞争 | 加锁或 sync.Map |
| 不区分 key 不存在和零值 | 业务判断错误 | 使用 value, ok |
面试标准回答
数组和 slice 有什么区别?
数组长度固定,长度是类型的一部分,数组是值类型,赋值和传参会复制整个数组。slice 是对底层数组的视图,包含指针、长度和容量,长度可变,append 可能复用底层数组,也可能扩容分配新数组。
slice append 扩容过程是什么?
append 时如果容量足够,会复用原底层数组并返回 len 增加的新 slice;如果容量不足,会分配更大的新数组,复制旧元素,再追加新元素并返回新 slice。所以 append 后必须接收返回值。
slice 为什么会有共享底层数组问题?
切片表达式产生的新 slice 通常仍指向原底层数组,所以修改子切片可能影响原切片。append 在容量足够时也会写回原数组。需要隔离时使用 make + copy。
map 为什么不是并发安全的?
map 底层有桶、哈希、扩容等内部状态,并发写会破坏这些状态,所以多个 goroutine 同时写 map 可能触发 fatal error: concurrent map writes。并发场景要用 Mutex、RWMutex、sync.Map 或 channel 串行化。
关联知识点
小结
Go 集合的重点是理解内存结构和工程边界。数组是固定长度值类型,slice 是底层数组视图,map 是哈希表思想。写生产代码时要特别注意 slice 共享数组、append 扩容、小切片持有大数组、map 无序遍历和 map 并发安全。
