于是来看看 Go 语言的 map | 青训营笔记

92 阅读5分钟

介绍一下 map!

在 Go 语言中,map 是一种引用类型并且性能非常高。在进行单个键值对的查找、插入、删除操作时,map的时间复杂度为 O(1)。

  1. map 在内存使用方面比较高效。因为 map 的底层实现是哈希表,它可以动态地调整桶的数量,以保证哈希表的效率。

  2. map 的值可以是任意类型,包括基本类型和结构体类型,十分灵活。

但由于 map 需要维护哈希表的结构和每个桶中的键值对列表,当 map 中存储的数据量很大时,可能会对内存造成较大的压力,内存使用量比较高。

并且 map 中的键必须是可以比较的类型,例如整数、浮点数、字符串、指针等。如果键的类型不支持比较操作,那么就不能用作 map 的键。

map 的基本用法

make函数的语法如下:

make(map[KeyType]ValueType, initialCapacity)

其中,KeyType 是键的类型,ValueType 是值的类型,initialCapacity 是 map 的初始容量。如果不指定 initialCapacity,那么 map 会根据需要自动扩容。

可以使用以下方式对 map 进行操作:

  • 插入键值对:m[key] = value
  • 获取键对应的值:value = m[key]
  • 删除键值对:delete(m, key)
  • 判断键是否存在:value, ok := m[key]

map 的遍历

在 Go 语言中,可以使用 for range 语句来遍历map中的所有键值对,例如:

m := map[string]int{"apple": 1, "banana": 2, "orange": 3}
for key, value := range m {
    fmt.Println(key, value)
}

上面的代码会遍历 map 中所有的键值对,并将键和值分别赋值给 key 和 value 变量。

需要注意的是,在遍历 map 时,键值对的顺序是随机的,因为 map 中的键值对是无序存储的。

map 的底层实现

Go 语言中,map 的底层实现是散列表(hash table)。

散列表是一种常用的数据结构,可以在 O(1) 的时间内进行插入、查找和删除操作。

在 map 中,每个键都会被哈希为一个唯一的哈希值,然后将哈希值与桶(bucket)的数量取模来得到桶的索引,最后将键值对存储在对应的桶中。如果多个键被哈希为相同的哈希值,那么它们会被存储在同一个桶中,形成一个链表。

当 map 的容量不足时,Go 语言会自动扩容 map。扩容时会创建一个新的桶数组,然后将所有的键值对重新哈希到新的桶中。在重新哈希的过程中,如果多个键被哈希为相同的哈希值,那么它们仍然会被存储在同一个桶中,但是它们在桶中的相对顺序可能会发生改变。

哈希冲突

Go 语言中的哈希表实现使用了开放寻址法来解决哈希冲突。哈希表的实现是由 runtime 包提供的,主要包括以下几个结构体:

  • hmap:哈希表的主要结构体,包括哈希表的元素数量、负载因子、桶的数量、哈希种子等信息。
  • bmap:桶的结构体,用于存储哈希表中的键值对,包括哈希值和键值对数组。
  • bucket:键值对的数组,用于存储键值对。

当某个桶已经有键值对时,会从当前桶开始往后查找空闲的桶,直到找到一个空闲桶或者遍历到哈希表的末尾为止。如果找到了空闲桶,则将键值对插入到该桶中;否则,会扩容哈希表,以增加桶的数量。

代码如下:

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    // 计算哈希值
    hash := t.hasher(key, uintptr(h.hash0))
    // 计算哈希表中桶的数量
    bucket := hash & bucketMask(h.B)
    // 获取桶的指针
    b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucket.size)))
    // 遍历桶中的所有键值对
    for i := uintptr(0); i < bucketCnt; i++ {
        // 获取键值对的指针
        x := add(unsafe.Pointer(b), i*t.bucket.stride)
        // 判断是否为空闲桶
        if t.indirectkey {
            if *(*unsafe.Pointer)(x) == nil {
                // 将键插入到空闲桶中
                *(*unsafe.Pointer)(x) = key
                // 返回值的指针
                return add(x, t.keysize)
            }
        } else {
            if t.key.equal(key, *(*unsafe.Pointer)(x)) {
                // 键已经存在,更新值
                return add(x, t.keysize)
            }
            if isEmpty((*unsafe.Pointer)(x)) {
                // 将键插入到空闲桶中
                *(*unsafe.Pointer)(x) = key
                // 返回值的指针
                return add(x, t.keysize)
            }
        }
    }
    // 没有空闲桶,进行扩容操作
    growWork(t, h, bucket)
    // 重新计算桶的指针
    b = (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucket.size)))
    // 将键插入到空闲桶中
    x := unsafe.Pointer(uintptr(add(unsafe.Pointer(b), 0)) + bucketCnt*t.bucket.stride)
    *(*unsafe.Pointer)(x) = key
    // 返回值的指针
    return add(x, t.keysize)
}

并发安全性

map 在并发读写时可能会出现竞态条件(race condition),需要注意并发安全的问题。

常见的做法是使用 sync 包中的 Mutex 类型来进行同步。在对 map 进行读写操作时,需要先获取互斥锁,然后再释放锁。

以下是一个使用互斥锁保证 map 并发安全的示例:

var m = make(map[string]int)
var mu sync.Mutex

func set(key string, value int) {
    mu.Lock()
    defer mu.Unlock()
    m[key] = value
}

func get(key string) int {
    mu.Lock()
    defer mu.Unlock()
    return m[key]
}

set 函数和 get 函数都使用了互斥锁进行同步。

另一种保证 map 并发安全的做法是使用 Go 语言中的 sync 包中的 RWMutex 类型,它支持多个 goroutine 同时读取map,但是只允许一个 goroutine 写入map。

扩容

回到开头我们说:map 会根据需要自动扩容

和 Java 一样,当负载因子达到 0.75 之后就会触发扩容:

  1. 创建一个新的哈希表,将其桶的数量翻倍,并将元素数量设置为 0。

  2. 遍历旧哈希表中的所有桶,将每个桶中的键值对插入到新哈希表中。在插入过程中,如果新哈希表中的桶已经有键值对,则会进行开放寻址的操作来寻找空闲的桶。

  3. 释放旧哈希表中的所有桶,并将旧哈希表的指针指向新哈希表。此时,扩容操作完成