Skip to content

Golang集合 ​

Go 常用集合包括数组、切片和 map。数组长度固定,切片长度可变,map 用于键值映射。实际项目中最常用的是 slice 和 map,但它们也最容易出坑:slice 共享底层数组、append 扩容、截取导致大数组不能释放、map 遍历无序、map 并发写 panic。

学习集合不能只会语法,要理解它们的内存结构和工程边界。

学习目标 ​

学完本页你应该能回答:

  1. 数组和 slice 的区别是什么。
  2. slice 的指针、长度、容量分别表示什么。
  3. append 什么时候复用底层数组,什么时候扩容。
  4. 为什么切片截取会互相影响。
  5. 为什么小切片可能导致大数组无法释放。
  6. map 的 key 有什么要求,为什么遍历无序。
  7. map 为什么不是并发写安全的。
  8. 生产中 slice/map 怎么避免内存和并发问题。

数组、slice、map 对比 ​

类型特点是否常用典型场景
数组长度固定,长度是类型的一部分,值类型较少直接用固定长度缓冲、底层存储
slice长度可变,是对底层数组的视图非常常用列表、批量数据、结果集
mapkey-value 映射,哈希表思想,无序非常常用字典、缓存、去重、索引

示例:

go
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 不包含长度在类型里。

数组是值类型 ​

数组赋值会复制整个数组。

go
a := [3]int{1, 2, 3}
b := a
b[0] = 100

fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]

因为数组是值类型,函数传参也会复制数组:

go
func change(a [3]int) {
    a[0] = 100
}

大数组复制成本高,所以实际项目里更常使用 slice。

slice 结构 ​

slice 本身不是数组,它是一个很小的结构,包含:

  1. 指向底层数组的指针。
  2. 当前长度 len。
  3. 当前容量 cap。
mermaid
flowchart TD
    A["slice header"] --> B["array 指针"]
    A --> C["len 长度"]
    A --> D["cap 容量"]
    B --> E["底层数组"]

示例:

go
nums := make([]int, 2, 5)
fmt.Println(len(nums)) // 2
fmt.Println(cap(nums)) // 5

len 表示当前可访问元素数量,cap 表示从起始位置到底层数组末尾还能容纳多少元素。

创建 slice ​

常见方式:

go
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=3

nil slice 和空 slice:

go
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 必须接收返回值:

go
nums = append(nums, 4)

原因是 append 可能返回一个指向新底层数组的 slice。

mermaid
flowchart TD
    A["append 元素"] --> B{"cap 是否足够"}
    B -- "足够" --> C["复用原底层数组"]
    C --> D["写入新元素"]
    D --> E["返回 len 增加的新 slice"]
    B -- "不足" --> F["分配更大数组"]
    F --> G["复制旧元素"]
    G --> H["写入新元素"]
    H --> I["返回指向新数组的 slice"]

示例:

go
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,但底层数组可能共享。

go
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]

结构:

mermaid
flowchart TD
    A["nums slice"] --> C["底层数组 1 2 3 4"]
    B["sub slice"] --> C

如果不希望互相影响,要复制:

go
subCopy := make([]int, len(sub))
copy(subCopy, sub)

Go 1.21+ 可以使用 slices.Clone,但如果要兼容旧版本,make + copy 最通用。

append 共享数组的坑 ​

go
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 不影响原数组,可以限制容量:

go
a := base[:2:2] // len=2 cap=2
b := append(a, 100)

三下标切片 s[i:j:k] 中,长度是 j-i,容量是 k-i。

小切片导致大数组无法释放 ​

常见问题:从一个大切片中截取一小段长期保存。

go
func getHeader(data []byte) []byte {
    return data[:100]
}

如果 data 是 100MB,返回的 100 字节小切片仍然引用同一个底层数组,导致 100MB 无法被 GC 回收。

正确做法:

go
func getHeader(data []byte) []byte {
    header := make([]byte, 100)
    copy(header, data[:100])
    return header
}

排查内存高时,要注意这种“看起来只保存了小切片,实际引用了大数组”的情况。

slice 删除元素 ​

删除第 i 个元素:

go
nums = append(nums[:i], nums[i+1:]...)

如果元素是指针或大对象,为了帮助 GC,可以清空尾部:

go
copy(nums[i:], nums[i+1:])
nums[len(nums)-1] = nil
nums = nums[:len(nums)-1]

这适用于 []*User 这类切片。否则底层数组尾部仍可能引用对象,影响 GC。

map 基本使用 ​

go
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 类型的零值:

go
score := scores["NotExist"] // 0

所以需要用双返回值判断 key 是否存在:

go
score, ok := scores["NotExist"]

map key 要求 ​

map key 必须是可比较类型。

可以做 key:

  1. string。
  2. int、uint、float、bool。
  3. pointer。
  4. array。
  5. struct,前提是字段都可比较。

不能做 key:

  1. slice。
  2. map。
  3. function。

原因是 map 底层需要根据 key 计算哈希并判断相等,不可比较类型无法直接作为 key。

map 无序遍历 ​

go
for k, v := range scores {
    fmt.Println(k, v)
}

map 遍历顺序不固定,不能依赖顺序。Go 运行时故意让遍历顺序不稳定,避免开发者写出依赖 map 顺序的代码。

如果需要稳定顺序:

go
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。

mermaid
flowchart TD
    A["key"] --> B["计算 hash"]
    B --> C["定位 bucket"]
    C --> D["在 bucket 中比较 key"]
    D --> E["找到 value"]

这也是为什么 map 平均查询很快,通常接近 O(1)。但哈希冲突、扩容、数据量大时,仍然可能有性能波动。

map 并发安全 ​

普通 map 不是并发写安全的。

go
m := map[int]int{}
go func() { m[1] = 1 }()
go func() { m[2] = 2 }()

可能出现:

text
fatal error: concurrent map writes

判断规则:

mermaid
flowchart TD
    A["多个 goroutine 访问 map"] --> B{"是否有写操作"}
    B -- "没有,纯只读" --> C["通常安全"]
    B -- "有写操作" --> D["必须同步保护"]
    D --> E["Mutex/RWMutex"]
    D --> F["sync.Map"]
    D --> G["channel 串行化"]

Mutex 示例:

go
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 拉取医院数据时,常见需求:

  1. 用 slice 保存批次结果。
  2. 用 map 按资产编码去重。
  3. 用 map 统计来源系统数量。
go
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{} 常用于集合,因为空结构体不占额外数据空间,表达“只关心是否存在”。

线上排查 ​

内存高 ​

重点看:

  1. 是否一次性把大文件读入 slice。
  2. 是否小 slice 长期引用大数组。
  3. 是否 map 只增不删。
  4. 是否缓存没有过期策略。
  5. pprof heap 中是否有大对象由 slice/map 持有。

数据竞争 ​

运行:

bash
go test -race ./...

如果报告 map 或 slice 并发访问,要检查:

  1. 是否多个 goroutine 写 map。
  2. 是否一个 goroutine append slice,另一个 goroutine 读。
  3. 是否需要 Mutex、channel 或复制快照。

map 遍历顺序导致测试不稳定 ​

如果测试依赖 map 输出顺序,可能本地偶尔通过、CI 偶尔失败。解决方式是排序 key 后再断言。

常见坑 ​

问题后果正确做法
append 后不接返回值结果丢失或仍引用旧 headers = append(s, x)
子切片修改原切片数据被意外修改必要时 copy
小切片引用大数组内存无法释放copy 出独立切片
删除指针切片不清尾部对象仍被引用清 nil 后缩短
依赖 map 遍历顺序输出不稳定排序 key
并发写 mappanic 或数据竞争加锁或 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 并发安全。