1

声明

本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。

要点

本文关注Go语言map相关的语言特性。

map初始化与内存分配

首先,必须给map分配内存空间之后,才可以往map中添加元素:

func main() {
    var m map[int]int // 使用var语法声明一个map,不会分配内存
    m[1] = 1 // 报错:assignment to entry in nil map
}

如果你使用的是make来创建一个map,Go在声明的同时,会自动为map分配内存空间,不会报错:

func main() {
    m := make(map[int]int) // make语法创建map
    m[1] = 1 // ok
}

map中get操作的返回值

我们直接看一个例子:

func main() {
    m := make(map[int]int)
    fmt.Println(m[1]) // 0
    m[1] = 0
    fmt.Println(m[1]) // 0
}

大家看到问题了吧,如果某个key-value对在map中并不存在,不像其他语言,我们访问这个key是并不会报错的,而是返回value的零值。如果是int,那就返回0。但是,如果我们真正的往map里添加一个key-value对,其值为0,那么我们如何区分是根本没有这个key-value对,还是有这个key-value对,但是值为0呢?其实,访问map中的元素这个表达式有两个返回值:

func main() {
    m := make(map[int]int)
    v, ok := m[1]
    fmt.Println(v, ok) // 0, false
    m[1] = 0
    v, ok = m[1]
    fmt.Println(v, ok) // 0, true
}

第一个返回值和之前的例子相同,而第二个返回值就可以被用来判断,是否map中存在这个key-value对。如果存在,返回true;反之返回false,我们通常可以与if联合进行使用:

func main() {
    m := make(map[int]int)
    if _, ok := m[1]; !ok {
        fmt.Println("key不存在")
    }
}

map遍历的无序性

在Go语言中,多次遍历相同的map,得到的结果是不一样的:

func main() {
    m := make(map[int]int)
    m[0] = 1
    m[1] = 2
    m[3] = 5
    for k, v := range m {
        fmt.Println(k, v)
    }
    // 第一次遍历结果:
    0 1
    1 2
    3 5
    // 第二次遍历结果:
    3 5
    0 1
    1 2
}

为什么map是引用类型

为什么我们常常把map视为引用类型?我们先看一个简单的例子:

func main() {
    m := make(map[int]int)
    m[1] = 1 // 赋一个初始值
    test(m) // 函数调用
    fmt.Println(m[1]) // 2
}

func test(m map[int]int) {
    m[1] = 2 // 修改值
}

我们看到,当map作为函数参数传递的时候,在外部函数对map的修改,会影响到原来map的值,为什么会这样呢?
大家都知道,Go语言只有值传递,那么为什么我们还会有把指针传过去的错觉呢?这还要从字典get与set操作的底层实现说起。Go语言的map在底层是用hashtable来实现的。在我们用var语法声明一个map的时候,实际上就创建了一个hmap结构体:

type hmap struct {
    count     int // 元素个数,调用 len(map) 时,直接返回此值
    buckets    unsafe.Pointer // 指向一个bucket数组
    ...
}

我们主要关注count和buckets这两个字段。count就是指map元素的个数;而buckets是真正存储map的key-value对的地方。这也就可以解释为什么我们一开始那个坑的报错问题。我们用var m map[int]int声明的map,只是分配了一个hmap结构体而已,而buckets这个字段并没有分配内存空间。
所以,最后解答我们为什么是引用类型的问题。其实我们传给test函数的值,只是一个hmap结构体;而这个结构体里面又包含了一个bucket数组的指针,也就相当于,表面上我们传了个结构体值过去,而内部却是传了一个指针,这个指针所存储的地址,也就是指针指向的bucket数组结构并没有改变。我们如果对存储key-value对的bucket进行修改,如m[1] = 2这种操作,实际上修改的就是改变了外部函数的bucket值。我们画一个图表示下:

每一个bucket数组中存储的元素结构为bmap,这里真正存储着key与value的值:

type bmap struct {
    tophash  [8]uint8   // tophash,在hash计算过程中会用到
    keys     [8]keyType // 存储key
    values   [8]keyType // 存储value
    pad      uintptr    // 填充,用于内存对齐
    overflow uintptr    // 溢出bucket,hash值相同时会用到
}

为什么key有类型约束

我们常常能够听到“Go 语言字典的键类型不可以是函数类型、字典类型和切片类型,但是value可以为任意类型"。那么,为什么Go语言官方需要对key做限制呢?为了弄清楚这个问题,我们还需要继续深入底层实现。
之前我们已经讲过hmap与bmap的基本结构了,我们继续来看Go语言map的get与set操作,基于以上存储结构,究竟是如何实现的。首先,我们基于之前的那张图,继续画一个空map的内存布局:

如图所示,每一个bucket可以存放8个key-value对。

set操作

假设我们现在要往里插入一个元素m[1] = 2,我们应该把这个元素放在bucket数组中的哪一个bucket上呢?确定了哪一个bucket之后,我们需要放到该bucket内部的哪个位置上呢?
我们首先对key进行哈希运算,假设hash(1)的结果,转成二进制值为:

10010111|000011110110110010001111001010100010010110010101010│00000

哈希值的低5位用来定位究竟是在哪一个bucket;高8位用来定位这个key-value对在bucket内部的哪个位置。
低5位为0,那么在第0号bucket;高8位为151,这个就要和bmap中的tophash有所关联了。我们在往第0号bucket中插入key-value对的时候,发现key1的位置上为空,那么直接往tophash[0]的位置上写入刚才计算的高8位hash值151,然后把key-value对插入即可。
那么,我们为什么需要写入这个tophash值呢?是因为在进行get查找操作的时候,能更加方便快速的定位到bucket内部的元素,后面我们会详细讲。我们画一个插入完m[0] = 2的示意图:

到此为止都很简单,我们继续往里面插入一个元素,m[3] = 3,假设hash(3)算出来的哈希值和刚才的一摸一样,那么这个元素应该放到哪里呢?
由于仍在0号bucket,所以往后找一个空闲的bucket即可,即key2的位置,我们在tophash[1]的位置记录下这个hash值,然后将key-value的值插入到指定的位置:

现在我们往这个字典里插入了两个元素。假设现在我要访问刚刚插入的m[3]这个值,是什么样的流程呢?

get操作

查找操作的关键是定位到key的存储位置。首先,我们需要同样先计算hash值h(3),得到和上文一摸一样的结果:

10010111|000011110110110010001111001010100010010110010101010│00000

同样的,根据低5位定位到0号bucket,然后读取高8位的值,为151。注意下面就开始不一样了。
我们知道,所有哈希值都是存在tophash这个数组中的,我们遍历tophash这个数组,我们看到tophash[0] = 151,那么我们拿出tophash下标0对应1号位置上的key,等于1,与我们要找的key值比较之后,发现并不等于我们要找的key值3,需要继续遍历;然后继续遍历,找下一个2号位置上key,为3,和我们要找的key值3相等,最终拿出这个位置上的key-value对,就是我们最终要取得的值,get操作结束。
我想大家已经明白了,把对应位置上的key值与我们要找的key值做比较的过程,就需要用到key值比较的这个操作,所以,Go语言要求key值必须可以比较。这就解答了我们一开始的问题了。

哈希冲突的解决

解答了我们所有的问题之后,我们继续想一下,如果bucket内部满了,无法继续插入了,我们应该怎么办?这就是很经典的解决哈希表冲突的问题。
这个时候,bmap结构体内部的overflow字段就派上用场了。如果插入之后当前bucket无法容纳这个元素,Go就会新分配一个bucket,用当前bucket的overflow字段指向这个新的bucket,然后往新的bucket里插入当前key-value对即可。插入流程与前文一致:

如果overflow bucket数量过多,在get操作时,对这个overflow链表进行遍历的时间复杂度会大大升高,为了避免溢出bucket数量过多,Go语言会在超过某一个阈值的时候,触发扩容操作。Go语言bucket的扩容操作也是渐进式的,读者可以把这个扩容操作和redis的渐进式rehash扩容操作一起比较学习。
我们可以看到,Go语言结合了链地址法和开放定址法这两种方案。链地址法的操作维度是bucket,而在每个bucket内部采用的则是开放定址法。有兴趣的朋友可以看一下PHP数组底层的实现,比Go语言的实现更为简单。个人认为Go语言在某些方面(解决哈希冲突)的效率较PHP更高,而PHP中的底层结构更为简洁。限于篇幅,这里就不一一进行比较了。

下期预告

【Go语言踩坑系列(五)】错误与异常处理

关注我们

欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~

Nosay


NoSay
449 声望544 粉丝