map 实现

[TOC]

除了数组之外,map 应该是最为常用的数据结构了,也叫哈希表,字典,映射表。map 维护着键值对之间映射关系。在没有过多哈希冲突情况下,通过 key 可以实现时间复杂度的 O(1) 快速查找对应的值。

哈希表(hash table)

哈希表用的是类似数组支持按照下标随机访问数据的特性。知道下标就可以O(1)定位对应的值.

哈希表用的就是数组支持按照下标随机访问的时候,时间复杂度是 O(1) 的特性。通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。

在实现 map 时,主要的问题有:

  • 哈希函数怎么设计
  • 哈希冲突怎么解决
  • 哈希扩容rehash问题
  • 哈希缩容问题

装载因子 = 存储元素个数 / 槽位数

哈希函数

哈希函数的选择在很大程度上能够决定 map 的读写性能。

  • 哈希函数映射的结果一定要尽可能均匀分布数据;如果分布不均匀会有更多的哈希冲突,极端情况下就退化为 O(n) 的时间复杂度了。
  • 不能计算复杂,消耗过多CPU;
  • 关键字长度,数据特点,分布,散列表大小
  • 数据分析法,根据数据特点设计哈希函数
  • ASCII码进位相加,取模

哈希冲突解决

就算哈希函数结果分布均匀,很多时候,哈希函数输入的范围都会大于输出的范围,出现哈希冲突,即多个key被映射到同一个槽位的情况还是经常出现的。这就需要解决哈希碰撞的问题,常见方法的就是开放寻址法和链表法。

开放寻址法

开放寻址法: 遇到冲突沿着数组索引 index 往下有找空位插入,也就可能占了别人的位置,(所以哈希冲突太多就不好使了),查找则对应位置被占了,往下找,知道找到或者遇到空位。(线性探测)

index := hash(key) % array.len  // 计算哈希值,并取模
  • 为了查找方便,需要标记已删除的数据位置,
  • 开放寻址法来实现,底层的数据结构通常是数组,由于是数组存储,对cpu缓存相对友好,但是不能存储太大量的数据(哪有那么多的连续的内存空间啊)。
  • 但冲突代价更高,装载因子不能太大,超过 70% ,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。内存间利用率不高;
  • 所以开始寻址法,需要关注装载因子,对性能影响至关重要。

链表法

与开放地址法相比,链表法是哈希表最常见的实现方法,大多数的编程语言都用链表法实现哈希表。可以理解为经过哈希映射后对应一个 bucket。

当多个 key 被映射到同一个 bucket 后,通过链表把他们连接起来。

  • 相比开发寻址平均查找长度会小一些。
  • 存储value的内存可以动态申请的,更节省内存。

  • 相比开发寻址,装载因子相对可以大很多,可存储的数据量更大
  • 如果存储小对象,指针占用空间比例会大;
  • bucket 值之间链表存储,对cpu缓存不够友好;

二次探测,

哈希算法

将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法

要点:

  1. 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
  2. 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同;
  3. 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
  4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

知名应用:md5,sha,crc 全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。

MD5哈希值是固定的 128 位二进制串.最多能表示 2^128 个数据

rehash

扩容 当装载因子较大时,需要申请新的空间,把数据搬过去,搬迁时可能还需要重新计算哈希值,如果数据量很大时,可能导致变慢。

动态扩容,比如当装载因子达到某个阈值如0.75时,重新申请翻倍的空间;新数据插入新的空间,旧数据分批搬过去。(期间的查找,先找新的空间,没找到去老的空间找)

Go Map 实现

Go 语言运行时同时使用了多个数据结构组合表示哈希表,其中使用 hmap (src/runtime/map.go) 结构体来表示哈希:

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

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

02.go-map-hashgrow

hmap.buckets 是一个指针,指向的是一个 runtime.bmap 结构体,它就是所谓的“桶”或者“槽位”,每个 runtime.bmap 能存储8个key。

每个桶在申请内存时,会分配一片连续的内存空间。

type bmap struct {
	 tophash [bucketCnt]uint8 // 存储key哈希值的高8位
}

tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能。

使用位图,一个字节正好8位。来提高是否存在的效率。

在编译期间,桶会被动态创建为新的结构体:

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

创建

初始化时,可以通过字面量和 make() 函数两种方法进行创建。

make(map[k]v, hint)
  • 如果map被分配到栈上,且 hint <= 8(bucketSize) 时或缺省hint 都会调用 runtime.makemap_small 来进行快速初始化,
  • 如果 hint > 8,则调用 runtime.makemap(),这是主要的初始化函数。
func makemap(t *maptype, hint int, h *hmap) *hmap {
	mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
	if overflow || mem > maxAlloc { // 内存检查
		hint = 0
	}

	if h == nil {
		h = new(hmap)
	}
	h.hash0 = fastrand()  // 获取哈希种子

    // 根据装载因子寻找一个合适的 B
	B := uint8(0)
	for overLoadFactor(hint, B) {
		B++
	}
	h.B = B
	
    // 如果 B 等于 0,跳过, buckets 就会在赋值的时候再分配
	if h.B != 0 {
		var nextOverflow *bmap
		h.buckets, nextOverflow = makeBucketArray(t, h.B, nil) // 分配一片连续的空间
		if nextOverflow != nil {
			h.extra = new(mapextra)
			h.extra.nextOverflow = nextOverflow
		}
	}
	return h
}

计算内存是否超出最大值。

获取哈希种子。

计算出需要的最小需要的桶的数量。

根据传入的 B 计算出的需要创建的桶数量并使用 runtime.makeBucketArray 在内存中分配一片连续的空间,用于存储数据。

正常桶和溢出桶在内存中的存储空间是连续的,只是被 runtime.hmap 中的不同字段引用,当溢出桶数量较多时会通过 runtime.newobject 创建新的溢出桶。

查找

在访问 map 时,根据访问方式和平台主要有 runtime.mapaccess1,runtime.mapaccess2,runtime.mapaccessK 等一系列方法,实现打大同小异。

根据 key 通常访问一个 map 主要有两种方式是

  1. v := hash[key], 使用 runtime.mapaccess1,该函数仅会返回一个指向目标值的指针;
  2. v,ok := hash[key],使用 runtime.mapaccess2,除了返回目标值之外,它还会返回一个用于表示该键值是否存在的 bool 值。
// go1.15
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
	...
    // 并发写和读冲突检测
	if h.flags&hashWriting != 0 {
		throw("concurrent map read and map write")
	}
	hash := t.hasher(key, uintptr(h.hash0))
    // 用于计算bucket的掩码,如果 B = 3,那么结果用二进制表示就是 111
    // 如果 B = 4,那么结果用二进制表示就是 1111
	m := bucketMask(h.B)
    // 根据掩码 m ,按位与运算,找到出对应序号的 bucket 地址
	b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
	if c := h.oldbuckets; c != nil { // oldbuckets 不为 nil,说明发生了扩容
		if !h.sameSizeGrow() {
			// 新bucket是旧的2倍,之前只有一半的 bucket,需要除 2
			m >>= 1
		}
        // 求出 key 在老的 map 中的 bucket 位置
		oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize)))
		if !evacuated(oldb) {
			b = oldb
		}
	}
    
     // tophash 取哈希值的高 8 位的值
	top := tophash(hash)
bucketloop: // 循环查找 tophash 和 key 是否存在
	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 t.indirectkey() { // 如果 key 是指针的话,还需要找到引用的对象
				k = *((*unsafe.Pointer)(k))
			}
			if t.key.equal(key, k) { // key 相等时根据 offset 获取value值
				e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
				if t.indirectelem() {
					e = *((*unsafe.Pointer)(e))
				}
				return e, true  // 找到,并返回value
			}
		}
	}
	return unsafe.Pointer(&zeroVal[0]), false //没找到返回零值
}

在 64 为机器上,key 经过哈希计算后得到哈希值,共 64 个 bit 位。

  • 高八位存储 tophash
  • 假设 B = 5,则 bucket 数量为 2^5 = 32 个。
  • 根据 B 的值,哈希值低 5位用于表示当前的 key 位于的bucket 中。

上述 bucketloop 就是查找的核心逻辑,大致查找过程为:

  • 查找时遍历 bmap.tophash 数组找到 tophash 的index,如果没有则说明不在这个桶里。
  • 然后根据找到 tophash 的index 和 bmap 的地址定位到 key 的位置,比较key 是否相等。
  • 如果 key 相等,则返回对应 index 的 value 值。
  • 如果没找到,且 overflow bucket 不为空,还会继续寻找 *overflow bucket,过程类似。

02.go-map-search

如何从哈希的高8位找到 bmap 中的 tophash 的?

  • for 循环遍历,先 bmap 在 tophash 数组,遍历次数装载因子和溢出桶数量而增大。

overflow bucket 什么时候使用?

  • 同一个bucket 桶的bmap 自多存 8 个 key,满了之后就会使用到溢出桶。

整理

  • 查找时,如果存在并发的写操作,会导致 panic
  • 不同类型的 key,所用的 hash 算法是不一样,在编译期确定具体可以参考 algarray。

  • 查找过程还是用到for循环,数组的遍历,当然次数通常只有数次,不会随着数据样本线性增长。

  • 所以对于数据样本不多的情况下,直接遍历数组的查找性比map能会更好。

赋值

在理解了 map 的内存布局,查找过程,写入的逻辑理解起来就相对简单些了。map 写入的核心函数为 runtime.mapassign()

mapassign 有几个变种,是由编译器决定具体用哪一个函数的。选择依据 key 的类型是否是 string,如果不是 string,那么根据 key 的类型大小做选择。

  • 先函数会根据传入的键拿到对应的哈希和桶。
  • 遍历正常桶和 overflow 桶,比较桶中存储的 tophash 和键的哈希,如果找到了相同结果就会返回目标位置的地址。
  • 如果桶都满了,会调用 runtime.hmap.newoverflow 创建新桶,通过 overflow 指针,连接到现在通上。
  • 如果当前没找到,会为其分配新的存储空间,并返回对应的地址。
  • runtime.mapassign() 中没有直接把value写进去,而是只节返回地址。赋值操作是由额外的汇编函数 runtime.mapassign_fast64(SB) 完成的, 这是在编译期间,由编译器插入的。这个可以通过go程序对应的汇编代码看到。

02.go-map-overflow-bucket

从上述图可以看出,如果桶溢出,多个 bmap 之间是通过 overflow 指针连接成了一个链表。

  • 所以 Go map 是使用 “链表发” 解决哈希冲突问题的。在查找 key 时相当于两层循环,外层遍历同一个 bucket 槽位的 bmap 链表。
  • 里面循环遍历 tophash 数组。

溢出桶其实也算是链表发解决冲突的一种实现,过多的溢出桶,或过高的装载因子会带来 map 读写性能的下降,最终需要冲突 map 扩容来解决。

在 map 的赋值过程中,还涉及到 map 扩容的逻辑。

扩容

什么时候会触发扩容?

  • biggerSizeGrow :装载因子 loadFactor >= 6.5 , 即元素个数 >= 桶个数 * 6.5 。
  • sameSizeGrow: 使用了太多 overflow 桶,进行 sameSizeGrow 扩容。
    • 当 bucket 总数 < 2^15 时,如果 overflow 桶总数 >= bucket 的总数,则认为 overflow 的桶太多了。
    • 当 bucket 总数 >= 2^15 时,那我们直接和 2^15 比较,overflow 桶 >= 2^15 时,即认为溢出桶太多了。
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
	}
	...
}

装载因子过大时,说明大部分桶都快满了。如果插入新元素,有大概率需要挂在 overflow 的桶上。扩容时桶的数量加倍,也就是 B = B + 1。

等量扩容

如果溢出桶太多,会导致扩容,这种是等量扩容 sameSizeGrow 。等量还能叫扩容?这种场景,主要是针对:由于数据不断的插入,桶数量会增加,随着删除桶存储空间会变的稀疏,造成内存利用率不高。通过适当的移动 bucket 中的值,创建新的桶,旧的溢出桶会被GC回收,来实现存储更加紧凑,提高内存利用率。

扩容的主要逻辑在 runtime.hashGrow 中。

func hashGrow(t *maptype, h *hmap) {
	// 如果已经超过了 load factor 的阈值,那么需要对 map 进行扩容,即 B = B + 1,bucket 总数会变为原来的二倍
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		bigger = 0
		h.flags |= sameSizeGrow // 如果还没到阈值,那么只需要保持相同数量的 bucket,横向等量扩容
	}
	oldbuckets := h.buckets
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

	flags := h.flags &^ (iterator | oldIterator)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
    
	// 提交扩容结果
	h.B += bigger  // 增加 B 来扩容桶的数量,bigger 为0或1
	h.flags = flags
	h.oldbuckets = oldbuckets   // 调整指针指向旧的 bucket
	h.buckets = newbuckets		// 调整指针指向新的 bucket
	h.nevacuate = 0
	h.noverflow = 0

    // 调整溢出桶的指针
	if h.extra != nil && h.extra.overflow != nil {
		// Promote current overflow buckets to the old generation.
		if h.extra.oldoverflow != nil {
			throw("oldoverflow is not nil")
		}
		h.extra.oldoverflow = h.extra.overflow
		h.extra.overflow = nil
	}
	if nextOverflow != nil {
		if h.extra == nil {
			h.extra = new(mapextra)
		}
		h.extra.nextOverflow = nextOverflow
	}
 
	// the actual copying of the hash table data is done incrementally
	// by growWork() and evacuate().
}

在扩容的过程中会通过 runtime.makeBucketArray 创建一组新桶和预创建的溢出桶,然后将原有的桶数组设置到 hmap.oldbuckets 指针上并将新的空桶设置到 hmap.buckets 指针上。

在 runtime.evacuate 中,会进行 rehash 数据的迁移。

func growWork(t *maptype, h *hmap, bucket uintptr) {
	evacuate(t, h, bucket&h.oldbucketmask())
	if h.growing() {
		evacuate(t, h, h.nevacuate)
	}
}

在 map 赋值和删除中,都可能触发 runtime.growWork 执行。

缩容

什么? Go 的 map 是不会缩容的?可以看下这个 https://github.com/golang/go/issues/20135

删除

mapdelete

todo

问题及思考

为什么 map 的遍历 key 是无序的?

  • 在用 map 进行存储时, key 与 bucket 数据之间并没有明显的线性关系,尤其是在进行哈希扩容和缩容后,往往 key 的存储顺序会发生较大的变化。
  • 根据 map 特点,key的顺序不是非常的关键,在实现时,优先关注的是哈希函数结果是否分布均匀,开销是否小,内存利用率是否高。rehash成本等等。作为语言基础且使用高频数据结构性能很重要。
  • Go 的map 每次遍历,有一个哈希中子,引入随机性,可以避免工程师对 map 的 key 顺序的假设和依赖。
  • 其实如果需要一个映射表的key 顺序固定,也是可以实现的,有些第三方的库就提供了此功能。但作为 语言的基础的 map,权衡投入收益没必要。

map是线程安全的吗,能支持并发读吗?为什么?

  • 不是线程安全的,可以只并发的读
  • 通过写标志为检测,在并发的写是会导致 panic
  • 为什么不是线程安全的?没必要,map 的使用大部分场景不需要并发的写,增加线程安全会带来额外的开销。
  • 如果需要考虑线程安全问题,可以使用 sync.Map 。或者其他的如写时复制,lockfree 等实现。

可以为map的元素取址吗,为什么?

  • 因为 value 的地址随着 rehash 的变化的,功能不可以对 map 的value地址做假设。语言编译就做了限制,常规手段,是不能取址的。
  • 如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生rehash,key 和 value 的位置就会改变,旧地址也就失效了。

如何比较两个map相等?

开放寻址法与链表发解决哈希冲突,各有什么优劣?

开放寻址

  • 数组存储的索引和值存储数据,实现简单,数组对 CPU cacheline 相对更友好。
  • 遇到冲突根据index后移,装载因子不能太大,通常超过70%,就会造成大量冲突,寻址。
  • 内存利用率不高,数组存储需要连续内存,一定程度限制了大小。

链表法

  • 在每个槽位内,通过链表实现多个value的存储。
  • 内存可以根据需要申请,利用率更高。
  • 可以接受更大的装载因子,存更多的数据。
  • 链表存储,存小对象时,指针占空间多,没有数组对 CPU 缓存友好。

go map 是怎么根据 key 找到对应的值的?

  • 先根据 key 计算出哈希值
  • 根据哈希值的低 B 位找到 bucket ,遍历 bucket 中的bmap 和 溢出桶,先找到丢 tophash 的 index 再凭此比较 key,找到 value。

哪些情况会触发 map 扩容?

  • 装载因子过高,大于 6.5。
  • 溢出桶太多,进行等量扩容,调整桶存储内容分布,创建新的桶,就的溢出桶会被GC回收,使得存储空间紧凑。

目前的 go map 会收缩吗?

  • 不会
  • https://github.com/golang/go/issues/20135

reference

https://blog.golang.org/maps

https://github.com/cch123/golang-notes/blob/master/map.md

https://github.com/golang/go/issues/16070

https://github.com/golang/go/issues/20135

更新时间: