Go语言设计与实现 学习笔记 第三章 数据结构(1)
3.1 数组
数组和切片是Go语言中常见的数据结构,很多刚刚使用Go的开发者往往会混淆这两个概念,数组作为最常见的集合在编程语言中是非常重要的,除了数组之外,Go语言引入了另一个概念——切片,切片与数组有一些类似,但是它们的不同之处导致使用上会产生巨大的差别。我们在这一节中会从Go语言的编译期间和运行时来介绍数组的底层实现原理,其中会包括数组的初始化、访问和赋值几种常见操作。
3.1.1 概述
数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问元素对应的存储地址,常见的数组大多都是一维的线性数组,而多维数组在数值和图形计算领域有比较常见的应用。
数组作为一种基本的数据类型,我们通常会从两个维度描述数组,我们首先需要描述数组中存储的元素类型,还需要描述数组最大能够存储的元素个数,在Go语言中我们往往会使用如下方式表示数组类型:
[10]int
[200]interface{}
与很多语言不同,Go语言中数组在初始化之后大小就无法改变,存储元素类型相同、但是大小不同的数组类型在Go语言看来也是完全不同的,只有两个条件都相同才是同一个类型。
// 创建新数组,Type参数表示元素类型,bound元素表示数组长度
func NewArray(elem *Type, bound int64) *Type {
if bound < 0 {
Fatalf("NewArray: invalid bound %v", bound)
}
// 创建一个新数组类型
t := New(TARRAY)
// 把数组类型和长度存到t中的Extra字段里,该字段类型为Array
t.Extra = &Array{Elem: elem, Bound: bound}
// NotInHeap方法用于检查数组是否在堆上分配,并将结果存入t
t.SetNotInHeap(elem.NotInHeap())
return t
}
编译期间的数组类型是由上述的cmd/compile/internal/types.NewArray函数生成的,类型Array包含两个字段,一个是元素类型Elem,另一个是数组的大小Bound,这两个字段共同构成了数组类型,而当前数组是否应该在堆栈中初始化也在编译期就确定了。
3.1.2 初始化
Go语言中的数组有两种不同的创建方式,一种是显式指定数组大小,另一种是使用[...]T
声明数组,Go语言会在编译期间对数组的大小进行推断:
arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}
上述两种声明方式在运行期间得到的结果是完全相同的,后一种声明方式在编译期间会被转换成前一种,这就是编译器对数组大小的推导,下面我们来介绍编译器的推导过程。
上限推导
两种不同的声明方式会导致编译器做出完全不同的处理,如果我们使用第一种方式[10]T
,那么变量的类型在编译进行到类型检查阶段就会被提取出来,随后会使用cmd/compile/internal/types.NewArray函数创建包含数组大小的Array类型。
当我们使用[...]T
的方式声明数组时,虽然在这一步也会创建一个Array类型Array{Elem: elem, Bound: -1}
,但是其中的数组大小上限会是-1,这里的-1只是一个占位符,编译器会在后面的cmd/compile/internal/gc.typecheckcomplit函数中对该数组的大小进行推导:
// 此函数用于检查复合字面值(composite literal)
func typecheckcomplit(n *Node) (res *Node) {
// ...
switch t.Etype {
// 处理数组和切片类型
case TARRAY, TSLICE:
var length, i int64
// 将n的列表字段转换为一个切片
nl := n.List.Slice()
// 计算切片的长度
for i2, l := range nl {
i++
if i > length {
length = i
}
}
// 如果t是一个变长数组(用...定义的数组),则设置数组长度
if t.IsDDDArray() {
t.SetNumElem(length)
}
}
}
这个删减后的cmd/compile/internal/gc.typecheckcomplit函数通过遍历元素的方式来计算数组中元素的数量。上述代码中的DDDArray指的就是使用[…]T声明的数组,因为声明这种数组时需要使用三个点(Dot),所以在编译器中被称作DDDArray。
所以我们可以看出[...]T{1, 2, 3}
和[3]T{1, 2, 3}
在运行时是完全等价的,[...]T
这种初始化方式也只是Go语言为我们提供的一种语法糖,当我们不想计算数组中的元素个数时就可以通过这种方法减少一些工作。
语句转换
对于一个由字面量组成的数组,根据数组元素数量的不同,编译器会在负责初始化字面量的cmd/compile/internal/gc.anylit函数中做两种不同的优化:
1.当元素数量小于等于4个时,会直接将数组中的元素放在栈上;
2.当数组元素大于4个时,会将数组中的元素放置到静态区并在运行时取出;
func anylit(n *Node, var_ *Node, init *Nodes) {
t := n.Type
switch n.Op {
case OSTRUCTLIT, OARRAYLIT:
if n.List.Len() > 4 {
// ...
}
fixedlit(inTnitFunction, initKindLocalCode, n, var_, init)
// ...
}
}
当数组的元素小于等于4个时,cmd/compile/internal/gc.fixedlit
会负责在函数编译之前将[3]{1, 2, 3}
转换成更原始的语句:
func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) {
// 定义一个splitnode函数,用于将赋值语句拆分为赋值语句的左方值和右方值
var splitnode func(*Node) (a *Node, value *Node)
// ...
for _, r := range n.List.Slice() {
a, value := splitnode(r)
// 创建一个赋值操作节点a,OSA表示赋值操作assignment operation
a = nod(OAS, a, value)
// 对赋值语句节点a进行类型检查
a = typecheck(a, ctxStmt)
switch kind {
// 处理静态初始化
case initKindStatic:
genAsStatic(a)
// 处理局部代码初始化
case initKindLocalCode:
a = orderStmtInPlace(a, map[string][]*Node{})
a = walkstmt(a)
init.Append(a)
}
}
}
当数组中元素的个数小于等于四个时,cmd/compile/internal/gc.fixedlit
函数接受的kind
是initKindLocalCode
,上述代码会将原有的初始化语句[3]int{1, 2, 3}
拆分成一个声明变量的表达式和几个赋值表达式,这些表达式会完成对数组的初始化:
var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3
但如果当前数组的元素大于4个,anylit
方法会先获取一个唯一的staticname
,然后调用cmd/compile/internal/gc.fixedlit
函数在静态存储区初始化数组中的元素并将临时变量赋值给当前的数组:
func anylit(n *Node, var_ *Node, init *Nodes) {
t := n.Type
switch n.Op {
case OSTRUCTLIT, OARRAYLIT:
if n.List.Len() > 4 {
vstat := staticname(t)
// 将静态变量设为已读
vstat.Name.SetReadonly(true)
fixedlit(inNonInitFunction, initKindStatic, n, vstat, init)
a := nod(OAS, var_, vstat)
a = typecheck(a, ctxStmt)
a = walkexpr(a, init)
init.Append(a)
break
}
// ...
}
}
假设我们在代码中初始化[]int{1, 2, 3, 4, 5}
数组,那么我们可以将上述过程理解成以下的伪代码:
var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
arr = statictmp_0
总结起来,如果数组中元素的个数小于等于4个,那么所有的变量会直接在栈上初始化,如果数组元素大于4个,变量就会在静态存储区初始化然后拷贝到栈上,这些转换后的代码会继续进入中间代码生成和机器码生成两个阶段,最后生成可执行的二进制文件。
3.1.3 访问和赋值
无论是在栈上还是静态存储区,数组在内存中其实就是一连串的内存空间,表示数组的方法就是一个指向数组开头的指针、数组中元素的数量以及数组中元素类型占的空间大小,如果我们不知道数组中元素的数量,访问时就可能发生越界,而如果不知道数组中元素类型的大小,就没办法知道应该一次取出多少字节的数据,如果没有这些信息,我们就无法知道这片连续的内存空间到底存储了什么数据:
数组访问越界是非常严重的错误,Go语言中对越界的判断是可以在编译期间由静态类型检查完成的,cmd/compile/internal/gc.typecheck1
函数会对访问数组的索引进行验证:
func typecheck1(n *Node, top int) (res *Node) {
switch n.Op {
case OINDEX:
ok |= ctxExpr
l := n.Left // array
r := n.Right // index
switch n.Left.Type.Etype {
case TSTRING, TARRAY, TSLICE:
// ...
if n.Right.Type != nil && !n.Right.Type.IsInteger() {
yyerror("non-integer array index %v", n.Right)
break
}
// 如果未进行边界检查且索引是常量
if !n.Bounded() && Isconst(n.Right, CTINT) {
x := n.Right.Int64()
if x < 0 {
yyerror("invalid array index %v (index must be non-negative)", n.Right)
} else if n.Left.Type.IsArray() && x >= n.Left.Type.NumElem() {
yyerror("invalid array index %v (out of bounds for %d-element array)", n.Right, n.Left.Type.NumElem())
}
}
}
// ...
}
}
1.访问数组的索引是非负整数时会直接报错——non-integer array index %v
;
2.访问数组的索引是负数时会直接报错——invalide array index %v (index must be non-negative)
;
3.访问数组的索引越界时会直接报错——invalid array index %v (out of bounds for %d-element array)
;
数组和字符串的一些简单越界错误都会在编译期间发现,比如我们直接使用整数或者常量访问数组,但是如果使用变量去访问数组或字符串时,编译器就无法发现对应的错误了,这时就需要Go语言运行时发挥作用了:
arr[4]: invalid array index 4 (out of bounds for 3-element array)
arr[i]: panic: runtime error: index out of range [4] with length 3
Go语言运行时在发现数组、切片、字符串的越界操作会由运行时的panicIndex
和runtime.goPanicIndex
函数触发程序的运行时错误并导致崩溃退出:
// 定义一个名为runtime.panicIndex的汇编函数
// 不允许栈分裂,且需要0字节栈空间(没有局部变量)和8字节参数空间
TEXT runtime.panicIndex(SB),NOSPLIT,$0-8
// 将寄存器AX中的值移动到栈帧(FP,栈顶指针)偏移量为0的位置,表示将第一个参数x存储到栈上
MOVL AX, x+0(FP)
// 将寄存器CX中的值移动到栈帧偏移量为4的位置,表示将第二个参数y存储到栈上
MOVL CX, y+4(FP)
// 跳转到runtime.goPanicIndex函数
// SB是静态基址寄存器,其中存放了一个地址,指向全局变量区,允许你访问编译时已知的全局变量和函数
JMP runtime.goPanicIndex(SB)
func goPanicIndex(x int, y int) {
// 发生panic前做一些检查,getcallerpc函数返回调用者的程序计数器(PC),用于记录出错的位置
panicCheck1(getcallerpc(), "index out of range")
// 触发一个运行时错误,boundsError结构体中存放错误信息
panic(boundsError{x: int64(x), signed: true, y: y, code: boundsIndex})
}
当数组的访问操作OINDEX
成功通过编译器的检查后,会被转换成几个SSA指令,假设我们有如下所示的Go语言代码,通过如下的方式进行编译会得到ssa.html
文件:
package check
func outOfRange() int {
arr := [3]int{1, 2, 3}
i := 4
elem := arr[i]
return elem
}
$ GOSSAFUNC=outOfRange go build array.go
dumped SSA to ./ssa.html
start
阶段生成的SSA代码就是优化之前的第一版中间代码,下面展示的是elem := arr[i]
对应的中间代码,在这段中间代码中我们发现Go语言为数组的访问操作生成了判断数组上限的指令IsInBounds
以及当条件不满足时触发程序崩溃的PanicBounds
指令:
b1:
// ...
// 获取局部变量arr的地址,arr的类型是*[3]int
v22 (6) = LocalAddr <*[3]int> {arr} v2 v20
// 检查v21(索引)是否在数组边界内(v11是数组的长度)
v23 (6) = IsInBounds <bool> v21 v11
// 如果v23是true,则跳转到b2,否则跳转到b3,
// likely表示此条件很可能是true,编译器可以据此进行优化
// 优化内容包括可提高分支预测准确性、将b2代码直接放到if后面确保局部性等
If v23 → b2 b3 (likely) (6)
b2: ← b1-
// 获取索引为v21的元素的指针
v26 (6) = PtrIndex <*int> v22 v21
// 复制v20的内存状态到v27,准备读取
v27 (6) = Copy <mem> v20
// 从v26指向的位置加载一个整数
v28 (6) = Load <int> v26 v27 (elem[int])
...
// 将值v30返回
Ret v30 (+7)
b3: ← b1-
// 复制v20的内存状态到v24,准备进行错误处理
v24 (6) = Copy <mem> v20
// 触发边界检查失败的painc
v25 (6) = PanicBounds <mem> [0] v21 v11 v24
// 终止程序执行,返回值为PanicBounds的返回
Exit v25 (6)
PanicBounds
指令最终会被转换成上面提到的panicIndex
函数,当数组下标没有越界时,编译器会先获取数组的内存地址和访问的下标,然后利用PtrIndex
计算出目标元素的地址,再使用Load
操作将指针中的元素加载到内存中。
当然只有当编译器无法对数组下标是否越界做出判断时才会加入PanicBounds
指令交给运行时判断,在使用字面量整数访问数组下标时就会生成非常简单的中间代码,当我们将上述代码中的arr[i]
改成arr[2]
时,就会得到如下所示的代码:
b1:
// ...
v21 (5) = LocalAddr <*[3]int> {arr} v2 v20
v22 (5) = PtrIndex <*int> v21 v14
v23 (5) = Load <int> v22 v20 (elem[int])
// ...
Go语言对于数组的访问还是有着比较多的检查的,它不仅会在编译期间提前发现一些简单的越界错误并插入用于检测数组上限的函数调用,而在运行期间这些插入的函数会负责保证不会发生越界错误。
数组的赋值和更新操作a[i]=2
也会在SSA生成期间计算出数组当前元素的内存地址,然后修改当前内存地址的内容,这些赋值语句会被转换成如下所示的SSA操作:
b1:
// ...
v21 (5) = LocalAddr <*[3]int> {arr} v2 v19
v22 (5) = PtrIndex <*int> v21 v13
v23 (5) = Store <mem> {int} v22 v20 v19
// ...
赋值的过程中会先确定目标数组的地址,再通过PtrIndex
获取目标元素的地址,最后使用Store
指令将数据存入地址中,从上面的这些SSA代码中我们可以看出无论是数组的寻址还是赋值都是在编译阶段完成的,没有运行时的参与。
3.1.4 小结
数组是Go语言中重要的数据结构,了解它的实现能够帮助我们更好地理解这门语言,通过对其实现的分析,我们知道了对数组的访问和赋值需要同时依赖编译器和运行时,它的大多数操作在编译期间都会转换成对内存的直接读写,在中间代码生成期间,编译器还会插入运行时方法panicIndex
方式发生越界错误。
3.2 切片
我们在上一节介绍的数组在Go语言中没那么常用,更常用的数据结构其实是切片,切片就是动态数组,它的长度并不固定,我们可以随意向切片中追加元素,而切片会在容量不足时自动扩容。
在Go语言中,切片类型的声明方式与数组有一些相似,由于切片的长度是动态的,所以声明时只需要指定切片中的元素类型:
[]int
[]interface{}
从切片的定义我们能推测出,切片在编译期间的生成的类型只会包含切片中的元素类型,即int
或者interface{}
等。cmd/compile/internal/types.NewSlice
就是编译期间用于创建Slice
类型的函数:
func NewSlice(elem *Type) *Type {
// 查看缓存中是否已经有对应的slice类型
if t := elem.Cache.slice; t != nil {
// 验证缓存一致性
if t.Elem() != elem {
Fatalf("elem mismatch")
}
return t
}
// 创建一个新的TSLICE类型
t := New(TSLICE)
// 指定Slice的元素类型
t.Extra = Slice{Elem: elem}
// 将t缓存
elem.Cache.slice = t
return t
}
上述方法返回的结构体TSLICE
中的Extra
字段是一个只包含切片内元素类型的Slice{Elem: elem}
结构,也就是说切片内元素的类型是在编译期间确定的,编译器确定了类型之后,会将类型存储在Extra
字段中帮助程序在运行时动态获取。
3.2.1 数据结构
编译期间的切片是Slice
类型的,但是在运行时切片由如下的SliceHeader
结构体表示,其中Data
字段是指向数组的指针,Len
表示当前切片的长度,而Cap
表示当前切片的容量,也就是Data
数组的大小:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
Data
作为一个指针指向的数组是一片连续的内存空间,这片内存空间可以用于存储切片中保存的全部元素,我们可以将切片理解成一片连续的内存空间加上长度与容量标识。
从上图我们会发现切片与数组的关系非常密切,切片引入了一个抽象层,提供了对数组中部分片段的引用,作为数组的引用,我们可以在运行区间修改它的长度,如果底层的数组长度不足就会触发扩容机制,切片中的数组就会发生变化,不过在上层看来切片是没有变化的,上层只需要与切片打交道不需要关心底层的数组变化。
我们在上一节介绍过,获取数组大小、对数组中的元素的读写在编译期间就已经进行了简化,由于数组的内存固定且连续,很多操作都会变成对内存的直接读写。但是切片是运行时才会确定内容的结构,所有的操作还需要依赖Go语言的运行时来完成,我们接下来会介绍一些常见操作的实现原理。
3.2.2 初始化
G语言中的切片有三种初始化方式:
1.通过下标的方式获得数组或切片的一部分;
2.使用字面量初始化新的切片;
3.使用关键字make创建切片;
arr[0:3] or slice[0:3]
slice := []int{1, 2, 3}
slice := make([]int 10)
使用下标
使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,arr[0:3]
或者slice[0:3]
会由编译器转换成OpSliceMake
操作,我们可以通过下面的代码验证一下:
// ch03/op_slice_make.go
package opslicemake
func newSlice() []int {
arr := [3]int{1, 2, 3}
slice := arr[0:1]
return slice
}
通过GOSSAFUNC
变量编译上述代码可得到如下的SSA中间代码,在中间代码生成的decompose builtin
阶段,slice := arr[0:1]
对应的部分为:
// 创建一个slice存入v26,v11是slice的底层数组的指针,v14是slice大小,v17是slice容量
v27 (+5) = SliceMake <[]int> v11 v14 v17
// name指令将变量名和其SSA表示对应起来,帮助开发人员理解和跟踪SSA中间代码中的变量
// 表明v11是一个指向数组的指针,数组类型为*[3]int
name &arr[*[3]int]: v11
// 表明v11是slice的底层数组指针
name slice.ptr[*int]: v11
// 表明v14是slice的长度
name slice.len[int]: v14
// 表明v17是slice的容量
name slice.cap[int]: v17
SliceMake
这个操作会接受三个参数创建新的切片,元素类型、数组指针、切片大小和容量,这也是我们在数据结构一节中提到的切片的几个字段。
字面量
当我们使用字面量[]int{1, 2, 3}
创建新的切片时,cmd/compile/internal/gc.slicelit
函数会在编译期间将它展开成如下所示的代码片段:
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]
1.根据切片中的元素数量对底层数组的大小进行推断并创建一个数组;
2.将这些字面量元素存储到初始化的数组中;
3.创建一个同样指向[3]int
类型的数组指针;
4.将静态存储区的数组vstat
赋值给vauto
指针所在的地址;
5.通过[:]
操作获取一个底层使用vauto
的切片;
第5步中的[:]
就是使用下标创建切片的方法,从这一点我们也能看出[:]
操作是创建切片最底层的一种方法。
关键字
如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成,但当我们使用make
关键字创建切片时,很多工作都需要运行时的参与;调用方必须在make
函数中传入一个切片的大小以及可选的容量,cmd/compile/internal/gc.typecheck1
会对参数进行校验:
// 对AST节点进行类型检查
func typecheck1(n *Node, top int) (res *Node) {
switch n.Op {
// ...
// 如果是make操作
case OMAKE:
// 提取参数列表
args := n.List.Slice()
i := 1
switch t.Etype {
// 如果是make slice
case TSLICE:
// 是否有长度参数
if i >= len(args) {
yyerror("missing len argument to make(%v)", t)
return n
}
// 获取长度和容量参数
l = args[i]
i++
var r *Node
if i < len(args) {
r = args[i]
}
// ...
// 检查长度是否大于容量
if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && L.vAL.u.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 {
yyerror("len larger than cap in make(%v)", t)
return n
}
// 改变节点操作类型,并将参数设为对应子节点
n.Left = l
n.Right = r
n.Op = OMAKESLICE
}
// ...
}
}
上述函数不仅会检查len
是否传入,还会保证传入的容量cap
一定大于等于len
,除了校验参数外,还会将OMAKE
节点转换成OMAKESLICE
,随后的中间代码生成阶段在cmd/compile/internal/gc.walkexpr
函数中的OMAKESLICE
分支依据以下条件对OMAKESLICE
进行转换:
1.切片大小和容量是否足够小;
2.切片是否发生了逃逸,当切片发生逃逸或非常大时,会在堆上初始化,我们需要runtime.makeslice
函数在堆上运行时初始化,如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4)
会被直接转换成如下所示代码:
var arr [4]int
n := arr[:3]
上述代码会初始化数组并且通过下标[:3]
来得到数组的切片,这两部分操作都会在编译阶段完成,编译器会在栈上或者静态存储区创建数组,[:3]
会被转换成前面的OpSliceMake
操作。
分析了主要由编译器处理的分支之后,我们回到用于创建切片的运行时函数runtime.makeslice
,这个函数的实现非常简单:
func makeslice(et *_type, len, cap int) unsafe.Pointer {
// 计算需要分配的内存大小,et.size是每个元素的大小,cap是slice容量
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
// 如果内存计算溢出、超出最大能分配的内存大小、slice大小不合法
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := mathMulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
// 分配内存
return mallocgc(mem, et, true)
}
它的主要工作是计算当前切片占用的内存空间并在堆上申请一片连续的内存,它使用如下方式计算占用的内存:内存空间 = 切片中元素大小 × 切片容量
。
虽然大多错误都能在编译期间被检查出来,但是在创建切片过程中如果发生了以下错误就会直接导致程序触发运行时错误并崩溃:
1.内存空间的大小发生了溢出;
2.申请的内存大于最大可分配内存;
3.传入的长度小于0或大于容量;
mallocgc是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在Go语言调度器里面的P结构中,而大于32KB的一些对象会在堆上初始化,我们会在后面的章节介绍Go语言的内存分配器,在这里就不展开分析了。
目前的runtime.makeslice会返回指向底层数组的指针,之前版本的Go语言中,数组指针、长度、容量会被合成一个slice结构并返回,但是从cmd/compile: move slice construction to callers of makeslice
(http://github.com/golang/go/commit/020a18c545bf49ffc087ca93cd238195d8dcc411#diff-d9238ca551e72b3a80da9e0da10586a4)这次提交后,构建结构体SliceHeader的工作就都交给runtime.makeslice的调用方处理了,这些调用方会在编译期间构建切片结构体:
func typecheck1(n *Node, top int) (res *Node) {
switch n.Op {
// ...
case OSLICEHEADER:
// 获取节点类型
t := n.Type
// 对n的左子节点进行类型检查
n.Left = typecheck(n.Left, ctxExpr)
// 对n的第一个和第二个参数进行类型检查
l := typecheck(n.List.First(), ctxExpr)
c := typecheck(n.List.Second(), ctxExpr)
// 将l和c转换成int
l = defaultlit(l, types.Types[TINT])
c = defaultlit(c, types.Types[TINT])
// 将进行过类型检查和类型转换后的参数重新设置回参数列表中
n.List.SetFirst(l)
n.List.SetSecond(c)
// ...
}
}
OSLICEHEADER操作会创建我们上面介绍过的结构体SliceHeader
,其中包含数组指针、切片长度和容量,它也是切片在运行时的表示:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
正是因为大多数对切片类型的操作并不需要直接操作原slice
结构体,所以SliceHeader
的编译时引入能够减少切片初始化时的少量开销,这个改动能减少~0.2%
的Go语言包大小并且能减少92个panicindex
调用,占整个Go语言二进制的~3.5%
。
3.2.3 访问元素
对切片常见的操作就是获取它的长度或容量,这两个不同的函数len
和cap
被Go语言编译器看成是两种特殊的操作,即OLEN
和OCAP
,它们会在SSA生成阶段被cmd/compile/internal/gc.expr
函数转换成OpSliceLen
和OpSliceCap
操作:
func (s *state) expr(n *Node) *ssa.Value {
switch n.Op {
case OLEN, OCAP:
switch {
// 如果是切片类型
case n.Left.Type.IsSlice():
op := ssa.OpSliceLen
if n.Op == OCAP {
op = ssa.OpSliceCap
}
// 将AST节点n转换为SSA形式
return s.newValue1(op. types.Types[TINT], s.expr(n.Left))
// ....
}
// ....
}
}
访问切片中的字段可能会触发decompose builtin
阶段的优化,len(slice)
或者cap(slice)
在一些情况下会被直接替换成切片的长度或容量,不需要运行时从切片结构中获取:
// 以下是Go编译器的优化规则,匹配箭头左边内容,然后优化为箭头右边内容
(SlicePtr (SliceMake ptr _ _)) -> ptr
(SliceLen (SliceMake _ len _)) -> len
(SliceCap (SliceMake _ _ cap)) -> cap
除了获取切片的长度和容量外,访问切片中元素使用的OINDEX
操作也会在中间代码生成期间转换成对地址的直接访问:
func (s *state) expr(n *Node) *ssa.Value {
switch n.Op {
case OINDEX:
switch {
case n.Left.Type.IsSlice():
// 返回索引表达式的地址
p := s.addr(n, false)
// 加载指定类型(第一个参数)的值(第二个参数)
return s.load(n.Left.Type.Elem(), p)
// ...
}
// ...
}
}
切片的基本操作都是在编译期间完成的,除了访问切片的长度、容量或其中的元素外,使用range
遍历切片时也会在编译期间转换成形式更简单的代码,我们会在后面的range
关键字一节介绍使用range
遍历切片的过程。
3.2.4 追加和扩容
向切片中追加元素应该是最常见的切片操作,在Go语言中我们会使用append
关键字向切片追加元素,中间代码生成阶段的cmd/compile/internal/gc.state.append
方法会拆分append
关键字,该方法追加元素会根据返回值是否赋值回原变量,分别进入两种流程,如果append
返回的“新切片”不需要赋值回原有的变量,就会进入如下处理流程:
// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
ptr, len, cap = growslice(slice, newlen)
newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
我们会先对切片结构体进行解构获取它的数组指针、大小、容量,如果在追加元素后切片的大小大于容量,那么就会调用runtime.growslice
对切片进行扩容并将新的元素依次加入切片;如果append
后的切片会覆盖原切片,即slice = append(slice, 1, 2, 3)
,cmd/compile/internal/gc.state.append
就会使用另一种方式改写关键字:
// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
newptr, len, newcap = growslice(slice, newlen)
vardef(a)
*a.cap = newcap
*a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
是否覆盖原变量的逻辑其实差不多,最大的区别在于最后的结果是不是赋值原有的变量,如果我们选择覆盖原有的变量,也不需要担心切片的拷贝,因为Go语言的编译器已经对这种情况做了优化。
到这里我们已经通过append
关键字被转换到的控制流了解了在切片容量足够时如何向切片中追加元素,但是当切片的容量不足时就会调用runtime.growslice
函数为切片扩容,扩容就是为切片分配一块新的内存空间并将原切片的元素全部拷贝过去,我们分几部分分析该方法:
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
在分配内存空间前需要先确定新的切片容量,Go语言根据切片的当前容量选择不同的策略进行扩容:
1.如果期望容量大于当前容量的两倍就使用期望容量;
2.如果当前切片容量小于1024就会将容量翻倍;
3.如果当前切片容量大于1024就会每次增加25%的容量,直到新容量大于期望容量;
确定了切片的容量后,就可以计算切片中新数组占用的内存了,计算方法是目标容量和元素大小相乘,计算新容量时可能会发生溢出或请求的内存超过上限,这时就会直接panic
,但相关代码在这里就被省略了:
var overflow bool
var newlenmem, capmem uintptr
switch {
// ...
default:
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
// math.MulUintptr对两个uintptr进行相乘,返回乘积和一个表示是否溢出的布尔值
capmem, _ = math.MulUintptr(et.size, uintptr(newcap))
// roundsize调整新内存大小,使其对齐
capmem = roundsize(capmem)
// 得到新切片容量
newcap = int(capmem / et.size)
}
// ...
// p是指向新分配的内存的指针
var p unsafe.Pointer
// 如果切片的元素类型中不包含指针
if et.kind & kindNoPointers != 0 {
p = malloc(capmem, nil, false)
// 将刚分配的新内存中,多分配出来的部分内容清零
// 此处计算内存偏移时用的是add函数而非+,因为go不允许对指针进行算术操作
// add函数用于进行指针运算
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// 分配带垃圾回收的内存
p = mallocgc(capmem, et, true)
// 如果写屏障启用
// 写屏障是gc的一种机制,用于追踪对象的引用的变化,尤其是在并发gc中
// 用于维护堆中的对象图,使垃圾回收器可以正确识别和回收不再使用的对象
if writeBarrier.enabled {
// bulkBarrierPreWriteSrcOnly主要用于批量复制包含指针的内存块前,处理源对象中的引用
// 以确保这些引用在垃圾回收过程中不会被遗漏,具体来说,它会标记这些旧引用
// 确保垃圾回收器在并发标记阶段能正确追踪它们
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
}
}
memmove(p, old.array, lenmem)
return slice(p, old.len, newcap)
}
如果切片中元素不是指针类型,那么就会调用memclrNoHeapPointers
将超出切片当前长度的位置清空并在最后使用memmove
将原数组内存中的内容拷贝到新申请的内存中。这里的memclrNoHeapPointers
和memmove
都是用目标机器上的汇编指令实现的,在这里就不展开介绍了。
runtime.growslice
函数最终会返回一个新的slice
结构,其中包含了新的数组指针、大小、容量,这个返回的三元组最终会改变原有切片,帮助append
完成元素追加的功能。
3.2.5 拷贝切片
切片的拷贝虽然不是一个常见的操作类型,但是却是我们学习切片实现原理必须要谈及的一个问题,当我们使用copy(a, b)
的形式对切片进行拷贝时,编译期间的cmd/compile/internal/gc.copyany
函数也会分两种情况进行处理,如果当前copy
不是在运行时调用的,copy(a, b)
会被直接转换成下面的代码:
// 取a和b中较大的那个长度
n := len(a)
if n > len(b) {
n = len(b)
}
if a.ptr != b.ptr {
memmove(a.ptr, b.ptr, n*sizeof(elem(a)))
}
其中memmove
会负责对内存进行拷贝,在其他情况下,编译器会使用runtime.slicecopy
函数替换运行期间调用的copy
,例如go copy(a, b)
:
// width是切片中元素的大小
func slicecopy(to, fm slice, width uintptr) int {
if fm.len == 0 || to.len == 0 {
return 0
}
// 取源切片和目标切片大小两者较小值,保证不会超出目标切片容量
n := fm.len
if to.len < n {
n = to.len
}
if width == 0 {
return n
}
// ...
// 计算需要复制的总字节数
size := uintptr(n) * width
// 如果只有一个字节需要复制,则直接复制单个字节
if size == 1 {
*(*byte)(t.array) = *(*byte)(fm.array)
// 否则对内存块进行复制
} else {
memmove(to.array, fm.array, size)
}
return n
}
上述函数的实现非常直接,两种不同的拷贝方式一般都会通过memmove
将整块内存中的内容拷贝到目标内存区域中:
相比于依次对元素进行拷贝,这种方式能提供更好的性能,但需要注意的是,哪怕使用memmove
对内存成块进行拷贝,这个操作还是会占用非常多的资源,在大切片上执行拷贝操作时一定要注意性能影响。
3.2.6 小结
切片的很多功能都是在运行时实现的,无论是初始化切片,还是对切片进行追加或扩容都需要运行时的支持,需要注意的是在遇到大切片扩容或复制时可能发生大规模的内存拷贝,一定要在使用时减少这种情况的发生,避免对程序的性能造成影响。
3.3 哈希表
这一节会介绍Go语言中的另一个集合元素——哈希,也就是Map的实现原理;哈希表是除了数组之外,最常见的数据结构,几乎所有语言都会有数组和哈希表这两种集合元素,有的语言将数组实现成列表,有的语言将哈希表称作结构体或字典,但是它们是两种设计集合元素的思路,数组用于表示元素的序列,而哈希表示键值对之间的映射关系,只是不同语言的叫法和实现稍微有些不同。
哈希表是一种古老的数据结构,在1953年就有人使用拉链法实现了哈希表,它能够根据键(Key)直接访问内存中的存储位置,也就是说我们能够直接通过键找到该键对应的一个值。
3.3.1 设计原理
哈希表是计算机科学中的最重要数据结构之一,这不仅因为它O(1)
的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点——哈希函数和冲突解决方法。
哈希函数
实现哈希表的关键点在于如何选择哈希函数,哈希函数的选择在很多程度上能够决定哈希表的读写性能,在理想情况下,哈希函数应该能将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的结果是不可能实现的。
比较实际的方式是让哈希函数的结果能够尽可能地均匀分布,然后通过工程上的手段解决哈希碰撞的问题,但是哈希的结果一定要尽可能均匀,结果不均匀的哈希函数会造成更多的冲突并导致更差的读写性能。
在一个使用结果较为均匀的哈希函数中,害的增删改查都需要O(1)
的时间复杂度,但是非常不均匀的哈希函数会导致所有的操作都会占用最差O(n)
的复杂度,所以在哈希表中使用好的哈希函数是至关重要的。
冲突解决
就像我们之前所提到的,在通常情况下,哈希函数的输入范围一定会远远大于输出的范围,所以在使用哈希表时一定会遇到冲突,哪怕我们使用了完美的哈希函数,当输入的键足够多最终也会造成冲突。
然而我们的哈希函数往往都是不完美的,输出的范围是有限的,所以一定会发生哈希碰撞,这时就需要一些方法来解决哈希碰撞的问题,常见的就是开放寻址法和拉链法。
开放寻址法
开放寻址法是一种在哈希表中解决哈希碰撞的方法,这种方法的核心思想是对数组中的元素依次探测和比较以判断目标键值对是否存在于哈希表中,如果我们使用开放寻址法来实现哈希表,那么在支撑哈希表的数据结构就是数组,不过因为数组的长度有限,存储(author, draven)
这个键值对时会从如下的索引开始遍历:
index := hash("author") % array.len
当我们向当前哈希表写入新的数据时发生了冲突,就会将键值对写入到下一个不为空的位置:
如上图所示,当Key3与已经存入哈希表中的两个键值对Key1和Key2发生冲突时,Key3会被写入Key2后面的空闲内存中;当我们再去读Key3对应的值时就会先对键进行哈希并取模,这会帮助我们找到Key1,因为Key1与我们期望的键Key3不匹配,所以会继续查找后面的元素,直到内存为空或者找到目标元素。
当需要查找某个键对应的值时,就会从索引的位置对数据进行线性探测,找到目标键值对或者空内存就意味着这一次查询操作的结束。
开放寻址法中对性能影响最大的就是装载因子,它是数组中元素的数量与数组大小的比值,随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会同时影响哈希表的读写性能,当装载率超过70%之后,哈希表的性能就会急剧下降,而一旦装载率达到100%,整个哈希表就会完全失效,这时查找任意元素都需要遍历数组中全部的元素(作者表达的意思应该是哈希表性能会显著下降,实际上查找元素并不一定要遍历数组中全部元素,只是冲突的概率大大增加了),所以在实现哈希表时一定要时刻关注装载因子的变化。
拉链法
与开放地址法相比,拉链法是哈希表中最常见的实现方法,大多数的编程语言都会用拉链法实现哈希表,它的实现比较开放地址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间。
实现拉链法一般会使用数组加上链表,不过有一些语言会在拉链法的哈希中引入红黑树以优化性能,拉链法会使用链表数组作为哈希底层的数据结构,我们可以将它看成一个可以扩展的“二维数组”:
如上图所示,当我们需要将一个键值对(Key6, Value6)
写入哈希表时,键值对中的键Key6
都会先经过一个哈希函数,哈希函数返回的哈希会帮助我们选择一个桶,和开放地址法一样,选择桶的方式就是直接对哈希返回的结果取模:
index := hash("Key6") % array.len
选择了2号桶之后就可以遍历当前桶中的链表了,在遍历链表的过程中会遇到以下两种情况:
1.找到键相同的键值对——更新键对应的值;
2.没有找到键相同的键值对——在链表的末尾追加新键值对;
将键值对写入哈希之后,要通过某个键在其中获取映射的值,就会经历如下的过程:
Key11展示了一个键在哈希表中不存在的例子,当哈希表发现它命中4号桶时,它会依次遍历桶中的链表,然而遍历到链表的末尾也没有找到期望的键,所以哈希表中没有该键对应的值。
在一个性能比较好的哈希表中,每一个桶中都应该有0~1
个元素,有时会有2~3
个,很少会超过这个数量,计算哈希、定位桶和遍历链表三个过程是哈希表读写操作的主要开销,使用拉链法实现的哈希也有装载因子这一概念:
装载因子 := 元素数量 / 桶数量
与开放地址法一样,拉链法的装载因子越大,哈希的读写性能就越差,在一般情况下使用拉链法的哈希表装载因子都不会超过1,当哈希表的装载因子较大时就会触发哈希的扩容,创建更多的桶来存储哈希中的元素,保证性能不会出现严重的下降。如果有1000个桶的哈希表存储了10000个键值对,它的性能是保存1000个键值对时候性能的1/10,但是仍然比在链表中直接读写好1000倍(在理想的哈希函数的情况下,相当于将长为10000的链表分为了1000个长为10的子链表,比原链表好1000倍)。
3.3.2 数据结构
Go语言运行时同时使用了多个数据结构组合表示哈希表,其中使用hmap
结构体来表示哈希,我们先来看一下这个结构体内部的字段:
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
1.count
表示当前哈希表中的元素数量;
2.B
表示当前哈希表持有的buckets
数量,但是因为哈希表中桶的数量都是2的倍数(作者这里说的是2的倍数,作者应该想说2的幂,取2的幂是一种实现上的优化选择,当计算哈希的结果应放入哪个桶中时,就可以直接hash("key") & (2^k-1)
算出桶索引,而不用取余运算,取余运算比按位与要慢很多,这种方式当桶的数量不是2的幂时也能计算桶索引,比如5个桶,二进制为101,此时再进行按位与会导致第2位的0失效,从而不管第2位是0还是1都能进入这个桶,这就增大了哈希冲突的几率,如果桶的数量是2的幂,那么哈希值的每一位都能用上),所以该字段会存储对数,也就是len(buckets) == 2^B
;
3.hash0
是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
4.oldbuckets
是哈希在扩容时用于保存之前buckets
的字段,它的大小是当前buckets
的一半;
如上图所示哈希表hmap
的桶就是bmap
,每一个bmap
都能存储8个键值对,当哈希表中存储的数据过多,单个桶无法装满时就会使用extra.overflow
中的桶来存储溢出的数据。上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶,上图中黄色的bmap
就是正常桶,绿色的bmap
是溢出桶,溢出桶是在Go语言还使用C语言实现时就使用的设计,由于它能够减少扩容的频率所以一直使用至今。
这个桶的结构体bmap
在Go语言源代码中的定义只包含一个简单的tophash
字段,tophash
存储了键的哈希的高8位,通过比较不同键的哈希的高8位可以减少访问键值对次数以提高性能(应该是通过高8位作预判断,这是一种优化,如果高8位都不同,那肯定是不同的键,如果相同再判断键的整个哈希值):
type bmap struct {
tophash [bucketCnt]uint8
}
bmap
结构体其实不止包含tophash
字段,由于哈希表中可能存储不同类型的键值对并且Go语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导,这些字段在运行时也都是通过计算内存地址的方式直接访问的,所以它的定义中就没有包含这些字段,但是我们能根据编译期间的cmd/compile/internal/gc.bmap
函数(该函数在bmap结构中根据map的类型添加字段)对它的结构重建:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
如果哈希表存储的数据逐渐增多,我们会对哈希表进行扩容或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过8个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
从Go语言哈希的定义中就可以发现,它比前面两节提到的数组和切片复杂得多,结构体中不仅包含大量字段,还使用了较多的复杂结构,在后面的小节中我们会详细介绍不同字段的作用。
3.3.3 初始化
既然已经介绍了常见哈希表的基本原理和实现方法,那么开始分析Go语言中哈希表的实现,首先要分析的就是在Go语言中初始化哈希的两种方法——通过字面量和运行时。
字面量
目前的现代编程语言基本都支持使用字面量的方式初始化哈希,一般都会使用key: value
的语法来表示键值对,Go语言中也不例外:
hash := map[string]int{
"1": 2,
"3": 4,
"5": 6,
}
我们需要在初始化哈希时声明键值对的类型,这种使用字面量初始化的方式最终都会通过cmd/compile/internal/gc.maplit
函数初始化,我们来分析一下cmd/compile/internal/gc.maplit
函数初始化哈希的过程:
// n是map字面量节点
// m是目标map变量
// init是初始化语句的列表
func maplit(n *Node, m *Node, init *Nodes) {
// 创建一个OMAKE节点,用于make map
a := nod(OMAKE, nil, nil)
// 将n的逃逸分析结果赋值给a
a.Esc = n.Esc
// 将n的类型节点和长度设置到a的列表中
a.List.Set2(typenod(n.Type), nodintconst(int64(n.List.Len())))
// 将m=a这一赋值语句加入到初始化语句列表init中
litas(m, a, init)
// stat用于存储map字面量节点n中的元素
var stat dyn []*Node
// 将map字面量节点n中的元素都加到stat中
for _, r := range n.List.Slice() {
stat = append(stat, r)
}
if len(stat) > 25 {
// ...
} else {
// 该函数将元素加入map m中
addMapEntries(m, stat, init)
}
}
当哈希表中的元素数量少于等于25个时,编译器会直接调用addMapEntries
将字面量初始化的结构体转换成以下代码,将所有键值对一次加入到哈希表中:
hash := make(map[string]int, 3)
hash["1"] = 2
hash["3"] = 4
hash["5"] = 6
这种初始化的方式与前面两节分析的数组和切片的几乎完全相同,由此看来集合类型的初始化在Go语言中有着相同的处理方式和逻辑。
一旦哈希表中元素的数量超过了25个,就会在编译期间创建两个数组分别存储键和值的信息,这些键值对会通过一个如下所示的for循环加入目标的哈希:
hash := make(map[string]int, 26)
vstatk := []string{"1", "2", "3", ... , "26"}
vstatv := []int{1, 2, 3, ... , 26}
for i := 0; i < len(vstatk); i++ {
hash[vstatk[i]] = vstatv[i]
}
这里展开的两个切片vstatk
和vstatv
还会被编译器继续展开,具体的展开方式可以阅读上一节了解切片的初始化,不过无论使用哪种方法,使用字面量初始化的过程都会使用Go语言中的关键字make
来创建新的哈希并通过最原始的[]
语法向哈希追加元素。
运行时
无论make
是从哪里来的,只要我们使用make
创建哈希,Go语言编译器都会在类型检查期间将它们转换成对runtime.makemap
的调用,使用字面量来初始化哈希也只是语言提供的辅助工具(使用字面量时,编译器创建map时也是使用的make关键字,见上段),最后调用的都是runtime.makemap
:
// t是map的类型
// hint是预估的元素数量
func makemap(t *maptype, hint int, h *hmap) *hamp {
// 计算需要为hint个元素分配的内存大小
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
// 如果乘法溢出或要分配的内存超出最大能分配的大小,则将元素数量设为0
if overflow || mem > maxAlloc {
hint = 0
}
// 如果没传h,则分配一个新hmap结构体
if h == nil {
h = new(hmap)
}
// 为哈希表生成一个哈希种子
h.hash0 = fastrand()
// 初始化B为0
B := uint8(0)
// 通过overLoadFactor函数计算B的大小,确保哈希表的负载因子不会超载
for overLoadFactor(hint, B) {
B++
}
// B决定了哈希表的bucket数量(2^B个bucket)
h.B = B
// 需要分配bucket时
if h.B != 0 {
var nextOverflow *bmap
// 返回分配的bucket和可能的nextOverflow
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
// 如果分配了nextOverflow,则设置extra字段
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
这个函数的执行过程会分成以下几个部分:
1.计算哈希占用的内存是否溢出或者超出能分配的最大值;
2.调用fastrand
获取一个随机的哈希种子;
3.根据传入的hint
计算出需要的最小桶数量;
4.使用runtime.makeBucketArray
创建用于保存桶的数组;
runtime.makeBucketArray
函数会根据传入的B
计算出需要创建的桶数量,然后在内存中分配一片连续空间用于存储数据:
// b是bucket数量的对数
func makeBucketArray(t *maptype, b int8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
// 计算基础bucket数目,即2^b
base := bucketShift(b)
// 初始化bucket数目为基础bucket数目
nbuckets := base
// 如果桶的数量大于等于2^4=16,则添加一些溢出桶
if b >= 4 {
// 把溢出bucket的数目加到桶的数量中
nbuckets += bucketShift(b - 4)
// 计算总内存大小
sz := t.bucket.size * nbuckets
// 将总内存向上舍入到合适的大小
up := roundupsize(sz)
// 如果舍入后的大小不同于原大小,调整桶的数量
if up != sz {
nbuckets = up / t.bucket.size
}
}
// 分配含有nbuckets个桶的数组
buckets = newarray(t.bucket, int(nbuckets))
// 如果有溢出桶
if base != nbuckets {
// 初始化溢出bucket指针
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
// 定位最后一个bucket
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
// 链接最后一个bucket到第一个bucket
last.setoverflow(t, (*bmap)(buckets))
}
// 返回bucketssh数组和溢出bucket指针
return buckets, nextOverflow
}
当桶的数量小于
2
4
2^4
24时,由于数据较少、使用溢出桶的可能性较低,这时就会省略创建的过程以减少额外开销;当桶的数量多于
2
4
2^4
24时,就会额外创建
2
B
−
4
2^{B-4}
2B−4个溢出桶,根据上述代码,我们能确定正常桶和溢出桶在内存中的存储空间是连续的,只是被bmap
中的不同字段引用。
3.3.4 读写操作
哈希表作为一种数据结构,我们肯定需要分析它的常见操作,首先就需要了解其读写操作的实现原理,访问哈希表一般都是通过下标或者遍历两种方式进行的:
_ = hash[key]
for k, v := range hash {
// k, v
}
这两种方式虽然都能读取哈希表中的数据,但是使用的函数和底层的原理完全不同,前者需要知道哈希的键并且一次只能获取单个键对应的值,而后者可以遍历哈希中的全部键值对,访问数据时也不需要预先知道哈希的键,在这里我们会介绍前一种访问方式,第二种访问方式会在range
一节中详细分析。
数据结构的写一般指的都是增加、删除和修改,增加和修改字段都使用索引和赋值语句,而删除字典中的数据需要使用关键字delete
:
hash[key] = value
hash[key] = newValue
delete(hash, key)
除了这些操作之外,我们还会分析哈希的扩容过程,这能帮助我们深入理解哈希是如何对数据进行存储的。
访问
在编译的类型检查期间,hash[key]
以及类似的操作都会被转换成对哈希的OINDEXMAP
操作,中间代码生成阶段会在cmd/compile/internal/gc.walkexpr
函数中将这些OINDEXMAP
操作转换成如下的代码:
v := hash[key] // => v := *mapaccess1(maptype, hash, &key)
v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)
赋值语句左侧接受参数的个数会决定使用的运行时方法:
1.当接受参数仅为一个时,会使用runtime.mapaccess1
,该函数仅会返回一个指向目标值的指针;
2.当接受两个参数时,会使用runtime.mapaccess2
,除了返回目标值之外,它还会返回一个用于表示当前键对应的值是否存在的布尔值;
runtime.mapaccess1
函数会先通过哈希表的哈希函数、种子获取当前键对应的哈希,再通过bucketMask
和add
函数拿到该键值对所在的桶序号和哈希最上面的8位数字。
// 在哈希表中查找一个键,并返回对应的值
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 获取用于键类型的算法,包括哈希函数和比较函数
alg := t.key.alg
// 使用哈希表的随机种子h.hash0计算key的哈希值
hash := alg.hash(key, uintptr(h.hash0))
// 计算bucket掩码,通常是1 << h.B - 1,用于计算哈希值对应的bucket索引
m := bucketMask(h.B)
// 找到目标bucket
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// 计算哈希值的高位部分,用于快速比较
top := tophash(hash)
// 标签,用来修饰for循环,break时加上标签可以直接break该层循环,即使是在其内层执行的break
bucketloop:
// 遍历b和b的溢出bucket
for ; b != nil; b = b.overflow(t) {
// 遍历当前bucket的每个槽位
for i := uintptr(0); i < bucketCnt; i++ {
// 如果当前槽位的tophash不匹配,说明这个槽位肯定不匹配
if b.tophash[i] != top {
// 如果当前桶后面的槽位都为空,则退出当前循环
if b.tophash[i] == emptyRest {
break bucketloop
}
continue;
}
// 计算当前槽位的键地址,dataOffset是key数组的起始位置
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// 如果整个键也是匹配的
if alg.equal(key, k) {
// 计算当前槽位的值地址,key数组后面是值数组
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
return v
}
}
}
// 如果没有找到目标键,返回零值指针,zeroVal是一个全局变量,表示零值
return unsafe.Pointer(&zeroVal[0])
}
在bucketloop
循环中,哈希会依次遍历正常桶和溢出桶中的数据,它会比较这8位数字和桶中存储的tophash
,每一个桶都存储其中每个键对应的tophash
,每一次读写操作都会与桶中所有的tophash
进行比较,用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高8位,这种设计能够减少同一个桶中有大量相等tophash
的概率。
如上图所示,每一个桶都是一整片的内存空间,当发现桶中的tophash
与传入键的tophash
匹配之后,我们会通过指针和偏移量获取哈希中存储的键keys[0]
并与key
比较,如果两者相同就会获取目标值的指针values[0]
并返回。
另一个同样用于访问哈希表中数据的runtime.mapaccess2
只是在runtime.mapaccess1
的基础上多返回了一个标识键值对是否存在的布尔值:
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
// ...
bucketloop:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if alg.equal(key, k) {
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
return v, true
}
}
}
return unsafe.Pointer(&zeroVal[0]), false
}
使用v, ok := hash[k]
的形式访问哈希表中元素时,我们能够通过这个布尔值更准确地知道当v == nil
时,v
到底是哈希中存储的元素还是表示该键对应的元素不存在,所以在访问哈希时,更推荐使用这一方式先判断元素是否存在。
上面的过程其实是在正常情况下,访问哈希表中元素时的表现,然而与数组一样,哈希表可能会在装载因子过高或者溢出桶过多时进行扩容,哈希表的扩容并不是一个原子的过程,在扩容的过程中保证哈希的访问是比较有意思的话题,我们在这里其实也省略了相关的代码,不过会在下面展开介绍。
写入
当形如hash[k]
的表达式出现在赋值符号左侧时,该表达式会在编译期间转换成调用runtime.mapassign
函数,该函数与runtime.mapaccess1
比较相似,我们将其分成几个部分分析,首先是函数会根据传入的键拿到对应的哈希和桶:
// 返回指向key对应的值的指针
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
alg := t.key.alg
// 使用哈希表的哈希种子h.hash0计算键的哈希值
hash := alg.hash(key, uintptr(h.hash0))
// 切换hashWriting位,如果h.flags中目标位是0,则异或后是1,如果是1,则异或后是0
h.flags ^= hashWriting
again:
// 计算哈希值对应的桶索引
bucket := hash & bucketMask(h.B)
// 根据桶索引找到对应的bucket
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
// 计算哈希值的高位部分,用于快速比较
top := tophash(hash)
然后通过遍历比较桶中存储的tophash
和键的哈希,如果找到了相同结果就会获取目标位置的地址并返回,其中inserti
表示目标元素在桶中的索引(作者此处说的很模糊,实际上,它是可以插入槽位的tophash值的指针),insertk
和val
分别表示键值对的地址,获得目标地址之后会直接通过算数计算进行寻址获得键值对k
和val
:
// 指向可以插入槽位的tophash值的指针
var inserti *uint8
// 指向可以插入槽位的键的指针
var insertk unsafe.Pointer
// 指向可以插入槽位的值的指针
var val unsafe.Pointer
bucketloop:
for {
// 遍历桶中每个槽位
for i := uintptr(0); i < bucketCnt; i++ {
// 如果该槽位的tophash与目标tophash不匹配
if b.tophash[i] != top {
// 如果该槽位为空(说明可以插入到该位置)且目前还未找到插入位置
if isEmpty(b.tophash[i]) && inserti == nil {
// 记录可以插入的槽位的tophash指针
inserti = &b.tophash[i]
// 记录可以插入的槽位的键指针
insertk = add(unsafe.Pointerr(b), dataOffset+i*uintptr(t.keysize))
// 记录可以插入的槽位的值指针
val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)
}
// 如果当前桶的后面槽位都为空
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// tophash匹配成功,接下来看整个key的哈希是否相等
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if !alg.equal(key, k) {
continue
}
// 匹配到已有的key,获取key对应的值的地址
val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
goto done
}
// 获取当前bucket的溢出bucket
ovf := b.overflow(t)
// 如果没有,退出循环
if ovf == nil {
break
}
// 如果有,继续遍历溢出桶
b = ovf
}
在上述的for循环中会依次遍历正常桶和溢出桶中存储的数据,整个过程会依次判断tophash
是否相等、key
是否相等,遍历结束后会从循环中跳出。
如果当前桶已经满了,哈希会调用newoverflow
函数创建新桶或者使用hmap
预先在noverflow
中创建好的桶来保存数据,新创建的桶不仅会被追加到已有桶的末尾,还会增加哈希表的noverfow
计数器。
// 如果遍历bucket和溢出bucket后没有找到能插入的位置
if inserti == nil {
// 分配一个新的溢出bucket
newb := h.newoverflow(t, b)
// 获取要插入槽位的tophash指针(新桶中的第一个槽位)
inserti = &newb.tophash[0]
// 获取要插入槽位的键的指针
insertk = add(unsafe.Pointer(newb), dataOffset)
// 获取要插入槽位的值的指针
val = add(insertk, bucketCnt*uintptr(t.keysize)
}
// typedmemmove用于执行类型安全的内存拷贝操作,如正确对齐内存、正确处理结构体或数组的指针等
typedmemmove(t.key, insertk, key)
// 将插入位置的tophash设为要插入的键的tophash
*inserti = top
// 增加哈希表中元素计数
h.count++
done:
// 返回key对应的值的指针
return val
}
如果当前键值对在哈希中不存在,哈希为新键值对规划存储的内存地址,通过typememmove
将键移动到对应的内存空间中,最后返回键对应的值的地址val
,如果当前键值对在哈希中存在,那么就会直接返回目标区域(指key对应的值)的内存地址。哈希并不会在mapassign
这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入的:
// 调用runtime.mapassign_fast64函数,它是mapassign函数对uint64类型key的优化版本
// 该函数位于静态基址段(SB段),表示一个静态符号
0018 (+5) CALL runtime.mapassign_fast64(SB)
// MOVQ是64位的加载指令
// 将栈指针(SP)偏移量为24处的值加载到寄存器DI中
// 寄存器DI常用于传递函数参数或保存中间结果
0020 (5) MOVQ 24(SP), DI ;; DI = &value
// LEAQ指令用于加载地址
// 将字符串字面值88(位于SB段)的地址加载到寄存器AX中
0026 (5) LEAQ go.string."88"(SB), AX ;; AX = &"88"
// 将寄存器AX中存放的字面值88的地址值移动到DI寄存器表示的地址中
0027 (5) MOVQ AX, (DI) ;; *DI = AX
runtime.mapassign_fast64
与runtime.mapassign
函数的实现差不多,我们需要关注的是后面的三行代码,24(SP
就是该函数返回的值地址,我们通过LEAQ
指令将字符串的地址存储到寄存器AX
中,MOVQ
指令将字符串"88"
存储到了目标地址上完成了这次哈希的写入。
扩容
我们在介绍哈希的写入过程时省略了扩容操作,随着哈希表中元素的逐渐增加,哈希的性能会逐渐恶化,所以我们需要更多的桶和更大的内存保证哈希的读写性能:
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ...
// 如果哈希表当前没有其他并行的增长且负载因子太大或溢出桶太多
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
// 扩容
hashGrow(t, h)
goto again
}
// ...
}
runtime.mapassign
函数会在以下两种情况发生时触发哈希的扩容:
1.装载因子已经超过6.5;
2.哈希使用了太多溢出桶;
不过由于Go语言哈希的扩容不是一个原子的过程,所以runtime.mapassign
函数还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。
根据触发的条件不同扩容的方式分成两种,如果这次扩容是溢出的桶太多导致的,那么这次扩容就是等量扩容sameSizeGrow
,sameSizeGrow
是一种特殊情况下发生的扩容,当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏。runtime: limit the number of map overflow buckets
(http://github.com/golang/go/commit/9980b70cb460f27907a003674ab1b9bea24a847c)引入了sameSizeGrow
通过重用已有的哈希扩容机制,一旦哈希中出现了过多的溢出桶,它就会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存。
扩容的入口是runtime.hashGrow
函数:
func hashGrow(t *maptype, h *hmap) {
// bigger决定哈希表是否增加大小
bigger := uint8(1)
// 如果检查插入一个元素后不会超过负载因子
if !overLoadFactor(h.count+1, h.B) {
// 不增加哈希表大小
bigger = 0
// sameSizeGrow扩容机制
h.flags |= sameSizeGrow
}
// 保存旧的桶数组
oldbuckets := h.buckets
// 创建新的桶数组,大小为h.B+bigger,此参数是2的幂,如果bigger为1,说明要扩大为原来的2倍
// nextOverflow是第一个溢出桶
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
// 更新哈希表元数据
// 更新哈希表大小B
h.B += bigger
// 更新标志位
h.flags = flags
// 保存旧的桶数组
h.oldbuckets = oldbuckets
// 将buckets更新为新的桶数组
h.buckets = newbuckets
// 初始化已经搬迁的桶数(将原桶数组中内容搬到新桶数组中),真正的数据搬迁会之后再进行
h.nevacuate = 0
// 初始化溢出桶数量
h.noverflow = 0
// 设置新的溢出桶链表
h.extra.oldoverflow = h.extra.overflow
h.extra.overflow = nil
h.extra.nextOverflow = nextOverflow
}
哈希在扩容的过程中会通过runtime.makeBucketArray
创建一组新桶和溢出桶,随后将原有的桶数组设置到oldbuckets
上并将新的空桶设置到buckets
上,溢出桶也使用了相同的逻辑进行更新,下图展示了触发扩容后的哈希:
我们在runtime.hashGrow
中还看不出来等量扩容和正常扩容的太多区别,等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移,哈希表的数据迁移的过程是在runtime.evacuate
函数中完成的,它会对传入桶中的元素进行“再分配”。
// oldbucket参数是需要搬迁的旧桶的索引
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
// 根据索引计算需要搬迁的旧桶的地址
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// newbit一般为旧桶大小,即1 << (h.B-1)
// 一般一个旧桶中的数据会分流到两个新桶,哈希值的newbit位为1的数据放到一个桶,为0的放到另一个桶
newbit := h.oldbuckets()
// 如果b还未搬迁
if !evacuated(b) {
// xy用于保存要搬迁到的两个新桶的信息
var xy [2]evacDst
// x是其中一个新桶
x := &xy[0]
// 获取新桶x的地址
x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
// 获取新桶x的key数组起始指针
x.k = add(unsafe.Pointer(x.b), dataOffset)
// 获取新桶x的value数组起始指针
x.v = add(x.k, bucketCnt*uintptr(t.keysize))
// y是另一个新桶
y := &xy[1]
// 获取新桶y的地址
y.b = (*bmap)(add(h.buckets, oldbucket+newbit)*uintptr(t.bucketsize)))
// 获取新桶y的key数组起始指针
y.k = add(unsafe.Pointer(y.b), dataOffset)
// 获取新桶y的value数组起始指针
y.v = add(y.k, bucketCnt*uintptr(t.keysize))
runtime.evacuate
函数会将一个旧桶中的数据分流到两个新桶,所以它会创建两个用于保存分配信息上下文的evacDst
结构体,这两个结构体分别指向了一个新桶:
如果这是等量扩容,旧桶与新桶之间是一对一的关系,此时两个evacDst
结构体只会初始化一个,当哈希表的容量翻倍时,每个旧桶的元素会被分流到新创建的两个桶中,我们仔细分析一下分流元素的逻辑:
// 遍历要搬迁的桶和其溢出桶链表
for ; b != nil; b = b.overflow(t) {
// 获取原桶中key数组地址
k := add(unsafe.Pointer(b), dataOffset)
// 获取原桶中value数组地址
v := add(k, bucketCnt*uintptr(t.keysize))
// 遍历桶中每个槽位,每次遍历,i、k、v都会移动到原桶中的下一个槽位
for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
// 获取当前槽位的键的tophash值
top := b.tophash[i]
// 判断当前key应该搬迁到哪个新桶中
k2 := k
var useY uint8
hash := t.key.alg.hash(k2, uintptr(h.hash0))
if hash&newbit != 0 {
useY = 1
}
// evacuatedX和evacuatedY是常量
// evacuatedX用于标记当前槽位已搬迁到x桶中
// 而evacuatedY=useY+1,表示已搬迁到y桶中
b.tophash[i] = evacuatedX + useY
// 获取要搬迁到的目标桶的evacDst
dst := &xy[useY]
// 如果目标桶已满
if dst.i == bucketCnt {
// 分配一个新溢出桶,作为目标桶
dst.b = h.newoverflow(t, dst.b)
// 重置元素数量计数器
dst.i = 0
// 重置key地址为溢出桶的key数组地址
dst.k = add(unsafe.Pointer(dst.b), dataOffset)
// 重置value地址为溢出桶的value数组地址
dst.v = add(dst.k, bucketCnt*uintptr(t.keysize))
}
// 设置搬迁后的tophash
dst.b.tophash[dst.i&(bucketCnt-1)] = top
// 将key和value内存安全地拷贝到目标位置
typedmemmove(t.key, dst.k, k)
typedmemmove(t.elem, dst.v, v)
// 更新搬迁目标桶的下一个插入槽位
dst.i++
dst.k = add(dst.k, uintptr(t.keysize))
dst.v = add(dst.v, uintptr(t.valuesize))
}
}
// ...
}
只使用哈希函数是不能定位到具体某一个桶的,哈希函数只会返回很长的哈希,例如:b72bfae3f3285244c4732ce457cca823bc189e0b
,我们还需一些方法将哈希映射到具体的桶上,在很多时候我们都会使用取模或者位操作来获取桶的编号,例如当前哈希中包含4个桶,那么哈希的桶掩码就是0b11(3)
,将上例哈希值使用位操作(按位与)就会得到3,我们就会在3号桶中存储该数据:
0xb72bfae3f3285244c4732ce457cca823bc189e0b & 0b11 = 3
如果扩容后新的哈希表有8个桶,在大多数情况下,上面经过桶掩码0b11
计算结果为3的哈希值会因为桶掩码增加了一位变成0b111
而分流到新的3号和7号桶,所有数据都会被typedmemmove
拷贝到目标桶中:
runtime.evacuate
最后会调用runtime.advanceEvacuationMark
增加哈希的nevacuate
计数器,在所有的旧桶都被分流后清空哈希的oldbuckets
和oldoverflow
字段:
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
// 增加已经搬迁的桶的数量
h.nevacuate++
// 计算处理的停止位置,即本次最多处理1024个搬迁桶
stop := h.nevacuate + 1024
// 如果处理位置超出了原哈希表中桶的数量,将停止位置设为原哈希表中的最大桶数
if stop > newbit {
stop = newbit
}
// 如果没有超出停止位置,且当前处理位置的桶已经搬迁过
for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
// 递增已搬迁桶的数量
h.nevacuate++
}
// 如果所有桶都被搬迁了
if h.nevacuate == newbit { // newbit == # of oldbuckets
// 不再需要旧桶
h.oldbuckets = nil
// 旧溢出桶也不再需要
if h.extra != nil {
h.extra.oldoverflow = nil
}
// 清除sameSizeGrow标志
h.flags &^= sameSizeGrow
}
}
之前在分析哈希表访问函数runtime.mapaccess1
时其实省略了扩容期间获取键值对的逻辑,当哈希表的oldbuckets
存在时,就会先定位到旧桶并在该桶没有被分流时从中获取键值对。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ...
// 获取键关联的算法
alg := t.key.alg
// 使用哈希种子h.hash0计算key的哈希值
hash := alg.hash(key, uintptr(h.hash0))
// 获取桶的掩码
m := bucketMask(h.B)
// 获取哈希值对应的桶的地址
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// 如果有旧桶(即哈希表正在扩容)
if c := h.oldbuckets; c != nil {
// 如果正在进行不等量扩容
if !h.sameSizeGrow() {
// 获取原桶的桶掩码
m >>= 1
}
// 计算哈希值在原桶中桶的地址
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
// 如果桶尚未搬迁
if !evacuated(oldb) {
// 使用原桶
b = oldb
}
}
bucketloop:
// ...
}
因为旧桶还没有被runtime.evacuate
函数搬迁,其中还保存着我们需要使用的数据,会替代新创建的空桶提供数据。
我们在runtime.mapassign
函数中也省略了一段逻辑,当哈希表正在处于扩容状态时,每次向哈希表写入值时都会触发runtime.growWork
对哈希表的内容进行增量拷贝:
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ...
again:
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
// ...
}
当然除了写入操作之外,删除操作也会在哈希表扩容期间触发runtime.growWork
,触发的方式和代码与这里的逻辑几乎完全相同,都是计算当前值所在的桶,然后对该桶中的元素进行拷贝。
我们简单总结一下哈希表的扩容设计和原理,哈希在存储元素过多时会触发扩容操作,每次都会将桶的数量翻倍,整个扩容过程并不是原子的,而是通过runtime.growWork
增量触发的,在扩容期间访问哈希表时会使用旧桶,向哈希表写入数据时会触发旧桶元素的分流;除了这种正常的扩容之外,为了解决大量写入、删除造成的内存泄漏问题,哈希引入了sameSizeGrow
这一机制,在出现较多溢出桶时会对哈希进行“内存整理”减少对空间的占用。