3 数据容器

3.1 数组与切片

因为切片比数组好用吗,也更安全,Go推荐使用slice而不是数组。本节内容比较了slice和数组的区别,也研究了slice的一些特定性质。

3.1.1 数组和切片有何异同

切片结构本质是对数组的封装,它描述了一个数组的片段。无论是数组还是切片都可以通过下标来访问单个元素。
数组是定长的,长度定义好之后,不能再更改。在Go语言中,数组不常见,因为其长度是类型的一部分,限制了表达能力,比如[2]int和[3]int就是不同类型。而切片更灵活,它可以动态扩容,且切片的类型和长度无关。

数组是一片连续的内存,切片实际上是一个结构体,包含三个字段:长度、容量、底层数组。

3.1.2 切片如何被截取

截取也是一种比较常见的创建 slice 的方法,可以从数组或者 slice 直接截取,需要指定起、止索引位置。基于已有 slice 创建新 slice 对象,被称为 reslice。新 slice 和老 slice 共用底层数组,新老 slice 对底层数组的更改都会影响到彼此。基于数组创建的新 slice 也是同样的效果:对数组或 slice 元素做的更改都会影响到彼此。

值得注意的是,新老 slice 或者新 slice 老数组互相影响的前提是两者共用底层数组,如果因为执行 append 操作使得新 slice 或老 slice 底层数组扩容,移动到了新的位置,两者就不会相互影响了。所以,问题的关键在于两者是否会共用底层数组。

截取操作采用如下方式:

data := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
slice := data[2:4:6] // data[low, high, max]

对 data 使用 3 个索引值,截取出新的 slice。这里 data 可以是数组或者 slice。low 是最低索引值,这里是闭区间,也就是说第一个元素是 data 位于 low 索引处的元素;而 high 和 max 则是开区间,表示最后一个元素只能是索引 high-1 处的元素,而最大容量则只能是索引 max-1 处的元素。要求:max >= high >= low。当 high == low 时,新 slice 为空。还有一点,high 和 max 必须在老数组或者老 slice 的容量(cap)范围内。

来看一个例子:运行下面的代码,输出是什么?

package main

import "fmt"

func main() {
    slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    s1 := slice[2:5]
    s2 := s1[2:6:7]
    s2 = append(s2, 100)
    s2 = append(s2, 200)
    s1[2] = 20
    fmt.Println(s1)
    fmt.Println(s2)
    fmt.Println(slice)
}

运行此段程序,得到如下输出:

[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

得到这样结果的原因是:

第一步:初始状态

  • s1 从 slice 索引 2(闭区间)到索引 5(开区间,元素真正取到索引 4),长度为 3,容量默认到数组结尾,为 8。所以 s1 指向底层数组索引 2~4 的元素:[2, 3, 4],容量为 8。
  • s2 从 s1 的索引 2(闭区间)到索引 6(开区间,元素真正取到索引 5),容量到索引 7(开区间,真正到索引 6),长度为 4,容量为 5。s2 指向底层数组索引 4~7 的元素:[4, 5, 6, 7],容量为 5。

此时,slice、s1 和 s2 三者的元素指向同一个底层数组。

第二步:追加 100
s2 = append(s2, 100)
此时 s2 容量为 5,长度为 4,容量刚好够(还有 1 个空位),直接追加。这会修改原始数组索引 8 位置的元素(从 8 改为 100)。这一改动,数组和 s1 都可以看得到。

第三步:追加 200
s2 = append(s2, 200)
此时 s2 容量已满(长度=容量=5),容量不够用,需要进行扩容。于是,s2 “另起炉灶”,将原来的元素复制到新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量扩大为原始容量的 2 倍,也就是 10。此时 s2 的底层数组已经和原来的 slice、s1 没有关系了。

第四步:修改 s1
s1[2] = 20
这次操作只会影响原始数组相应位置的元素(将原来索引 4 位置的 4 改为 20),影响不到 s2 了,因为它已经“远走高飞”了。

最后执行打印操作

  • 打印 s1 的时候,只会打印出 s1 长度以内的元素(长度为 3),所以打印 [2, 3, 20],虽然它的底层数组不止 3 个元素。
  • 打印 s2 的时候,打印出扩容后的新底层数组中的元素:[4, 5, 6, 7, 100, 200]
  • 打印 slice 的时候,原始底层数组已被多次修改:索引 2~4 是 [2, 3, 20],索引 5~7 是 [5, 6, 7],索引 8 被 s2 第一次 append 改成了 100,索引 9 还是原来的 9,所以最终结果是 [0, 1, 2, 3, 20, 5, 6, 7, 100, 9]

切片的容量增长机制

一般都是在向切片追加了元素之后,由于容量不足,才会引起扩容。向切片追加元素调用的是 append 函数,append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以在切片后面追加“...”符号直接传入切片里所有的元素。append 函数的返回值是一个新的切片,Go 语言的编译器不允许调用了 append 函数后不使用返回值。

使用 append 函数可以向 slice 追加元素,实际上是往底层数组相应的位置放置要追加的元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,就不能再继续放置新的元素了。这时,slice 会整体迁移到新的位置,并且新底层数组的长度也会增加,使得可以继续放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量需要预留一定的 buffer,否则每次添加元素都会发生迁移,成本太高。

网络上流传的说法是:当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;当原 slice 容量超过 1024,新 slice 容量变成原来的 1.25 倍。但这个说法并不准确。通过实际代码测试可以发现,当老 s 容量小于 1024 的时候,新 s 容量的确是老 s 的 2 倍;但当老 s 容量大于等于 1024 的时候,情况就有变化了。例如向 s 中添加元素 1280,老 s 的容量为 1280,新 s 的容量则变成了 1696,并不是 1.25 倍的关系(1696/1280=1.325);添加完 1696 后,新的容量 2304 也不是 1696 的 1.25 倍(2304/1696=1.358)。

要弄清真实的扩容规律,需要深入 Go 源码。向 s 追加元素时,若容量不够,会调用 growslice 函数。growslice 函数的参数依次是元素的类型、老 slice、新 slice 要求的最小容量。在 growslice 函数中,首先计算 newcap:如果传入的 cap(所需最小容量)大于老容量的 2 倍,则 newcap 直接取 cap;否则,如果老 slice 的长度小于 1024,newcap 取老容量的 2 倍;如果老 slice 的长度大于等于 1024,则循环执行 newcap += newcap/4,直到 newcap 大于等于所需容量。但这不是最终结果,代码的后半部分还会对 newcap 进行内存对齐,因为 Go 的内存分配器会按照预先定义好的 size class 来分配内存。经过 roundupsize 函数的内存对齐后,newcap 会向上取整到某个预定义的数值,最终的新容量可能大于等于老容量的 2 倍或 1.25 倍。例如,当传入 size 为 40 时,经过内存对齐后得到 48,除以元素大小 8,最终新容量为 6,而不是简单的翻倍。

看一个例子来理解 append 的行为:

s := []int{5}
s = append(s, 7)    // s 扩容,容量变为 2,[5,7]
s = append(s, 9)    // s 扩容,容量变为 4,[5,7,9]
x := append(s, 11)  // s 的底层数组还有空间,直接追加,底层数组变成 [5,7,9,11],s 不变仍为 [5,7,9],x 为 [5,7,9,11]
y := append(s, 12)  // 同样直接在 s 的底层数组索引 3 处追加,底层数组变成 [5,7,9,12],x 和 y 都指向这个数组,所以 x 也变成 [5,7,9,12]

最终输出:[5 7 9] [5 7 9 12] [5 7 9 12]。这里的关键是 append 返回的是新切片,且如果容量足够就不会扩容。

再来看另一个例子:

s := []int{1,2}
s = append(s,4,5,6)
fmt.Printf("len=%d, cap=%d", len(s), cap(s))

输出结果是 len=5, cap=6。这是因为原 s 容量为 2,append 3 个元素后所需最小容量为 5,而原容量的 2 倍是 4,小于 5,所以 newcap 直接取 5。经过内存对齐,实际分配 48 字节,对应 6 个 int 元素,最终容量为 6。

最后,关于向 nil 切片追加元素:答案是肯定的。向 nil 切片或空切片执行 append 操作时,都会调用 growslice 函数来使底层数组进行扩容,最终调用 mallocgc 向 Go 的内存管理器申请一块内存,再赋给原来的 nil 切片或空切片。

3.2 散列表map

线上环境和 map相关的常见问题就是并发读写导致的 panic,这背后的考虑和代码实现都在本节进行探讨。

维基百科里这样定义 map:在计算机科学里,被称为相关数组、map、符号表或者字典,它是由一组 <key, value> 对组成的抽象数据结构,并且同一个 key 只会出现一次,与 map 相关的操作主要是增删查改。map 的设计也被称为“The dictionary problem”,最主要的数据结构有两种:哈希查找表和搜索树,Go 语言采用的是哈希查找表,并且使用链表法解决哈希冲突。

在源码中,表示 map 的结构体是 hmap,包含 count(元素个数)、B(buckets 数组长度的对数)、buckets(指向 buckets 数组的指针)、oldbuckets(扩容时指向老 buckets 的指针)等字段。buckets 数组的长度为 2^B,每个 bucket(bmap)里面最多装 8 个 <key,value> 对,key 经过哈希计算后得到 64 个 bit 位,用最后 B 个 bit 位决定落入哪个 bucket,再用高 8 位决定在 bucket 中的具体槽位;当发生哈希冲突时,bucket 内部 8 个槽位填满后,会通过 overflow 指针连接新的 bucket 形成链表。创建 map 底层调用 makemap 函数,返回的是 *hmap 指针,这导致 map 作为函数参数时,函数内部对 map 的操作会影响实参,而 slice 作为参数时则不会(因为 slice 传的是结构体副本)。

key 的定位过程通过 mapaccess 系列函数完成,核心是两层循环:外层遍历 bucket 和 overflow bucket 链表,内层遍历单个 bucket 的 8 个槽位,通过 tophash 快速匹配,找到后根据偏移公式定位 key 和 value。map 的赋值过程调用 mapassign 函数,同样先检查并发写标志,然后定位插入位置,如果 key 已存在则更新,否则插入新 key,当装载因子超过 6.5 或 overflow bucket 数量过多时会触发扩容。map 的删除过程调用 mapdelete 函数,找到对应位置后对 key 和 value 进行清零,并将 tophash 置为 emptyOne 或 emptyRest。

map 的扩容分为两种情况:当装载因子超过 6.5 时,将 B 加 1,bucket 总数翻倍,key 会重新哈希并分裂到两个新 bucket 中(X part 和 Y part);当 overflow bucket 数量过多时,进行等量扩容,bucket 数量不变,但重新排列使 key 更紧凑。扩容是渐进式的,每次最多搬迁 2 个 bucket,在 mapassign 和 mapdelete 函数中调用 growWork 触发搬迁,搬迁过程中 oldbuckets 指向老数组,已搬迁的 bucket 的 tophash 会被标记为 evacuatedX 或 evacuatedY。

map 的遍历过程调用 mapiterinit 和 mapiternext 函数,由于扩容过程中存在新老 bucket 共存的情况,遍历时需要处理未搬迁的老 bucket:如果当前遍历的新 bucket 对应的老 bucket 未搬迁,则要从老 bucket 中找出将来会裂变到当前新 bucket 的那些 key。同时,map 的遍历起始位置是随机的(随机 bucket 序号和随机 cell 序号),这是为了强调遍历结果无序的特性,避免程序员依赖固定顺序。

map 不是线程安全的,在查找、赋值、遍历、删除过程中都会检测写标志,一旦发现并发写会直接 panic。float 类型可以作为 key,但由于浮点数精度问题以及 NaN 的特殊性(NaN != NaN,且哈希函数对 NaN 会加入随机数),会导致一些诡异的现象,因此慎用。Go 语言中读取 map 有两种语法:不带 comma 的返回 key 对应的零值,带 comma 的额外返回一个 bool 表示 key 是否存在,这两种语法在编译时会分别对应 mapaccess1 和 mapaccess2 函数。map 的 key 必须是可比较的类型,slice、map、function 不能作为 key。无法对 map 的元素取地址,因为扩容时元素位置会改变。可以在同一个协程内边遍历边删除 map 中的元素,这是语言规范允许的。比较两个 map 是否相等需要遍历所有 key-value 对进行深度比较,不能直接用 == 操作符。

posted @ 2026-03-30 11:02  cyusouyiku  阅读(1)  评论(0)    收藏  举报