Go 中 map 实现原理与源码解析

本文详细探讨了 Go 语言中 map 的实现原理,包括哈希表概念、哈希函数、解决哈希冲突的开放寻址法与链地址法、map 数据结构、初始化、哈希函数、基本操作如查找、写入、扩容和删除,以及遍历 map 的过程。文中指出,Go 采用链地址法解决冲突,实际存储中使用数组加链表的形式,并在扩容时采用渐进式搬迁策略,确保高效性。
摘要由CSDN通过智能技术生成

全文四万多字,包含了对 map 的相关原理还有源码的深度解析,文中的图片大都来自文末的参考文章,如果有哪个地方写的不对,欢迎批评指正!!!

Go 中的 map 使用的是链地址法解决哈希冲突,但是它的实现并不是对冲突的元素采用链表存储,而是采用了数组的形式。

哈希表相关概念

哈希表是计算机科学中的最重要数据结构之一,这不仅因为它 O(1) 的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点 —— 哈希函数和冲突解决方法。

哈希函数

哈希函数(常被称为散列函数)是可以用于将任意大小的数据映射到固定大小值的函数,常见的包括MD5、SHA系列等。实现哈希表的关键点在于哈希函数的选择,哈希函数的选择在很大程度上能够决定哈希表的读写性能。在理想情况下,哈希函数应该能够将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。

一个设计优秀的哈希函数应该包含以下特性:

  • 均匀性:一个好的哈希函数应该在其输出范围内尽可能均匀地映射,也就是说,应以大致相同的概率生成输出范围内的每个哈希值。
  • 效率高:哈希效率要高,即使很长的输入参数也能快速计算出哈希值。
  • 可确定性:哈希过程必须是确定性的,这意味着对于给定的输入值,它必须始终生成相同的哈希值。
  • 雪崩效应:微小的输入值变化也会让输出值发生巨大的变化。
  • 不可逆:从哈希函数的输出值不可反向推导出原始的数据。

哈希桶与装载因子

  1. 哈希桶。哈希桶(也称为槽,类似于抽屉原理中的一个抽屉)可以理解为一个哈希值,所有的哈希值组成哈希空间。

  2. 装载因子。装载因子是表示哈希表中元素的填满程度。它的计算公式:
    装 载 因 子 = 填 入 哈 希 表 中 的 元 素 个 数 / 哈 希 表 的 长 度 。 装载因子=填入哈希表中的元素个数/哈希表的长度。 =/
    装载因子越大,填入的元素越多,空间利用率就越高,但发生哈希冲突的几率就变大。反之,装载因子越小,填入的元素越少,冲突发生的几率减小,但空间浪费也会变得更多,而且还会提高扩容操作的次数

    装载因子也是决定哈希表是否进行扩容的关键指标,在 java 的 HashMap 的中,其默认装载因子为 0.75;Python的 dict 默认装载因子为2/3。

哈希冲突

哈希函数是将任意大小的数据映射到固定大小值的函数。那么,可以预见到,即使哈希函数设计得足够优秀,几乎每个输入值都能映射为不同的哈希值。但是,当输入数据足够大,大到能超过固定大小值的组合能表达的最大数量数,冲突将不可避免!

这里提到的哈希碰撞不是多个键对应的哈希完全相等,可能是多个哈希的部分相等,例如:两个键对应哈希的前四个字节相同。

抽屉原理:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,至少会有一个抽屉里面放不少于两个苹果。抽屉原理有时也被称为鸽巢原理。

抽屉原理

解决哈希冲突的方法

开放寻址法

开放寻址法是一种在哈希表中解决哈希碰撞的方法,这种方法的核心思想是依次探测和比较数组中的元素以判断目标键值对是否存在于哈希表中

对于开放寻址法而言,所有的元素都是存储在 Hash 表当中的,所以无论任何时候都要保证哈希表的槽位数 m 大于或等于键的数据 n(必要时,需要对哈希表进行动态扩容)。

开放寻址法有多种方式:线性探测法、平方探测法、随机探测法和双重哈希法。举个线性探测法的例子:

Hash(key) 表示关键字 key 的哈希值, 表示哈希表的槽位数(哈希表的大小)。

线性探测法则可以表示为:

  • 如果 Hash(x) % M 已经有数据,则尝试 (Hash(x) + 1) % M ;

  • 如果 (Hash(x) + 1) % M 也有数据了,则尝试 (Hash(x) + 2) % M ;

  • 如果 (Hash(x) + 2) % M 也有数据了,则尝试 (Hash(x) + 3) % M ;

……

开放寻址法中对性能影响最大的是装载因子。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n) 的,这时需要遍历数组中的全部元素,所以在实现哈希表时需要多关注装载因子的变化。

链地址法

链地址法的思想是将映射在一个桶里的所有元素用链表串起来。

对于开放寻址法而言,它只有数组一种数据结构就可完成存储,继承了数组的优点,对 CPU 缓存友好,易于序列化操作。但是它对内存的利用率不如链地址法,且发生冲突时代价更高。当数据量明确、装载因子小,适合采用开放寻址法

链表节点可以在需要时再创建,不必像开放寻址法那样事先申请好足够内存,因此链地址法对于内存的利用率会比开方寻址法高。链地址法对装载因子的容忍度会更高,并且适合存储大对象、大数据量的哈希表。而且相较于开放寻址法,它更加灵活,支持更多的优化策略,比如可采用红黑树代替链表。但是链地址法需要额外的空间来存储指针

在 Python 中 dict 在发生哈希冲突时采用的开放寻址法,而 java 的 HashMap 采用的是链地址法,而 Go 中使用的也是链地址法,但不完全遵循了链地址法的思想,其主要使用的空间还是数组,其次才用了链表。

Map 中的数据结构

Go 中的结构体为 hamp,该结构体的字段如下:

type hmap struct {
   
    // 代表当前哈希表中的元素个数,len(map) 返回的就是该字段值
	count     int 
    // 状态标识,比如正在被写、buckets 和 oldbuckets 在被遍历、等量扩容(Map扩容相关字段)
	flags     uint8
    // buckets(桶)的数量的对数,也就是说该哈希表中桶的数量为 2^B 个
	B         uint8
    // 溢出桶的大致数量
	noverflow uint16
    // 哈希种子,这个值在哈希创建时随机生成,并在计算 key 的哈希的时候会传入哈希函数,以此提高哈希函数的随机性
	hash0     uint32 // hash seed

    // 指向 buckets 数组的指针,数组大小为 2^B,如果元素个数为 0,它为 nil。
	buckets    unsafe.Pointer
    // 如果发生扩容,oldbuckets 是指向老的 buckets 数组的指针,老的 buckets 数组大小是新的buckets 的 1/2。非扩容状态下,它为 nil。
	oldbuckets unsafe.Pointer
    // 表示扩容进度,小于此地址的 buckets 代表已搬迁完成。
	nevacuate  uintptr

    // 这个字段是为了优化 GC 扫描而设计的。当 key 和 value 均不包含指针,并且都可以 <=128 字节时使用。extra 是指向 mapextra 类型的指针。
	extra *mapextra
}

bmap

buckets 是一个指针,它指向的是一个类型为 bmap 的结构体数组,也就是具体存储 map 键值对的哈希空间。bmap 的结构如下:

type bmap struct {
   
	// tophash 包含此桶中每个键的哈希值最高字节(高8位)信息。
    // 如果tophash[0] < minTopHash,tophash[0]则代表桶的搬迁(evacuation)状态。
	tophash [bucketCnt]uint8
}

这里的 tophash 指的是哈希值的高八位,在 Go 中,Hash 值的分布如下,高八位即 high-order bits 部分:

go中哈希值的高地位

在运行期间,bmap 结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对(例如声明了接口类型),而且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段。所以在编译期间通过 cmd/compile/internal/gc.bmap 函数重建了它的结构,动态地创建一个新的结构:

type bmap struct {
   
    //hash值的高八位
    topbits  [8]uint8
    // key 的数组
    keys     [8]keytype
    // value 的数组
    values   [8]valuetype
    // 对齐内存使用的,不是每个 bmap 都有会这个字段,需要满足一定条件
    pad      uintptr
    // 溢出桶,也是指向一个 bmap,上面的字段 topbits、keys、elems 长度为 8,最多存8组键值对,存满了就往指向的这个 bmap 里存
    overflow uintptr
}

一个 bmap 的内存模型如下所示:

bmap内存模型

在上图解示例中,该桶的第 7 位 cell 和第 8 位 cell 还未有对应键值对。需要注意的是,keyvalue 是各自存储起来的,并非想象中的 key/value/key/value… 的形式。这样做虽然会让代码组织稍显复杂,但是它的好处是能让消除填充所需要的字段(padding)。例如 map[int64]int ,如果按照 key/value/key/value/... 这样的模式存储,那在每一个 key/value 对之后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,这种形式 key/key/.../value/value/...,则只需要在最后添加 padding

此外,在 8 个键值对数据后面有一个 overflow 指针,因为桶中最多只能装 8 个键值对,如果有多余的键值对落到了当前桶,那么就需要再构建一个桶(称为溢出桶),通过 overflow 指针链接起来。

mapextra

当 map 的 keyvalue 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来,在这个字段里将指针指向溢出桶。

所以实际上 bmap.overflowhmap.extra.overflow 所指向的地址是一样的,都是溢出桶的内存地址,只是在某些特殊情况下用 hmap.extra.overflow 代替 bmap.overflow ,从而优化了 GC 过程。

type mapextra struct {
   
   
   // 如果 key 和 value 都不包含指针,并且可以被 inline(<=128 字节)
   // 就使用 hmap 的 extr a字段来存储 overflow buckets,这样可以避免 GC 扫描整个 map
   // 然而 bmap.overflow 也是个指针。随意其实 bmap.overflow 的指针也是指向了
   // hmap.extra.overflow 和 hmap.extra.oldoverflow 中
   // overflow 包含的是 hmap.buckets 的 overflow 的 buckets
   // oldoverflow 包含扩容时的 hmap.oldbuckets 的 overflow 的 bucket
   overflow    *[]*bmap
   oldoverflow *[]*bmap

   // 指向空闲的 overflow bucket 的指针
   nextOverflow *bmap
}

map 中的常量

map 中还定义了一些重要的常量:

注意:键和值超过 128 个字节后,会被转换成指针

const (
	// 一个桶中最多容纳的键值对的对数,也就是一个桶最多容纳 2^3=8 个
	bucketCntBits = 3
	bucketCnt     = 1 << bucketCntBits

    // 触发扩容的装载因子为 13/2=6.5
	loadFactorNum = 13
	loadFactorDen = 2

	// 键和值超过 128 个字节后,会被转换成指针
	maxKeySize  = 128
	maxElemSize = 128

	// 数据偏移量,大小为 bmap 结构体的大小,它需要正确的对齐,
	dataOffset = unsafe.Offsetof(struct {
   
		b bmap
		v int64
	}{
   }.v)

	// 每个桶(如果有溢出,则包含它的 overflow 的链桶)在搬迁完成状态(evacuated* states)下,
    // 要么会包含它所有的键值对,要么一个都不包含(但不包括调用 evacuate() 方法阶段,
    // 该方法调用只会在对 map 发起 write 时发生,在该阶段其他 goroutine 是无法查看该map的(map 非并发安全))。
    // 简单的说,在非写过程的状态中,桶里的数据要么一起搬走,要么一个都还未搬。
    // tophash 除了放置正常的高 8 位 hash 值,还会存储一些特殊状态值(标志该 cell 的搬迁状态)。
    
    // 正常的tophash值,最小应该是5,以下列出的就是一些特殊状态值:
    // 表示 cell 为空,并且比它高索引位的 cell 或者 overflows 中的 cell 都是空的。(初始化 bucket 时,就是该状态)
    emptyRest      = 0
    // 空的cell,cell已经被搬迁到新的bucket
	emptyOne       = 1
    // 键值对已经搬迁完毕,key 在新 buckets 数组的前半部分
	evacuatedX     = 2
    // 键值对已经搬迁完毕,key 在新 buckets 数组的后半部分
	evacuatedY     = 3
    // cell 为空,整个 bucket 已经搬迁完毕
	evacuatedEmpty = 4
    // tophash的最小正常值
	minTopHash     = 5

	// flags
    // 可能有迭代器在使用 buckets
	iterator     = 1
    // 可能有迭代器在使用 oldbuckets
	oldIterator  = 2
    // 有协程正在向 map 写入 key
	hashWriting  = 4
    // 等量扩容
	sameSizeGrow = 8

	// 用于迭代器检查的 bucket ID
	noCheck = 1<<(8*sys.PtrSize) - 1
)

整体来说,map 的数据结构如下所示:

Go map 数据结构

在上面的数据结构中,实际上 buckets 指向的 []bmapbmap.overflow 指向的 []bmap 的内存在地址空间上是连续的,这个可以在下面 map 初始化的时候看出来。

Map 初始化

map 初始化的方式有以下两种:

make(map[k]v)
// 指定初始化大小为 hint 的 map
make(map[k]v,hint)

对于不指定初始化大小,和初始化值 hint<=8(bucketCnt) 时,go会调用 makemap_small 函数(源码位置 src/runtime/map.go),并直接从堆上进行分配。

func makemap_small() *hmap {
   
    h := new(hmap)
    h.hash0 = fastrand()
    return h
}

hint>8 时,则调用 makemap 函数:

// 如果编译器认为可以在栈上创建 map 和第一个 bucket,那么 h 和 bucket 可能都是非空
// 如果 h != nil,那么 map 可以直接在 h 中创建
// 如果 h.buckets != nil,那么 h 指向的 bucket 可以作为 map 的第一个 bucket 使用
func makemap(t *maptype, hint int, h *hmap) *hmap {
   
    // math.MulUintptr 返回 hint 与 t.bucket.size 的乘积,并判断该乘积是否溢出。
	mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
	// maxAlloc 的值,根据平台系统的差异而不同,具体计算方式参照 src/runtime/malloc.go
    if overflow || mem > maxAlloc {
   
		hint = 0
	}

	// initialize Hmap
	if h == nil {
   
		h = new(hmap)
	}
    // 通过 fastrand 得到一个随机的哈希种子
	h.hash0 = fastrand()

	// 根据输入的元素个数 hint,找到能装下这些元素所需要的 B 值
	B := uint8(0)
    // 2^B < hint/装载因子,找到满足条件的 B
	for overLoadFactor(hint, B) {
   
		B++
	}
	h.B = B

	// 分配初始哈希表
    // 如果 B 为0,那么 buckets 字段后续会在 mapassign 方法中 lazily 分配
    if h.B != 0 {
   
		var nextOverflow *bmap
        // makeBucketArray 创建一个 map 的底层保存 buckets 的数组,它最少会分配 h.B^2 的大小。
		h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
		if nextOverflow != nil {
   
			h.extra = new(mapextra)
			h.extra.nextOverflow = nextOverflow
		}
	}

	return h
}

分配 buckets 数组的 makeBucketArray 函数如下:

// makeBucket 为 map 创建用于保存 buckets 的数组。
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
   
	base := bucketShift(b)
	nbuckets := base
    // 对于小的 b 值(小于4),即桶的数量小于 16 时,使用溢出桶的可能性很小。对于此情况,就避免计算开销。
	if b >= 4 {
   
		// 当桶的数量大于等于 16 个时,正常情况下就会额外创建 2^(b-4) 个溢出桶
		nbuckets += bucketShift(b - 4)
		sz := t.bucket.size * nbuckets
		up := roundupsize(sz)
		if up != sz {
   
			nbuckets = up / t.bucket.size
		}
	}

    // 这里,dirtyalloc 分两种情况。
    // 如果它为 nil,则会分配一个新的底层数组。
    // 如果它不为 nil,则它指向的是曾经分配过的底层数组,该底层数组是由之前同样的 t 和 b 参数通过 makeBucketArray 分配的
    // 如果数组不为空,需要把该数组之前的数据清空并复用。
	if dirtyalloc == nil {
   
        // 由这里可以看出,正常桶和溢出桶在内存中的存储空间是连续的,因为分配的大小是正常桶+溢出桶
		buckets = newarray(t.bucket, int(nbuckets))
	} else {
   
		buckets = dirtyalloc
		size := t.bucket.size * nbuckets
		if t.bucket.ptrdata != 0 {
   
			memclrHasPointers(buckets, size)
		} else {
   
			memclrNoHeapPointers(buckets, size)
		}
	}

	// 在满足分配溢出桶的条件下,为了把跟踪这些溢出桶的开销降至最低,使用了以下约定:
    // 如果预分配的溢出桶的 overflow 指针为 nil,那么可以通过指针碰撞(bumping the pointer)获得更多可用桶。
    // 关于指针碰撞:假设内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”)
	if base != nbuckets {
   
        // buckets(基地址) + base(2^B)*bucketsize, 即获得第一个 overflow
		nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
        // 对于最后一个溢出桶,需要一个安全的非 nil 指针指向它,这是为了保证这部分尚未使用的内存 GC 期间安全
        // 最后一个 overflow
		last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
        // 最后一个 overflow 指针指向 buckets(基地址, 也是安全的指针)
		last.setoverflow(t, (*bmap)(buckets))
	}
	return buckets, nextOverflow
}

通过上面的创建过程,初始化出来的 map 大致是如下的结构:

初始化的map

此外,还能看出,正常桶和溢出桶在内存中的存储空间是连续的,只是被 hmap 中的不同字段引用而已

Map 的哈希函数

在初始化 go 程序运行环境时(src/runtime/proc.go中的 schedinit),就需要通过 alginit 方法完成对哈希的初始化:

func schedinit() {
   
    lockInit(&sched.lock, lockRankSched)

    ...

    tracebackinit()
   	moduledataverify()
    stackinit()
    mallocinit()
    fastrandinit() // must run before mcommoninit
    mcommoninit(_g_.m, -1)
    cpuinit()       // must run before alginit
    // 这里调用alginit()
    alginit()       // maps must not be used before this call
    modulesinit()   // provides activeModules
    typelinksinit() // uses maps, activeModules
    itabsinit()     // uses activeModules

    ...

    goargs()
    goenvs()
    parsedebugvars()
    gcinit()

  	...
 }

对于哈希算法的选择,程序会根据当前架构判断是否支持 AES,如果支持就使用 AES hash,其实现的代码位于 src/runtime/asm_{386,amd64,arm64}.s 中;若不支持,其 hash 算法则根据 xxhash 算法和 cityhash 算启发而来,代码分别对应于 32 位(src/runtime/hash32.go)和 64 位机器(src/runtime/hash32.go)中:

func alginit() {
   
	// Install AES hash algorithms if the instructions needed are present.
	if (GOARCH == "386" || GOARCH == "amd64") &&
		cpu.X86.HasAES && // AESENC
		cpu.X86.HasSSSE3 && // PSHUFB
		cpu.X86.HasSSE41 {
    // PINSR{D,Q}
		initAlgAES()
		return
	}
	if GOARCH == "arm64" && cpu.ARM64.HasAES {
   
		initAlgAES()
		return
	}
	getRandomData((*[len(hashkey) * sys.PtrSize]byte)(unsafe.Pointer(&hashkey))[:])
	hashkey[0] |= 1 // make sure these numbers are odd
	hashkey[1] |= 1
	hashkey[2] |= 1
	hashkey[3] |= 1
}

上面在创建 map 的时候,map 的哈希种子是通过 h.hash0 = fastrand() 得到的。它是在以下 maptype 中的 hasher 中被使用到,在下文内容中会看到hash值的生成。

type maptype struct {
   
	typ    _type
	key    *_type
	elem   *_type
	bucket *_type // internal type representing a hash bucket
    // hasher 的第一个参数就是指向 key 的指针,
    // h.hash0 = fastrand() 得到的 hash0,就是 hasher 方法的第二个参数。
	// hasher方法返回的就是hash值。
	hasher     func(unsafe.Pointer, uintptr) uintptr
	keysize    uint8  // size of key slot
	elemsize   uint8  // size of elem slot
	bucketsize uint16 // size of bucket
	flags      uint32
}

Map 的基本操作

Key 的定位

假定 key 经过哈希计算后得到 64bit 位的哈希值。如果 B=5,buckets 数组的长度,即桶的数量是 32(2 的 5 次方)。

现要置一 key 于 map 中,该 key 经过哈希后,得到的哈希值如下:

![图片](假定key经过哈希计算后得到64bit位的哈希值。如果B=5,buckets数组的长度,即桶的数量是32(2的5次方)。

例如,现要置一key于map中,该key经过哈希后,得到的哈希值如下:

假定的64位哈希值

哈希值低位(low-order bits)用于选择桶,哈希值高位(high-order bits)用于在一个独立的桶中区别出键。当 B 等于 5 时,那么选择的哈希值低位也是 5 位,即 01010,它的十进制值为10,代表 10 号桶。再用哈希值的高 8 位,找到此 key 在桶中的位置。最开始桶中还没有 key,那么新加入的 key 和 value 就会被放入第一个 key 空位和 value 空位。

注意:对于高八位的选择,该操作的实质是取余,但是取余开销很大,在实际代码实现中采用的是位操作,其实现如下:

func tophash(hash uintptr) uint8 {
   
	top := uint8(hash >> (sys.PtrSize*8 - 8))
	if top < minTopHash {
   
		top += minTopHash
	}
	return top
}

当两个不同的 key 落在了同一个桶中,这时就发生了哈希冲突。go 的解决方式是链地址法(这里只描述非扩容且该 key 是第一次添加的情况):在桶中按照顺序寻到第一个空位并记录下来,后续在该桶和它的溢出桶中均未发现存在的该 key,将 key 置于第一个空位;否则,去该桶的溢出桶中寻找空位,如果没有溢出桶,则添加溢出桶,并将其置溢出桶的第一个空位。

例如,下图中的 B 值为 5,所以桶的数量为 32。通过哈希函数计算出待插入 key 的哈希值,低 5 位哈希00110,对应于 6 号桶;高 8 位10010111

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值