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

大多数情况下它们都可以正常 appendrange。但 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 并发安全。