map源码解析 | 青训营;

125 阅读2分钟

map源码解析

1 map 的初始化

常用的 map 初始化方式有两种:一种是字面量初始化,一种是使用 make

m1 := map[string]int{"a":1, "b":2} //字面量初始化
m2 := make(map[string]int, 10)     //使用make初始化

查看一下程序的编译结果 Untitled.png

无论是使用字面量初始化方式,还是使用 make 初始化方式,都是通过调用 runtime 库里的 makemap_small()makemap() 函数来进行初始化的。

区别在于,如果初始化的键值对数量或容量的小于或等于8,就会使用 makemap_small() ,大于8则会使用 makemap() 。判断初始化 map 容量大小的操作由编译器在编译时完成,并确定调用哪个函数。

makemap()makemap_small() 的函数签名分别如下:

func makemap_small() *hmap
func makemap(t *maptype, hint int, h *hmap) *hmap

从函数签名可知,map 的本质是一个指向 hmap 结构体的指针。

2 map 的结构

// hmap 是 Go map 的底层结构
// 先有个印象,一个 hamp 下会有多个哈希桶(bmap)
type hmap struct {
	count     int 
	flags     uint8
	B         uint8  // 哈希桶的数量的log2,如果有8个桶,则B=3,即有2^B个桶
									 // loadFactor 表示每个桶的期望负载,则最多能容纳 loadFactor * 2^B 个键值对
	noverflow uint16 // 溢出桶的数量(估计)
	hash0     uint32 // 哈希种子

	buckets    unsafe.Pointer // 指向哈希桶数组的指针,这个哈希桶数组有2^B个桶
	oldbuckets unsafe.Pointer // 扩容时指向旧桶的指针,只有当扩容时才不为nil
	nevacuate  uintptr        // 

	extra *mapextra // 可选字段
}

对 hmap 有印象后,我们再看看 buckets 这个指针。

makemap() 中,它被下面这段代码赋值

Untitled 1.png

追溯 makeBucketArray() ,……

buckets 数组中的每一个桶都是一个 bmap 结构体

// 每一个桶都是一个 bmap 结构体
type bmap struct {
	// tophash generally contains the top byte of the hash value
	// for each key in this bucket. If tophash[0] < minTopHash,
	// tophash[0] is a bucket evacuation state instead.
	tophash [bucketCnt]uint8
  // 其实,bmap 结构体还包括另外三个字段,但是查看代码时是看不到的。怎么实现的?
  // 分别是 keys 数组,elems 数组,和 overflow 指针
  // 之所以没写死,是因为 key 和 elem 的类型是不确定的,需要编译时才能确定
  // keys 是一个数组,大小为 bucketCnt, 存放 key,实质上 bucketCnt = 8
  // elems 是一个数组,大小为 bucketCnt, 存放 Value
  // 之所以将所有key存放在一个数组,将value存放在一个数组,
  // 而不是键值对的形式(key/value/key/value...这样交替出现)
  // 是为了消除例如map[int64]int8所需的填充(内存对齐)
	// overflow 是一个指针,指向溢出桶,当该桶不够用时,才会使用溢出桶
}

至此,我们对 map 的理解可以用下面这个图表示 Untitled 2.png

3 map 的访问(查找)

根据上面的理解,在map中查找一个键值对,首先要获取桶号,然后逐个比较该桶和溢出桶中的tophash(哈希高八位)的值。如果 tophash 匹配成功,则说明有可能 key 相等,接着验证key值是否相等,如果相等就说明找到了。

我们在程序中访问 map 通常有两种返回值情形,一种只获得值,一种获得值以及表示是否查找到的布尔值

val := m["a"] // 只获得值
val, ok := m["a"] // 获得值以及表示是否查找到的布尔值

这在 runtime 库中对应两个函数,其签名分别为

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

我们发现其实,这里返回的值其实都是指向对应值的指针,注意这里永远不返回 nil,如果对应的 key 不存在,则返回值类型对应的零值。

// 以 mapaccess1 为例,我们看下具体的查找过程
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	// ...略
  // 如果哈希表 h 为 nil 或 元素数量为 0,则返回零值
  // 特别当哈希表 h 为 nil 时,再继续往下很可能会引起 Panic 所以一定要提前返回零值
	if h == nil || h.count == 0 {
		if t.hashMightPanic() {
			t.hasher(key, 0) // see issue 23734
		}
		return unsafe.Pointer(&zeroVal[0])
	}

	// 防止 map 并发读写,如果检测到并发读写就 panic
	if h.flags&hashWriting != 0 {
		fatal("concurrent map read and map write")
	}

	// 对 key 和 hash0 进行哈希,计算哈希值
	hash := t.hasher(key, uintptr(h.hash0))
  // 计算桶号
  // 计算桶掩码m,之后用这个掩码 m & hash(与上hash)就可以得到桶号
  // 桶掩码的计算实质是 将 1 右移 B 位之后减1 得到的,其实就是低B位为1,高位为0
  // 也就是说,桶号就是 hash 的低 B 位
	m := bucketMask(h.B)
  // 这里根据桶号,桶大小,桶数组起始地址,来计算得到指向对应桶的指针
	b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
  // 如何h.oldbuckets != nil,说明此时map正在扩容,需要判断当前要访问的key是在新桶中还是在旧桶中
  // 总之下面这段代码会将 b 指向合适的新桶/旧桶
	if c := h.oldbuckets; c != nil {
		if !h.sameSizeGrow() {
			// There used to be half as many buckets; mask down one more power of two.
			m >>= 1
		}
		oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
		if !evacuated(oldb) {
			b = oldb
		}
	}

	// 获得 tophash 即hash 的高八位
	top := tophash(hash)
  // 如果算出来的桶不为 nil 进入查找一次,查不到就会去找溢出桶,直到 b 为 nil
bucketloop:
	for ; b != nil; b = b.overflow(t) {
    // 进入桶后,逐个比较 tophash,找到匹配的那一个才往下
		for i := uintptr(0); i < bucketCnt; i++ {
			if b.tophash[i] != top {
				if b.tophash[i] == emptyRest {
					break bucketloop
				}
				continue
			}
      // 匹配到 tophash 后,通过桶地址、数据起始地址和索引i以及键大小来计算指向键的指针
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
			if t.indirectkey() {
				k = *((*unsafe.Pointer)(k))
			}
      // 比较键是否相等,相等就找到了,计算指向值的指针并返回,
      // 注意这里用了bucketCnt,说明一个桶的空间是都分配了的,不管有没有填
      // 否则就继续往下匹配 tophash
			if t.key.equal(key, k) {
				e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
				if t.indirectelem() {
					e = *((*unsafe.Pointer)(e))
				}
				return e
			}
		}
	}
  // 没找到就返回零值
	return unsafe.Pointer(&zeroVal[0])
}

4 map 的扩容

map 为什么要扩容呢?

首先就是当可用空间不足时就需要扩容。其次当哈希碰撞比较严重时,很多数据都会落在同一个桶中,那么就会导致越来越多的溢出桶被链接起来。这样的话,查找的时候最坏的情况就是要遍历整个链表,时间复杂度很高,效率很低。而且当删除了很多元素后,可能会导致虽然有很多溢出桶,但是桶中的元素很稀疏。

map扩容的时机:

  • 达到最大的负载因子(6.5,也就是平均每个桶中k-v的数量大于6.5)
  • 溢出桶的数量太多

扩容发生时机是在插入时判断的,

Untitled 3.png

map扩容的类型:

  • 等量扩容:数据不多但是溢出桶太多了(整理)
  • 翻倍扩容:数据太多了

等量扩容,溢出桶太多了,导致查询效率低。扩容时,桶的数量不增加。

翻倍扩容,每个桶的k-v太多了,需要增加普通桶的数量,扩容后桶的数量为原来的两倍。

3.1 map扩容的步骤

步骤1:创建新桶

  • 创建一组新桶
  • oldbuckets指向原有的桶数组
  • buckets指向新的桶的数组
  • map标记为扩容状态

步骤2:迁移数据 将所有的数据从旧桶驱逐到新桶 采用渐进式驱逐 每次操作一个旧桶时(插入、删除数据),将旧桶数据驱逐到新桶 读取时不进行驱逐,只判断读取新桶还是旧桶

在每次插入或删除数据时,会判断map是否在扩容,如果在扩容就需要执行一些扩容的工作,也就是将旧桶中的数据驱逐到新桶,会驱逐当前要操作的桶及其溢出桶。

假设原来的桶的数量为4,那么B为2。当进行翻倍扩容后,桶的数量为8,B为3。那么这里就体现出了使用B的智慧,扩容后B为3,那么取哈希值的二进制后三位就有两种情况:010b和110b,分别是2和6。所以原来二号桶中的数据就会分布到新桶数组的2号和6号桶中。

步骤3:回收旧桶

  • 所有旧桶驱逐完成后,回收oldbuckets

3.2 扩容时访问数据

在map扩容期间访问map需要确定数据是在旧桶还是在新桶中,也就是判断数据是否已经被疏散到新桶中。

相关资料

blog.csdn.net/Peerless__/…

www.cnblogs.com/iiiiiher/p/…

blog.csdn.net/kid_14_12/a…

www.jianshu.com/p/cc15ee95f…

go.godbolt.org/

4. map 的 key 可以是 nil 吗

不可以。

5. 怎么实现一个线程安全的map

  1. 加锁。对整个map加上读写锁 sync.RWMutex
package main

import (
	"sync"
)

type RWMap struct {
	Map map[int]int
	sync.RWMutex
}

func NewRWMap(capacity int) *RWMap {
	if capacity < 0 {
		capacity = 0
	}
	return &RWMap{
		Map: make(map[int]int, capacity),
	}
}

func (m *RWMap) Set(key, value int) {
	m.Lock()
	defer m.Lock()
	m.Map[key] = value
}

func (m *RWMap) Delete(key int) {
	m.Lock()
	defer m.Lock()
	delete(m.Map, key)
}

func (m *RWMap) Get(key int) int {
	m.RLock()
	defer m.RUnlock()
	return m.Map[key]
}

优点是实现简单,缺点是锁粒度大。

  1. 分段加锁
  2. sync.Map