golang源码阅读之map

golang源码阅读之map

基础结构

基础结构:golang中的map是什么样子的,是由什么数据结构组成的?
初始化:初始化之后map是怎么样的?
get:如何获取一个元素?
put:如何存放一个元素?
扩容:当存放空间不够的时候扩容是怎么扩的?
基础结构
在这里插入图片描述

最外面是hmap结构体,用buckets存放一些名字叫bmap的桶(数量不定,是2的指数倍)
bmap是一种有8个格子的桶(一定只有8个格子),每个格子存放一对key-value
bmap有一个overflow,用于连接下一个bmap(溢出桶)
hmap还有oldbuckets,用于存放老数据(用于扩容时)
mapextra用于存放非指针数据(用于优化存储和访问),内部的overflow和oldoverflow实际还是bmap的数组。

注意源码中的样子和编译之后是不同的,golang会根据map存放的类型不同来搞定它们实际的样子

type hmap struct {
	    // 元素个数,调用 len(map) 时,直接返回此值
		count     int
		flags     uint8
		// buckets 的对数 log_2
		B         uint8
		// overflow 的 bucket 近似数
		noverflow uint16
		// 计算 key 的哈希的时候会传入哈希函数
		hash0     uint32
	    // 指向 buckets 数组,大小为 2^B
	    // 如果元素个数为0,就为 nil
		buckets    unsafe.Pointer
		// 扩容的时候,buckets 长度会是 oldbuckets 的两倍
		oldbuckets unsafe.Pointer
		// 指示扩容进度,小于此地址的 buckets 迁移完成
		nevacuate  uintptr
		extra *mapextra // optional fields
	}

buckets 是一个指针,最终它指向的是一个结构体:

type bmap struct {
tophash [bucketCnt]uint8
}

但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

那么看完结构你肯定会有疑问?为什么要多一层8个格子的bucket呢?我们怎么确定放在8个格子其中的哪个呢?带着问题往下看。

初始化

func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
		// 省略各种条件检查...
	
		// 找到一个 B,使得 map 的装载因子在正常范围内
		B := uint8(0)
		for ; overLoadFactor(hint, B); B++ {
		}
	
		// 初始化 hash table
		// 如果 B 等于 0,那么 buckets 就会在赋值的时候再分配
		// 如果长度比较大,分配内存会花费长一点
		buckets := bucket
		var extra *mapextra
		if B != 0 {
			var nextOverflow *bmap
			buckets, nextOverflow = makeBucketArray(t, B)
			if nextOverflow != nil {
				extra = new(mapextra)
				extra.nextOverflow = nextOverflow
			}
		}
	
		// 初始化 hamp
		if h == nil {
			h = (*hmap)(newobject(t.hmap))
		}
		h.count = 0
		h.B = B
		h.extra = extra
		h.flags = 0
		h.hash0 = fastrand()
		h.buckets = buckets
		h.oldbuckets = nil
		h.nevacuate = 0
		h.noverflow = 0
	
		return h
	}

中需要注意一个点:“B”,还记得刚才说名字叫bmap的桶数量是不确定的吗?这个B一定程度上表示的就是桶的数量,当然不是说B是3桶的数量就是3,而是2的3次方,也就是8;当B为5,桶的数量就是32;记住这个B,后面会用到它。
其实你想嘛,初始化还能干什么,最重要的肯定就是确定一开始要有多少个桶,初始的大小还是很重要的,还有一些别的初始化哈希种子等等,问题不大。我们的重点还是要放在存/取上面。

GET

图解
其实从结构上面来看,我们已经可以摸到一些门道了。先自己想一下,要从一个hashmap中获取一个元素,那么一定是通过key的哈希值去定位到这个元素,那么想着这个大致方向,看下面一张流程图来详细理解golang中是如何实现的。

计算出key的hash
用最后的“B”位来确定在哪个桶(“B”就是前面说的那个,B为4,就有16个桶,0101用十进制表示为5,所以在5号桶)
根据key的前8位快速确定是在哪个格子(额外说明一下,在bmap中存放了每个key对应的tophash,是key的前8位)
最终还是需要比对key完整的hash是否匹配,如果匹配则获取对应value
如果都没有找到,就去下一个overflow找
总结一下:通过后B位确定桶,通过前8位确定格子,循环遍历连着的所有桶全部找完为止。
那么为什么要有这个tophash呢?因为tophash可以快速确定key是否正确,你可以把它理解成一种缓存措施,如果前8位都不对了,后面就没有必要比较了。

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
		// ……
		
		// 如果 h 什么都没有,返回零值
		if h == nil || h.count == 0 {
			return unsafe.Pointer(&zeroVal[0])
		}
		
		// 写和读冲突
		if h.flags&hashWriting != 0 {
			throw("concurrent map read and map write")
		}
		
		// 不同类型 key 使用的 hash 算法在编译期确定
		alg := t.key.alg
		
		// 计算哈希值,并且加入 hash0 引入随机性
		hash := alg.hash(key, uintptr(h.hash0))
		
		// 比如 B=5,那 m 就是31,二进制是全 1
		// 求 bucket num 时,将 hash 与 m 相与,
		// 达到 bucket num 由 hash 的低 8 位决定的效果
		m := uintptr(1)<<h.B - 1
		
		// b 就是 bucket 的地址
		b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
		
		// oldbuckets 不为 nil,说明发生了扩容
		if c := h.oldbuckets; c != nil {
		    // 如果不是同 size 扩容(看后面扩容的内容)
		    // 对应条件 1 的解决方案
			if !h.sameSizeGrow() {
				// 新 bucket 数量是老的 2 倍
				m >>= 1
			}
			
			// 求出 key 在老的 map 中的 bucket 位置
			oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
			
			// 如果 oldb 没有搬迁到新的 bucket
			// 那就在老的 bucket 中寻找
			if !evacuated(oldb) {
				b = oldb
			}
		}
		
		// 计算出高 8 位的 hash
		// 相当于右移 56 位,只取高8位
		top := uint8(hash >> (sys.PtrSize*8 - 8))
		
		// 增加一个 minTopHash
		if top < minTopHash {
			top += minTopHash
		}
		for {
		    // 遍历 8 个 bucket
			for i := uintptr(0); i < bucketCnt; i++ {
			    // tophash 不匹配,继续
				if b.tophash[i] != top {
					continue
				}
				// tophash 匹配,定位到 key 的位置
				k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
				// key 是指针
				if t.indirectkey {
				    // 解引用
					k = *((*unsafe.Pointer)(k))
				}
				// 如果 key 相等
				if alg.equal(key, k) {
				    // 定位到 value 的位置
					v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
					// value 解引用
					if t.indirectvalue {
						v = *((*unsafe.Pointer)(v))
					}
					return v
				}
			}
			
			// bucket 找完(还没找到),继续到 overflow bucket 里找
			b = b.overflow(t)
			// overflow bucket 也找完了,说明没有目标 key
			// 返回零值
			if b == nil {
				return unsafe.Pointer(&zeroVal[0])
			}
		}
	}

PUT

其实当你知道了如何GET,那么PUT就没有什么难度了,因为本质是一样的。PUT的时候一样的方式去定位key的位置:
通过key的后“B”位确定是哪一个桶
通过key的前8位快速确定是否已经存在
最终确定存放位置,如果8个格子已经满了,没地方放了,那么就重新创建一个bmap作为溢出桶连接在overflow
图解
在这里插入图片描述
这里主要图解说明一下,如果新来的key发现前面有一个格子空着(这个情况是删除造成的),就会记录这个位置,当全部扫描完成之后发现自己确实是新来的,那么就会放前面那个空着的,而不会放最后

扩容

扩容的方式
相同容量扩容
2倍容量扩容
啥意思呢?第一种出现的情况是:因为map不断的put和delete,出现了很多空格,这些空格会导致bmap很长,但是中间有很多空的地方,扫描时间变长。所以第一种扩容实际是一种整理,将数据整理到前面一起。第二种呢:就是真的不够用了,扩容两倍。
扩容的条件
装载因子
装载因子的定义是这个样子:
loadFactor := count / (2^B)
其中count为map中元素的个数,B就是之前个那个“B”
翻译一下就是装载因子 = (map中元素的个数)/(map当前桶的个数)
扩容条件1
装载因子 > 6.5(这个值是源码中写的)
其实意思就是,桶只有那么几个,但是元素很多,证明有很多溢出桶的存在(可以想成链表拉的太长了),那么扫描速度会很慢,就要扩容。
扩容条件2
overflow 的 bucket 数量过多:当 B 小于 15,如果 overflow 的 bucket 数量超过 2^B ;当 B >= 15,如果 overflow 的 bucket 数量超过 2^15 。
其实意思就是,元素不多,溢出桶太多了,说白了就是,元素太分散。
扩容条件3
当前不能正在扩容
图解
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nA3yKoK7-1577962786430)(https://i.imgur.com/Sw64zsZ.jpg)]

这张图表示的就是相同容量的扩容,实际上就是一种整理,将分散的数据集合到一起,提高扫描效率。(上面表示扩容之前,下面表示扩容之后)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MFYdzeUF-1577962786430)(https://i.imgur.com/1WZR3Ac.jpg)]

这张图表示的是就是2倍的扩容(上面表示扩容之前,下面表示扩容之后),如果有两个key后三位分别是001和101,当B=2时,只有4个桶,只看最后两位,这两个key后两位都是01所以在一个桶里面;扩容之后B=3,就会有8个桶,看后面三位,于是它们就分到了不同的桶里面。

下面说一些扩容时的细节:
扩容不是一次性完成的,还记的我们hmap一开始有一个oldbuckets吗?是先将老数据存到这个里面
每次搬运1到2个bucket,当插入或修改、删除key触发
扩容之后肯定会影响到get和put,遍历的时候肯定会先从oldbuckets拿,put肯定也要考虑是否要放到新产生的桶里面去
扩容的三个条件,看到了吗?这个地方在mapassign方法中。

hashGrow() 函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候

func hashGrow(t *maptype, h *hmap) {
	// B+1 相当于是原来 2 倍的空间
	bigger := uint8(1)

	// 对应条件 2
	if !overLoadFactor(int64(h.count), h.B) {
		// 进行等量的内存扩容,所以 B 不变
		bigger = 0
		h.flags |= sameSizeGrow
	}
	// 将老 buckets 挂到 buckets 上
	oldbuckets := h.buckets
	// 申请新的 buckets 空间
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger)

	flags := h.flags &^ (iterator | oldIterator)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
	// 提交 grow 的动作
	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	// 搬迁进度为 0
	h.nevacuate = 0
	// overflow buckets 数为 0
	h.noverflow = 0

	// ……
}

真正执行搬迁工作的 growWork() 函数。

func growWork(t *maptype, h *hmap, bucket uintptr) {
	// 确认搬迁老的 bucket 对应正在使用的 bucket
	evacuate(t, h, bucket&h.oldbucketmask())

	// 再搬迁一个 bucket,以加快搬迁进程
	if h.growing() {
		evacuate(t, h, h.nevacuate)
	}
}

evacuate是搬运方法,这边可以看到,每次搬运是1到2个 (代码太多)

func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
	// 定位老的 bucket 地址
	b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
	// 结果是 2^B,如 B = 5,结果为32
	newbit := h.noldbuckets()
	// key 的哈希函数
	alg := t.key.alg
	// 如果 b 没有被搬迁过
	if !evacuated(b) {
		var (
			// 表示bucket 移动的目标地址
			x, y   *bmap
			// 指向 x,y 中的 key/val
			xi, yi int
			// 指向 x,y 中的 key
			xk, yk unsafe.Pointer
			// 指向 x,y 中的 value
			xv, yv unsafe.Pointer
		)
		// 默认是等 size 扩容,前后 bucket 序号不变
		// 使用 x 来进行搬迁
		x = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
		xi = 0
		xk = add(unsafe.Pointer(x), dataOffset)
		xv = add(xk, bucketCnt*uintptr(t.keysize))// 如果不是等 size 扩容,前后 bucket 序号有变
		// 使用 y 来进行搬迁
		if !h.sameSizeGrow() {
			// y 代表的 bucket 序号增加了 2^B
			y = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
			yi = 0
			yk = add(unsafe.Pointer(y), dataOffset)
			yv = add(yk, bucketCnt*uintptr(t.keysize))
		}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值