四. go 常见数据结构实现原理之 map

目录

  • 一. 基础
    • hash 的基本方案
  • 二.map初始化创建
    • map的底层结构 hmap
    • bucket桶
      • 桶的细节总结
      • minTopHash 与是否迁移
    • extra
    • 一些重要的常量标志
    • 初始化
  • 三. 插入数据
    • 存储数据时key的定位策略
  • 四. 查询数据
  • 五. 删除
  • 六. 扩容
    • 扩容策略与扩容大小
    • 扩容与数据迁移源码
  • 七. 总结
    • map底层结构相关问题总结
    • 初始化底层总结
    • 插入数据底层总结
    • 查询数据底层总结
    • 扩容底层总结
    • 常见问题

一. 基础

四. go 常见数据结构实现原理之 map_第1张图片

  1. 在go 基础入门十一 map集合,进行了map基础操作的相关示例,例如
  2. 注意点
  1. map声明示例(注意点声明不会分配内存需要执行make才可以,分配内存后才能赋值使用)
  2. slice,map,function不可以作为key,因为不可以进行等值判断)

hash 的基本方案

  1. 了解map前,先了解hash的基本方案: 开放寻址法和拉链法
  2. 开放寻址法:
  1. 假设在map中已经存在三个节点,现在插入第四个
  2. 先通过hash函数计算,然后根据map 大小长度对其取模,如果计算出来的位置已经有其他信息占据,则会往后移动直到找到空位置
  1. 拉链法:
  1. 假设hashmap中已经存在一个节点,现在继续往里面加入一个
  2. 通过hashi计算,如果计算出来的位置上已经存在其他信息,则是通过链表的形式往下加入
  1. go中采用拉链法解决hash冲突问题

二.map初始化创建

  1. 了解map初始化创建过程,先了解map底层存储结构,底层的几个重要的属性
  2. 了解了map底层的存储结构后,在跟着make()函数源码,查看初始化创建原理

map的底层结构 hmap

  1. map在底层使用hmap存储数据,内部重点包含:
  1. count: 指的是当前map的大小,即当前存放的元素个数,使用len()函数获取长度时,实际就是过unsafe.Poiner读取的该值
  2. flags: 用来表示当前map的状态,例如当前map是否是写入状态等,在多个地方需要使用此值,如mapaccess1()
  3. B: 代表 bucket 数组的容量(bucket capacity),也就是 bucket 数组中 bucket 的最大个数,但需要考虑一个重要因素装载因子,所以真正可以使用的桶只有loadFactor * 2^B 个,如果超出此值将触发扩容,默认装载因子是6.5
  4. noverflow 溢出buckets数量
  5. hash0: 生产hash码的hash种子,随机数种子,在操作键值的时候,需要引入此值加入随机性
  6. buckets 底层数组指针,指向当前map的底层数组指针
  7. oldbuckets 同是底层数组指针,一般在进行map迁移的时候,用来指向原来旧数组。只有迁移过程中此字段才有意义,此字段数组大小只有buckets 的一半
  8. nevacuate 进度计算器,表示扩容进度,小于此地址的 buckets 迁移完成
  9. extra 可选字段,溢出桶专用,只要有桶装满就会使用
// A header for a Go map.
type hmap struct {
    count     int // 元素个数,调用 len(map) 时,直接返回此值
    flags     uint8 //代表当前 map 的状态(是否处于正在写入的状态等)
    B         uint8  // 指示bucket数组的大小,也就是 bucket 数组中 bucket 的最大个数(buckets 的对数 log_2)
    //map中溢出桶的数量。当溢出的桶太多时,map 会进行 same-size map growth,其实质是避免桶过大导致内存泄露
    noverflow uint16 
    hash0     uint32 // 代表生成 hash 的随机数种子

    buckets    unsafe.Pointer // 指向 buckets 数组,大小为 2^B,如果元素个数为0,就为 nil
    oldbuckets unsafe.Pointer // 是在 map 扩容时存储旧桶的,当所有旧桶中的数据都已经转移到了新桶中时,则清空
    nevacuate  uintptr        // 在扩容时使用,用于标记当前旧桶中小于 nevacuate 的数据都已经转移到了新桶中
    extra *mapextra // 存储 map 中的溢出桶
}

bucket桶

  1. bucket桶的数据结构,实际map中的数据是存入桶中的,每个桶是哈希表中的一个存储位置,用来存储一部分键值对,每个桶的大小是由当前 map 的大小,桶的大小和负载因子三个因素决定,当一个桶存满时会通过overflow指针指向一个新的bucket,形成一个链表
  1. tophash: 长度为8的数组,在计算key的hash值时存储数据时,分高位低位,如果低位相同,会将高位存储在该数组中,以方便后续匹配
  2. data区存放的是key-value数据,存放顺序是key/key/key/…value/value/value,注意,没有将每个键值单独存储在一个字段中,主要是为了解决内存对齐带来的浪费问题,例如map[int64]int8 ,如果按key/value/key/value/… 这种方式存储的话,则在每一个key/value之后需要padding7个字节才行。按key/key/key/… 和 value/value/value/…方式存储时这种方式只需要在最后一个值的后面进行一次padding就可以了
  3. overflow 指针指向的是下一个bucket,当一个bucket存满类,则会创建新的bucket,并使用 overflow 字段进行bucket之间的链接,形成单向链表功能
  4. 注意: data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的
//runtime/map.go/bmap
type bmap struct {
    tophash [8]uint8 //存储哈希值的高8位
    data    byte[1]  //key value数据:key/key/key/.../value/value/value...
    overflow *bmap   //溢出bucket的地址
}
  1. 注意上面的bmap结构并不是实际使用的,实际会使用编译期会动态地创建一个新的同名结构
  1. 在桶内会根据 key 计算hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)
  2. 桶在存储的 tophash 字段后,会存储 key 数组和 value 数组
type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

桶的细节总结

  1. 桶是map中最小的挂载粒度:map中将一部分kv键值对存储到一个桶中,当一个桶存满时新建一个桶,然后以链表的形式串联起来,这样可以减少对象的数量,减轻gc的负担。
  2. 哈希高8位优化桶查找key : 将key哈希值的高8位存储在桶的tohash数组中,这样查找时不用比较完整的key就能过滤掉不符合要求的key,tohash中的值相等,再去比较key值
  3. 桶中key/value分开存放 : 桶中所有的key存一起,所有的value存一起,目的是为了方便内存对齐
  4. 根据k/v大小存储不同值 : 当k或v大于128字节时,其存储的字段为指针,指向k或v的实际内容,小于等于128字节,其存储的字段为原值
  5. 一个桶最大可以存储多少个kv键值对: 由当前 map 的大小、桶的大小和负载因子三个因素决定
  1. 早期版本中桶默认大小为 8 个元素, Go 1.17 版本及其以后的版本中,默认情况下每个桶的大小为 6 个键值对
  2. 在扩容过程中每个桶的大小也可能会随之改变,所以桶的大小不是一个固定的值
  1. bucket桶,如果再细分桶中实际又可以看为存储了多个 cell, cell 就是哈希表(map)中存储数据的最小单元,每个bucket中有一个tophash的数组,数组的长度与该bucket能够存储的最大cell数量相等。而每个cell内部也有一个tophash的值。当往哈希表中添加元素时,计算元素的key值的哈希值,取哈希值的高八位作为tophash,存储到对应bucket的tophash数组中,同时将key-value对存储到bucket的某个cell中。而在进行哈希表的查找时,也是先获取要查找元素的key值的tophash值,然后遍历对应bucket中所有cell,比较cell的tophash值是否和要查找元素的key值的tophash值相等,如果相等,则再比较它们的key是否相等,从而确定指定的key是否在哈希表中存在
  2. 桶的搬迁状态 : 可以根据tohash字段的值,是否小于minTopHash,来表示桶是否处于搬迁状态

minTopHash 与是否迁移

  1. 在哈希表进行扩容时,每个子桶会被移动到新的桶(bucket)中。为了判断一个子桶是否已经被迁移完成,桶内tophash 数组保存子桶中 key 的哈希值的高 8 位。minTopHash 是一个常量,它表示子桶的 tophash 数组中最小的值。如果一个子桶内部所有的 tophash 值都小于 minTopHash,那么就说明该子桶的所有元素都已经迁移完成,并且可以被标记为“moved”状态。标记为“moved”状态的子桶在查找、遍历过程中会被跳过,从而大幅提高了哈希表的性能

minTopHash 不同版本默认值不同,1.17 版本之前默认 4, 在1.17版本为了减小哈希表扩容时的内存占用则被改为了 2(???)

  1. 在判断是否迁移完毕时底层会执行evacuated(),注意只取了tophash数组中的第一个进行判断,判断它是否在 0-5 之间。对比上面的常量,当 top hash 是 evacuatedEmpty、evacuatedX、evacuatedY 这三个值之一,说明此 bucket 中的 key 全部被搬迁到了新 bucket
func evacuated(b *bmap) bool {
    h := b.tophash[0]
    //emptyOne =0, minTopHash=5
    return h > emptyOne && h < minTopHash
}

extra

  1. 在hmap中存在一个extra 可选字段,溢出桶专用,只要有桶装满就会使用,是一个mapextra 结构变量,假设有一个 map 内部有 10000 个键值对。如果所有的键都散列到同一个 bucket 中,那么这个 bucket 中的键值对数量就会非常大,这样在遍历 map 时就会非常慢。当一个 bucket 中的键值对数量达到一定阈值时,新的键值对就会被存储到溢出 bucket 中,使用溢出 bucket 来存储一部分键值对,那么遍历 map 的效率就会得到提高
  2. 当一个 bucket 中的键值对数量达到 8 个时,会触发扩容操作创建一个新的更大的哈希表,并将所有的键值对重新散列到新的哈希表中。在这个过程中,如果一个 bucket 中的键值对数量超过 8 个,会使用溢出 bucket 来存储这些多余的键值对。溢出 bucket 是一个单独的哈希表,它的大小也会根据需要进行调整,以便更好地存储键值对。“溢出 bucket” 指的就是 hmap 中的extra
type mapextra struct {
	//用于存储溢出 bucket 中的键值对
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    //指向下一个空的溢出桶的指针
    nextOverflow *bmap
}
  1. 在遍历 map 时,会先遍历主 bucket 中的所有键值对。如果当前 bucket 中有溢出 bucket,通过 overflow 数组中的指针找到溢出 bucket,继续遍历溢出 bucket 中的键值对。如果当前的溢出 bucket 已经被填满了,通过 nextOverflow 指针链接到下一个溢出 bucket,然后继续遍历下一个溢出 bucket 中的键值对。通过 extra 字段中的 overflow 数组和 nextOverflow 指针,可以更高效地遍历 map,提高程序的性能

一些重要的常量标志

const (
    // 一个桶中最多能装载的键值对(key-value)的个数为8
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits

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

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

    // 数据偏移量应该是bmap结构体的大小,它需要正确地对齐。
    // 对于amd64p32而言,这意味着:即使指针是32位的,也是64位对齐。
    dataOffset = unsafe.Offsetof(struct {
        b bmap
        v int64
    }{}.v)


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

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

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

初始化

  1. map的源码位于 src/runtime/map.go 中,map同样也是数组存储的的,每个数组下标处存储的是一个bucket,就是指哈希桶
  2. 在使用map时需要调用make()初始化创建一个map,实际会调用makemap()该函数内部:
  1. 首先调用MulUintptr()根据 hint 计算 map 需要的空间大小
  2. 通过new()函数初始化hmap,并调用fastrand()获取一个随机数,设置为随机种子
  3. 通过overLoadFactor(hint, B)函数检查当前 map 的负载因子是否超过阈值默认6.5,如果超过阈值,则需要扩容,累加B,
  4. 如果B==0进行延时初始化操作(赋值的时候才进行初始化),否则分配 bucket 数组的空间,并将其初始化为零值中的在makeBucketArray()函数中
// src/runtime/map.go
// 用于创建一个新的 map
func makemap(t *maptype, hint int, h *hmap) *hmap {
    // 计算 map 需要的空间大小
    mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
    if overflow || mem > maxAlloc {
        hint = 0
    }

    // 初始化 Hmap
    if h == nil {
        h = new(hmap)
    }
    h.hash0 = fastrand() // 生成用于计算哈希值的随机数

    // 计算 bucket 数量
    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B

    // 分配 bucket 数组的空间,并将其初始化为零值
    if h.B != 0 {
        var nextOverflow *bmap
        h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
        if nextOverflow != nil { // 如果存在溢出 bucket 数组,则分配额外的空间来存储
            h.extra = new(mapextra)
            h.extra.nextOverflow = nextOverflow
        }
    }

    return h
}

// 判断 bucket 数量是否超过负载因子的上限
func overLoadFactor(n, B uint8) bool {
    return float64(n) > loadFactor * float64(uint(1)<<(B+1))
}
//不同版本overLoadFactor()内部实现不同?
func overLoadFactor(count int, B uint8) bool {
    return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
  1. 注意: makemap()函数返回的是一个指针,而makeslice()返回的是一个新的结构体,在参数传递的时候,是值复制,两者有差异,有些是引用的是同一个数组,有些不是
  2. 执行makeBucketArray()初始化bucket
  1. 调用bucketShift()计算 bucket 数组的大小
  2. 对于b<4的话(桶的数量< 2^4),基本不需要溢出桶,对于b>4(桶的数量> 24)的话,则需要创建2(b-4)个溢出桶,并将其连接到主 bucket 数组的末尾
  3. 数组分配通过函数 newarray() 实现,第一个参数是元素类型,每二个参数是数组长度,返回的是数组内存首地址
// src/runtime/map.go
// 用于创建 bucket 数组
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
    // 计算 bucket 数组的大小
    base := bucketShift(b)
    nbuckets := base
    if b >= 4 {
        // 如果 bucket 数量比较大,则估算所需的溢出 bucket 数量
        nbuckets += bucketShift(b - 4)
        sz := t.bucket.size * nbuckets
        up := roundupsize(sz)
        // 确保 bucket 数组的大小是 t.bucket.size 的倍数
        if up != sz {
            nbuckets = up / t.bucket.size
        }
    }

    // 分配 bucket 数组的空间,并将其初始化为零值
    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)
        }
    }

    // 如果存在溢出 bucket 数组,则进行初始化
    if base != nbuckets {
        nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
        last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
        last.setoverflow(t, (*bmap)(buckets))
    }

    return buckets, nextOverflow
}

三. 插入数据

  1. 在上面我们了解到,初始化创建map时,底层会封装返回一个hmap结构遍历, 在存储数据时,map内部有一个bucket桶的概念, 对应bmap结构,早期版本中桶默认大小为 8 个元素, Go 1.17 版本及其以后的版本中,默认情况下每个桶的大小为 6 个键值对,当一个桶存满时会通过overflow指针指向一个新的bucket,从而形成一个链表,这里我们先解释插入流程,
  2. 在存储时:
  1. 在桶内会根据 key 计算hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)。
  2. 桶在存储的 tophash 字段后,会存储 key 数组和 value 数组
  1. 在插入数据时底层会执行mapassign函数,不考虑并发安全和扩容时,大致可以分成下面五个步骤
  1. 执行hasher()计算插入数据key的hash值
  2. 执行bucket := hash & bucketMask(h.B),根据计算出的hash值确认当前key存储的桶
  3. 遍历所属桶和此桶串联的溢出桶,寻找key(通过桶的tohash字段和key值)
  4. 判断是否需要扩容,需要则执行growWork(t, h, bucket)
  5. 如果当前链上所有桶都满了,创建一个新的溢出桶,串联在末尾,然后更新相关字段
  6. 根据key是否存在,在桶中更新或者新增key/value值
// 往map中添加元素/修改元素值
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	//校验 map 是否为 nil
	if h == nil {
		panic(plainError("assignment to entry in nil map"))
	}
	//如果开启了竞争检测,则记录写操作
	if raceenabled {
		callerpc := getcallerpc()
		pc := funcPC(mapassign)
		racewritepc(unsafe.Pointer(h), callerpc, pc)
		raceReadObjectPC(t.key, key, callerpc, pc)
	}
	//如果开启了内存检测,则标记 key 内存区域为已读
	if msanenabled {
		msanread(key, t.key.size)
	}
	//校验map是否未初始化,或正在并发写操作,如果存在,则抛出异常
	if h.flags&hashWriting != 0 {
		throw("concurrent map writes")
	}
	// 第一部分: 确认哈希值
	hash := t.hasher(key, uintptr(h.hash0))
	//标记当前正在写入 map
	h.flags ^= hashWriting
 	//如果 map 为空,先进行初始化
	if h.buckets == nil {
		h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
	}
 
again:
	
	// 第二部分: 根据hash值确认key所属的桶
	bucket := hash & bucketMask(h.B)
	//判断是否需要扩容
	if h.growing() {
		//执行扩容逻辑
		growWork(t, h, bucket)
	}
	//找到 key 所在的桶
	b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
	top := tophash(hash)
 	//遍历桶和溢出桶,查找 key
	var inserti *uint8
	var insertk unsafe.Pointer
	var elem unsafe.Pointer
bucketloop:
	
	// 第三部分: 遍历所属桶和此桶串联的溢出桶,寻找key(通过桶的tohash字段和key值)
	for {
		for i := uintptr(0); i < bucketCnt; i++ {
			if b.tophash[i] != top {
				//如果桶中当前位置为空,记录该位置
				if isEmpty(b.tophash[i]) && inserti == nil {
					inserti = &b.tophash[i]
					insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
					elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
				}
				//如果已经遍历完了所有桶,跳出循环
				if b.tophash[i] == emptyRest {
					break bucketloop
				}
				continue
			}
			//如果找到了 key,更新 elem 的值
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
			if t.indirectkey() {
				k = *((*unsafe.Pointer)(k))
			}
			if !t.key.equal(key, k) {
				continue
			}
			if t.needkeyupdate() {
				typedmemmove(t.key, k, key)
			}
			elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
			goto done
		}
		//如果当前桶没有找到 key,查找溢出桶
		ovf := b.overflow(t)
		if ovf == nil {
			break
		}
		b = ovf
	}
 
 	//如果 map 已经满,触发扩容
	if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
		//注意hashGrow()只是为扩容做的一些准备工作,例如申请内存,而真正进行扩容的是 growWork() 和 evacuate()函数
		hashGrow(t, h)
		goto again 
	}
 
	// 第四部分: 当前链上所有桶都满了,创建一个新的溢出桶,串联在末尾,然后更新相关字段
	if inserti == nil {
		newb := h.newoverflow(t, b)
		inserti = &newb.tophash[0]
		insertk = add(unsafe.Pointer(newb), dataOffset)
		elem = add(insertk, bucketCnt*uintptr(t.keysize))
	}
 
	// 第五部分 根据key是否存在,在桶中更新或者新增key/value值
	if t.indirectkey() {
		kmem := newobject(t.key)
		*(*unsafe.Pointer)(insertk) = kmem
		insertk = kmem
	}
	if t.indirectelem() {
		vmem := newobject(t.elem)
		*(*unsafe.Pointer)(elem) = vmem
	}
	//复制 key 的值到 insertk 指向的内存区域中
	typedmemmove(t.key, insertk, key)
	*inserti = top
	h.count++
 
done:
	//标记写入操作结束
	if h.flags&hashWriting == 0 {
		throw("concurrent map writes")
	}
	h.flags &^= hashWriting
	if t.indirectelem() {
		elem = *((*unsafe.Pointer)(elem))
	}
	return elem
}

存储数据时key的定位策略

  1. 因为在map中要存储一个键值对,必须先找到所在的位置,一般采用hash算法即取模的结果来实现
  2. 通过上面hmap 结构体,我们知道有一个 B 字段,这个字段决定了map对应的底层数组的大小,它的大小决定了可以容纳的bucket的个数。如果B=5的话,则可以bucket的数组元素值个数为 2^5=32个,但golang中需要考虑到装载分子6.5这个因素,所以真正装载的元素并没有这么多。每当一个map存储的元素个数占比达到65%的时候,就会触发map的扩容操作
  3. key要放在哪个bucket个, 需要先计算出一个hash值,然后再除以 32取余数即可
  1. 假设hash值为: 10010111 | 000011110110110010001111001010100010010110010101010 │ 00110
  2. h.B = 5,则取后面的5位 00110,值为6,则需要将key:value存放在第6号的bucket中
  1. 找到了要存储的桶位置,还需要找到放在桶的什么位置,每个位置可以称之为slot或者Cell, 老版本中默认一个桶可以存储8个元素, 2^3=8个,再多的话就需要使用到溢出桶overflow
 	// 一个桶中最多能装载的键值对(key-value)的个数为8
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits
  1. Cell定位通过调用tophash()函数取hash的高8位,这里高8位是 10010111,十进制的值为151(取余开销太大,所以代码实现上用的位操作代替), 于是在第6号bucket的中遍历每个Cell,直到找到bmap.tophash值为151的Cell,当前Cell在bucket中的索引位置就是要找的键和值的索引位置。然后根据当前位置索引值分别从bmap.keys和bmap.values中计算出对应的值。
  2. 两个不同的 key 落在同一个桶中,也就是发生了哈希冲突。冲突的解决手段是用链表法:在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key

四. 查询数据

  1. 在查询数据时与上面key定位大致相同,底层会执行函数mapaccess1()(注意还有一个mapaccess2()函数,两者区别是返回值不同,前者返回是一个值,后者返回两个值,第二个值决定了key是否在map中存在,里我们只介绍runtime.mapaccess1())
  1. 执行hasher()计算hash值并根据hash值找到桶
  2. 遍历桶和桶串联的溢出桶,寻找key
  1. 注意:
  1. 如果根据hash值定位到桶正在进行搬迁,并且这个bucket还没有搬迁到新哈希表中,那么就从老的哈希表中找。
  2. 在bucket中进行顺序查找,使用高八位进行快速过滤,高八位相等,再比较key是否相等,找到就返回value。如果当前bucket找不到,就往下找溢出桶,都没有就返回零值
// map中查找元素
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	if raceenabled && h != nil {
		callerpc := getcallerpc()
		pc := funcPC(mapaccess1)
		racereadpc(unsafe.Pointer(h), callerpc, pc)
		raceReadObjectPC(t.key, key, callerpc, pc)
	}
	if msanenabled && h != nil {
		msanread(key, t.key.size)
	}
	if h == nil || h.count == 0 {
		if t.hashMightPanic() {
			t.hasher(key, 0) // see issue 23734
		}
		return unsafe.Pointer(&zeroVal[0])
	}
	//校验map是否未初始化,或正在并发写操作,如果存在,则抛出异常
	if h.flags&hashWriting != 0 {
		throw("concurrent map read and map write")
	}
	
	// 第一部分:计算hash值并根据hash值找到桶
	hash := t.hasher(key, uintptr(h.hash0))
	m := bucketMask(h.B)
	b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
	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
		}
	}
	top := tophash(hash)
	
	// 第二部分:遍历桶和桶串联的溢出桶,寻找key
bucketloop:
	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() {
				k = *((*unsafe.Pointer)(k))
			}
			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])
}

五. 删除

  1. 在删除数据时,底层会执行mapdelete()函数,大致可以分为以下六步
  1. 首先会判断当前操作的map中写保护表示如果不是0,抛出异常
  2. 执行hashe()计算当前key的hash值
  3. 执行hash & bucketMask(h.B),根据hash值获取到对应的桶,并判断是否需要扩容,迁移,需要则执行growWork()
  4. 遍历桶和桶串联的溢出桶
  5. 找到key,然后将桶的该key的tohash值置空,相当于删除值
  6. 执行"h.flags&hashWriting == 0"解除写保护
  1. 注意点:
  1. 删除key仅仅只是将其对应的tohash值置空,如果kv存储的是指针,那么会清理指针指向的内存,否则不会真正回收内存,内存占用并不会减少
  2. 如果正在扩容,并且操作的bucket没有搬迁完,那么会搬迁bucket
// map中删除元素
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
	if raceenabled && h != nil {
		callerpc := getcallerpc()
		pc := funcPC(mapdelete)
		racewritepc(unsafe.Pointer(h), callerpc, pc)
		raceReadObjectPC(t.key, key, callerpc, pc)
	}
	if msanenabled && h != nil {
		msanread(key, t.key.size)
	}
	if h == nil || h.count == 0 {
		if t.hashMightPanic() {
			t.hasher(key, 0) // see issue 23734
		}
		return
	}
 
	// 第一部分: 写保护,校验map是否未初始化,或正在并发写操作,如果存在,则抛出异常
	if h.flags&hashWriting != 0 {
		throw("concurrent map writes")
	}
 
	// 第二部分: 获取hash值
	hash := t.hasher(key, uintptr(h.hash0))
 
	// Set hashWriting after calling t.hasher, since t.hasher may panic,
	// in which case we have not actually done a write (delete).
	h.flags ^= hashWriting
 
	// 第三部分: 根据hash值确定桶,
	bucket := hash & bucketMask(h.B)
	//并判断是否需要扩容
	if h.growing() {
		//如果需要执行扩容,数据迁移逻辑
		growWork(t, h, bucket)
	}
	b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
	bOrig := b
	top := tophash(hash)
 
	// 第四部分:遍历桶和桶串联的溢出桶
search:
	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 search
				}
				continue
			}
 
			// 第五部分: 找到key,然后将桶的该key的tohash值置空,相当于删除值了
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
			k2 := k
			if t.indirectkey() {
				k2 = *((*unsafe.Pointer)(k2))
			}
			if !t.key.equal(key, k2) {
				continue
			}
			// Only clear key if there are pointers in it.
			if t.indirectkey() {
				*(*unsafe.Pointer)(k) = nil
			} else if t.key.ptrdata != 0 {
				memclrHasPointers(k, t.key.size)
			}
			e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
			if t.indirectelem() {
				*(*unsafe.Pointer)(e) = nil
			} else if t.elem.ptrdata != 0 {
				memclrHasPointers(e, t.elem.size)
			} else {
				memclrNoHeapPointers(e, t.elem.size)
			}
			b.tophash[i] = emptyOne
			// If the bucket now ends in a bunch of emptyOne states,
			// change those to emptyRest states.
			// It would be nice to make this a separate function, but
			// for loops are not currently inlineable.
			if i == bucketCnt-1 {
				if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
					goto notLast
				}
			} else {
				if b.tophash[i+1] != emptyRest {
					goto notLast
				}
			}
			for {
				b.tophash[i] = emptyRest
				if i == 0 {
					if b == bOrig {
						break // beginning of initial bucket, we're done.
					}
					// Find previous bucket, continue at its last entry.
					c := b
					for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
					}
					i = bucketCnt - 1
				} else {
					i--
				}
				if b.tophash[i] != emptyOne {
					break
				}
			}
		notLast:
			h.count--
			break search
		}
	}
 
	// 第六部分: 解除写保护
	if h.flags&hashWriting == 0 {
		throw("concurrent map writes")
	}
	h.flags &^= hashWriting
}

六. 扩容

扩容策略与扩容大小

  1. 了解扩容首先要了解map的装载因子,又称为负载因子(默认1,超过6.5触发扩容)
  1. map 底层的hmap结构中维护了 count 和 B(桶数组的长度)两个值
  2. count 表示当前 map 中的 key-value 对儿数量
  3. B 表示桶数组的长度,也就是 map 所拥有的最大桶数量,由于方便哈希计算桶数量始终是一个 2 的整数幂,因此 B 也表示实际的桶数量:2^B 个桶
  4. 通过把 count 除以 2^B ,就可以得到 map 的负载因子,即 key-value 对儿与桶的平均比值,特就是"负载因子 = 键数量/bucket数量", 例如一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1
  1. map扩容时可以分为增量扩容和等量扩容两种
  1. 增量扩容: 例如当装载因子"键数量/bucket数量"超过6.5的时触发的扩容,就是说元素太多,执行扩容添加一倍的桶,保证元素个数最多只能达到桶总容量的65%
  2. 等量扩容: 例如先是大量的添加元素,后来又删除了,导致出现了太多的空桶和overflow溢出桶,查找key时,就需要遍历多桶,效率下降,这时就需要重新对元素进行位置调整,类似碎片整理一样,开辟一个新 bucket 空间,将溢出桶中的元素向前面的bucket迁移,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密, 并且: 如果插入 map 的 key 发生hash冲突落到同一个 bucket ,超过桶的容量时个就会产生 overflow bucket溢出桶,造成溢出桶过多,移动元素也解决不了这个问题,最终造成哈希表退化成了一个链表,操作效率变成了 O(n),等量扩容时创建和旧桶数目一样多的新桶,把原来的键值对迁移到新桶中
  1. 增量扩容的触发条件: 当装载因子越过6.5的时候: “负载因子 = 键数量/bucket数量”
  2. 等量扩容的触发条件:
  1. 当B小于15时,也就是常规桶 2^ B 小于 2^ 15 时,并且溢出桶大于2^B触发等量扩容;
  2. 当 B 大于等于 15,也就是常规桶总数 2^ B 大于等于 2^ 15,并且溢出桶的数量超过 2^15,触发等量扩容。
  1. 怎么触发判断的, 例如插入修改元素时底层执行mapassign()函数中,内部会执行一个if判断,通过该判断执行了3个操作
  1. overLoadFactor(): 判断负载因子是否大于6.5
  2. tooManyOverflowBuckets(): 判断buckets 数量是否过多
  3. growing()是否已经发生扩容,还未迁移完
  4. 如果上方条件满足执行hashGrow(),申请内存分配新的buckets,并将老的buckets挂到oldbuckets字段上, 真正搬迁的动作在growWork()中
	if !h.growing() && (overLoadFactor(int64(h.count), h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
  	}
// overLoadFactor reports whether count items placed in 1<
// 装载因子>6.5
func overLoadFactor(count int, B uint8) bool {
    return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

// tooManyOverflowBuckets reports whether noverflow buckets is too many for a map with 1<
// Note that most of these overflow buckets must be in sparse use;
// if use was dense, then we'd have already triggered regular map growth.
// 溢出桶过多
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
    // If the threshold is too low, we do extraneous work.
    // If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
    // "too many" means (approximately) as many overflow buckets as regular buckets.
    // See incrnoverflow for more details.
    if B > 15 {
        B = 15
    }
    // The compiler doesn't see here that B < 16; mask B to generate shorter shift code.
    return noverflow >= uint16(1)<<(B&15)
}

扩容与数据迁移源码

  1. 查看hashGrow()函数,主要是申请新的 buckets 空间,处理相关标志位例如:标志 nevacuate 被置为 0, 表示当前搬迁进度为 0

只是分配新的buckets,并将老的buckets挂到oldbuckets字段上, 真正搬迁的动作在growWork()中

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)

    // x = 01010011
    // y = 01010100
    // z = x &^ y = 00000011
    // 如果 y bit 位为 1,那么结果 z 对应 bit 位就为 0,否则 z 对应 bit 位就和 x 对应 bit 位的值相同。
    // 
    // 先把 h.flags 中 iterator 和 oldIterator 对应位清 0,然后如果发现 iterator 位为 1,
    // 那就把它转接到 oldIterator 位,使得 oldIterator 标志位变成 1。
    // 潜台词就是:buckets 现在挂到了 oldBuckets 名下了,对应的标志位也转接过去吧。
    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

    // ……
}
  1. 查看插入与删除元素时内部执行的growWork()函数,调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作,内部会调用evacuate()

扩容完成后需要做数据的迁移,执行growWork()函数,数据的迁移不是一次完成的,是使用时才会做对应bucket的迁移。也就是逐步做到的数据迁移

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 确认搬迁老的 bucket 对应正在使用的 bucket
    // 为了确认搬迁的 bucket 是我们正在使用的 bucket。
    // oldbucketmask() 函数返回扩容前的 map 的 bucketmask
    evacuate(t, h, bucket&h.oldbucketmask())

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

//如果 oldbuckets 不为空,说明还需要继续迁移
func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}
  1. 查看evacuate(), 该函数内部
  1. 首先定位到老的 bucket 地址
  2. 执行evacuated() 判断当前bucket桶是否已迁移
  3. 在迁移时,根据增量扩容,等量扩容不同策略,迁移逻辑不同, 在等量扩容时,新的 buckets 数量和之前相等,因此可以按序号来搬,比如原来在 0 号 bucktes,到新的地方后,仍然放在 0 号 buckets
  4. 针对增量扩容,新的 buckets 数量是之前的一倍,要重新计算 key 的哈希,才能决定它到底落在哪个 bucket, 扩容时内部声明了X, Y part变量,桶的数量是原来的 2 倍,前一半桶被称为 X part,后一半桶被称为 Y part。一个 bucket 中的 key 可能会分裂落到 2 个桶,一个位于 X part,一个位于 Y part(取决于扩容后,倒数第B位是 0 还是 1)。所以在搬迁一个 cell 之前,需要知道这个 cell 中的 key 是落到哪个 Part
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 没有被搬迁过,先判断当前bucket是不是已经转移(oldbucket 标识需要搬迁的bucket 对应的位置)
    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))
        }

        // 遍历所有的 bucket,包括 overflow buckets
        // b 是老的 bucket 地址
        for ; b != nil; b = b.overflow(t) {
            k := add(unsafe.Pointer(b), dataOffset)
            v := add(k, bucketCnt*uintptr(t.keysize))

            // 遍历 bucket 中的所有 cell
            for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
                // 当前 cell 的 top hash 值
                top := b.tophash[i]
                // 如果 cell 为空,即没有 key
                if top == empty {
                    // 那就标志它被"搬迁"过
                    b.tophash[i] = evacuatedEmpty
                    // 继续下个 cell
                    continue
                }
                // 正常不会出现这种情况
                // 未被搬迁的 cell 只可能是 empty 或是
                // 正常的 top hash(大于 minTopHash)
                if top < minTopHash {
                    throw("bad map state")
                }

                k2 := k
                // 如果 key 是指针,则解引用
                if t.indirectkey {
                    k2 = *((*unsafe.Pointer)(k2))
                }

                // 默认使用 X,等量扩容
                useX := true
                // 如果不是等量扩容
                if !h.sameSizeGrow() {
                    // 计算 hash 值,和 key 第一次写入时一样
                    hash := alg.hash(k2, uintptr(h.hash0))

                    // 如果有协程正在遍历 map
                    if h.flags&iterator != 0 {
                        // 如果出现 相同的 key 值,算出来的 hash 值不同
                        if !t.reflexivekey && !alg.equal(k2, k2) {
                            // 只有在 float 变量的 NaN() 情况下会出现
                            if top&1 != 0 {
                                // 第 B 位置 1
                                hash |= newbit
                            } else {
                                // 第 B 位置 0
                                hash &^= newbit
                            }
                            // 取高 8 位作为 top hash 值
                            top = uint8(hash >> (sys.PtrSize*8 - 8))
                            if top < minTopHash {
                                top += minTopHash
                            }
                        }
                    }

                    // 取决于新哈希值的 oldB+1 位是 0 还是 1
                    // 详细看后面的文章
                    useX = hash&newbit == 0
                }

                // 如果 key 搬到 X 部分
                if useX {
                    // 标志老的 cell 的 top hash 值,表示搬移到 X 部分
                    b.tophash[i] = evacuatedX
                    // 如果 xi 等于 8,说明要溢出了
                    if xi == bucketCnt {
                        // 新建一个 bucket
                        newx := h.newoverflow(t, x)
                        x = newx
                        // xi 从 0 开始计数
                        xi = 0
                        // xk 表示 key 要移动到的位置
                        xk = add(unsafe.Pointer(x), dataOffset)
                        // xv 表示 value 要移动到的位置
                        xv = add(xk, bucketCnt*uintptr(t.keysize))
                    }
                    // 设置 top hash 值
                    x.tophash[xi] = top
                    // key 是指针
                    if t.indirectkey {
                        // 将原 key(是指针)复制到新位置
                        *(*unsafe.Pointer)(xk) = k2 // copy pointer
                    } else {
                        // 将原 key(是值)复制到新位置
                        typedmemmove(t.key, xk, k) // copy value
                    }
                    // value 是指针,操作同 key
                    if t.indirectvalue {
                        *(*unsafe.Pointer)(xv) = *(*unsafe.Pointer)(v)
                    } else {
                        typedmemmove(t.elem, xv, v)
                    }

                    // 定位到下一个 cell
                    xi++
                    xk = add(xk, uintptr(t.keysize))
                    xv = add(xv, uintptr(t.valuesize))
                } else { // key 搬到 Y 部分,操作同 X 部分
                    // ……
                    // 省略了这部分,操作和 X 部分相同
                }
            }
        }
        // 如果没有协程在使用老的 buckets,就把老 buckets 清除掉,帮助gc
        if h.flags&oldIterator == 0 {
            b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
            // 只清除bucket 的 key,value 部分,保留 top hash 部分,指示搬迁状态
            if t.bucket.kind&kindNoPointers == 0 {
                memclrHasPointers(add(unsafe.Pointer(b), dataOffset), uintptr(t.bucketsize)-dataOffset)
            } else {
                memclrNoHeapPointers(add(unsafe.Pointer(b), dataOffset), uintptr(t.bucketsize)-dataOffset)
            }
        }
    }

    // 更新搬迁进度
    // 如果此次搬迁的 bucket 等于当前进度
    if oldbucket == h.nevacuate {
        // 进度加 1
        h.nevacuate = oldbucket + 1
        // Experiments suggest that 1024 is overkill by at least an order of magnitude.
        // Put it in there as a safeguard anyway, to ensure O(1) behavior.
        // 尝试往后看 1024 个 bucket
        stop := h.nevacuate + 1024
        if stop > newbit {
            stop = newbit
        }
        // 寻找没有搬迁的 bucket
        for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
            h.nevacuate++
        }
        
        // 现在 h.nevacuate 之前的 bucket 都被搬迁完毕
        
        // 所有的 buckets 搬迁完毕
        if h.nevacuate == newbit {
            // 清除老的 buckets
            h.oldbuckets = nil
            // 清除老的 overflow bucket
            // 回忆一下:[0] 表示当前 overflow bucket
            // [1] 表示 old overflow bucket
            if h.extra != nil {
                h.extra.overflow[1] = nil
            }
            // 清除正在扩容的标志位
            h.flags &^= sameSizeGrow
        }
    }
}
  1. 有一种 key,每次对它计算 hash,得到的结果都不一样。这个 key 就是 math.NaN() 的结果,针对该类型key,防止出现Get 操作获取不到,当迁移时碰到通过 tophash 的最低位决定分配到 X part 还是 Y part(如果扩容后是原来 buckets 数量的 2 倍)。如果 tophash 的最低位是 0 ,分配到 X part;如果是 1 ,则分配到 Y part

七. 总结

  1. 参考博客
  2. Go 语言问题集(Go Questions)

map底层结构相关问题总结

  1. map的底层结构:(了解map初始化,map增删改查都需要先了解map的底层结构)在我们使用map时需要调用make()函数进行初始化,底层实际时makemap(),查看该函数,内部会初始化一个hmap,这就是map的底层结构,在hmap中存在几个重要的属性:
  1. count: 指的是当前map的大小,即当前存放的元素个数,使用len()函数获取长度时,实际就是过unsafe.Poiner读取的该值
  2. flags: 用来表示当前map的状态,例如当前map是否是写入状态等,在多个地方需要使用此值,如mapaccess1()
  3. B: bucket 数组的容量(bucket capacity),也就是 bucket 数组中 bucket 的最大个数,但需要考虑一个重要因素装载因子,所以真正可以使用的桶只有loadFactor * 2^B 个,如果超出此值将触发扩容,默认装载因子是6.5
  4. noverflow 溢出buckets数量
  5. hash0: 生产hash码的hash种子,随机数种子,在操作键值的时候,需要引入此值加入随机性
  6. buckets 底层数组指针,指向当前map的底层数组指针
  7. oldbuckets 同是底层数组指针,一般在进行map迁移的时候,用来指向原来旧数组。只有迁移过程中此字段才有意义,此字段数组大小只有buckets 的一半
  8. nevacuate 进度计算器,表示扩容进度,小于此地址的 buckets 迁移完成
  9. extra 可选字段,溢出桶专用,只要有桶装满就会使用
  1. 其中真实的数据会存储到buckets 数组中,是一个bmap结构,查看该结构体,内部存在:
  1. tophash: 长度为8的数组,在计算key的hash值时存储数据时,分高位低位,如果低位相同,会将高位存储在该数组中,以方便后续匹配
  2. data区: 存放的是key-value数据,为了解决内存对齐带来的浪费问题存放顺序是key/key/key/…value/value/value,例如map[int64]int8 ,如果按key/value/key/value/… 这种方式存储的话,则在每一个key/value之后需要padding7个字节才行,按key/key/key/… 和 value/value/value/…方式存储时这种方式只需要在最后一个值的后面进行一次padding就可以了)实际在编译后会把data区分为keys,values两个数组属性
  3. overflow 指向的是下一个bucket的指针,一个bucket每8个kv键值对存放在一个桶中存满类,则会创建新的bucket,并使用 overflow 字段进行bucket之间的链接,形成单向链表功能
  1. 上面桶的细节总结中提到的问题与minTopHash 与是否迁移中相关问题

初始化底层总结

  1. 在我们使用map时需要调用make()函数进行初始化,实际会调用底层的makemap,该函数内部:
  1. 首先调用MulUintptr()根据 hint 计算 map 需要的空间大小
  2. 通过new()函数初始化hmap,并调用fastrand()获取一个随机数,设置为随机种子
  3. 通过overLoadFactor(hint, B)函数检查当前 map 的负载因子是否超过阈值默认6.5,如果超过阈值,则需要扩容,累加B,
  4. 如果B==0进行延时初始化操作(赋值的时候才进行初始化),否则分配 bucket 数组的空间,并将其初始化为零值中的在makeBucketArray()函数中
  1. 执行makeBucketArray()初始化bucket
  1. 调用bucketShift()计算 bucket 数组的大小
  2. 对于b<4的话(桶的数量< 2^4),基本不需要溢出桶,对于b>4(桶的数量> 24)的话,则需要创建2(b-4)个溢出桶,并将其连接到主 bucket 数组的末尾
  3. 数组分配通过函数 newarray() 实现,第一个参数是元素类型,每二个参数是数组长度,返回的是数组内存首地址

插入数据底层总结

  1. 在上面我们了解到,初始化创建map时,底层会封装返回一个hmap结构遍历, 在存储数据时,map内部有一个bucket桶的概念, 对应bmap结构,早期版本中桶默认大小为 8 个元素, Go 1.17 版本及其以后的版本中,默认情况下每个桶的大小为 6 个键值对,当一个桶存满时会通过overflow指针指向一个新的bucket,从而形成一个链表, 在插入数据时底层会执行mapassign函数,不考虑并发安全和扩容时,mapassign大致可以分成五个步骤
  1. 执行hasher()计算插入数据key的hash值
  2. 执行bucket := hash & bucketMask(h.B),根据计算出的hash值确认当前key存储的桶
  3. 遍历所属桶和此桶串联的溢出桶,寻找key(通过桶的tohash字段和key值)
  4. 判断是否需要扩容,需要则执行growWork(t, h, bucket)
  5. 如果当前链上所有桶都满了,创建一个新的溢出桶,串联在末尾,然后更新相关字段
  6. 根据key是否存在,在桶中更新或者新增key/value值
  1. 插入数据时key的定位策略, 在map中要存储一个键值对,必须先找到所在的位置,一般采用hash算法即取模的结果来实现
  1. 在存储数据时首先会调用hasher()计算key的hash值
  2. 通过了解map底层hmap结果,内部有一个B属性表示bucket 数组的容量(bucket capacity),也就是 bucket 数组中 bucket 的最大个数
  3. 拿到hash值后取模2B次方,如果B为5则hash取模25=32,拿到的编号就是对应桶的编号
  4. 如果计算出的桶号中已经存在其他 key,通过链地址法将相同桶号的 key 串联成一个链表
  5. 接下来定位cell,Cell位置计算公式为执行tophash()获取hash的高8位,然后遍历桶中所有cell,找到bmap.tophash值为当前计算出的cell进行存储
  6. 如果当前bucket中没有找到,就去当前bucket 中的溢出桶overflow中查找,如果overflow为nil,则直接返回空值即可

查询数据底层总结

  1. 在查询数据时与上面key定位大致相同,底层会执行函数mapaccess1()(注意还有一个mapaccess2()函数,两者区别是返回值不同,前者返回是一个值,后者返回两个值,第二个值决定了key是否在map中存在,里我们只介绍runtime.mapaccess1())
  1. 执行hasher()计算hash值并根据hash值找到桶
  2. 遍历桶和桶串联的溢出桶,寻找key
  1. 注意:
  1. 如果根据hash值定位到桶正在进行搬迁,并且这个bucket还没有搬迁到新哈希表中,那么就从老的哈希表中找。
  2. 在bucket中进行顺序查找,使用高八位进行快速过滤,高八位相等,再比较key是否相等,找到就返回value。如果当前bucket找不到,就往下找溢出桶,都没有就返回零值

扩容底层总结

  1. 扩容策略看上面
  2. 扩容迁移,当判断需要扩容后,会执行一个hashGrow()函数
  1. 该函数内会对B的数量进行+1,也就是扩容为原理的2倍,分配新的buckets,并将老的buckets挂到oldbuckets字段上,
  2. 查看插入与删除元素时内部执行的growWork()函数,这才是真正搬迁的底层函数,数据的迁移不是一次完成的,是使用时才会做对应bucket的迁移,该函数内部会调用一个evacuate()
  1. 查看evacuate()
  1. 首先定位到老的 bucket 地址
  2. 执行evacuated() 判断当前bucket桶是否已迁移
  3. 在迁移时,根据增量扩容,等量扩容不同策略,迁移逻辑不同, 在等量扩容时,新的 buckets 数量和之前相等,因此可以按序号来搬,比如原来在 0 号 bucktes,到新的地方后,仍然放在 0 号 buckets
  4. 针对增量扩容,新的 buckets 数量是之前的一倍,要重新计算 key 的哈希,才能决定它到底落在哪个 bucket, 扩容时内部声明了X, Y part变量,桶的数量是原来的 2 倍,前一半桶被称为 X part,后一半桶被称为 Y part。一个 bucket 中的 key 可能会分裂落到 2 个桶,一个位于 X part,一个位于 Y part(取决于扩容后,倒数第B位是 0 还是 1)。所以在搬迁一个 cell 之前,需要知道这个 cell 中的 key 是落到哪个 Part

常见问题

  1. Key为什么是无序的?
  1. map 在扩容后会发生 key 的迁移,原来落在同一个 bucket 中的 key,迁移后位置可能就会变(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。迁移后有的key 的位置发生了变化,有些有些key没有,遍历 map 的结果就是原来的顺序了
  2. 当我们在遍历 go 中的 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。
  1. float类型是否可以作为map的key?
  1. float 型可以作为 key,但是由于精度的问题,会导致一些诡异的问题,慎用
  2. 从语法上看,除了 slice,map,functions 这几种类型以外,只要是可比较的类型都可以作为 key,具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组,这些类型的共同特征是支持 = = 和 != 操作符,k1 == k2 时,可认为 k1 和 k2 是同一个 key。
  3. 如果是结构体, hash 后的值相等并且字面值相等,认为是相同的 key。注意有些字面值相等,hash出来的值不一定相等,比如引用。
  1. map可以遍历的同时删除吗? map 不是一个线程安全的,多个协程同时读写同一个 map 会直接 panic,如果在同一个协程内边遍历边删除不会panic,但是遍历结果集中可能包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后
  2. 一般而言,这可以通过读写锁来解决:sync.RWMutex(另外还有sync.map)
  1. 读之前调用 RLock() 函数,读完之后调用 RUnlock() 函数解锁;写之前调用 Lock() 函数,写完之后调用 Unlock() 解锁。
  2. 另外,sync.Map 是线程安全的 map,也可以使用。
  1. 可以对map元素取地址吗: 无法对 map 的 key 或 value 进行取址,将无法通过编译

如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。

  1. 如何比较两个map是否相等?map 深度相等的条件:
  1. 都为 nil
  2. 非空、长度相等,指向同一个 map 实体对象
  3. 相应的 key 指向的 value “深度”相等: 直接将使用 map1 == map2 是错误的。这种写法只能比较 map 是否为 nil。因此只能是遍历map 的每个元素,比较元素是否都是深度相等。
  1. map是线程安全的吗?map 不是线程安全的。

可以在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于1),则直接 panic。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。检测写标志:

	h.flags |= hashWriting
	if h.flags&hashWriting == 0 {
        throw("concurrent map writes")
    }
  1. map的负载因子: 6.5
  2. map中一个桶可以存储几个元素?在早期版本中每个桶的大小都是 8 个元素,在 Go 1.18 中桶的大小增加到了 16 个元素,注意每个元素都包含了元素的 key、value 值以及一些元信息,例如元素的状态和哈希值等。因此,一个桶中实际可以存储的元素数量取决于元素的大小和桶的大小
// src/runtime/map.go
const (
    // Maximum number of key/elem pairs a bucket can hold.
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits
        ......
}
  1. 有一种 key,每次对它计算 hash,得到的结果都不一样。这个 key 就是 math.NaN() 的结果,针对该类型key,防止出现Get 操作获取不到,当迁移时碰到通过 tophash 的最低位决定分配到 X part 还是 Y part(如果扩容后是原来 buckets 数量的 2 倍)。如果 tophash 的最低位是 0 ,分配到 X part;如果是 1 ,则分配到 Y part
  2. 要想实现元素的存储定位需要三个步骤:
  1. 根据h.B 来的值,来取出hash结果的最后 b.B 位数字(低位),定位到bucket
  2. 再根据hash结果的高8位,实现在bucket定位到指定的位置
  3. 最后根据位置分别从 bmap.key和bmap.values中读取存储的值内容
  1. 如何实现两种 get 操作: Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 key 类型的零值。如果 key 是 int 型就会返回 0,如果 key 是 string 类型,就会返回空字符串
package main
import "fmt"
func main() {
    ageMap := make(map[string]int)
    ageMap["qcrao"] = 18
    // 不带 comma 用法
    age1 := ageMap["stefno"]
    fmt.Println(age1)
    // 带 comma 用法
    age2, ok := ageMap["stefno"]
    fmt.Println(age2, ok)
}
  1. map里能不能存空数据,为什么
  1. map 的键不能是空值nil,会引发 panic。因为 map 内部使用了散列函数来计算键的哈希值,而 nil 值没有地址
  2. map 中的 value 可以存储空数据,但是需要注意零值和 nil 值之间的区别,如果 value 类型的零值是一个空值,可以存储,如果value 类型的零值是一个非空值,那么存储空数据时就需要使用指针类型或引用类型来代表空数据,例如使用 nil 指针或 nil 切片等
  1. map初始化的new、make区别
  1. new 函数用于为 map 分配内存空间,并返回一个指向该 map 的指针。使用 new 函数初始化 map 时,需要使用 make 函数为 map 的值赋初值
  2. make 函数用于创建一个非零长度的 map,并返回该 map 的值。使用 make 函数初始化 map 时,不需要再进行后续的赋初值操作
  1. map是线程安全的吗、map为什么不安全: Go语言中的 map 在并发情况下,只读是线程安全的,同时读写是线程不安全的。同一个变量在多个goroutine中访问需要保证其安全性。因为map变量为指针类型变量,并发写时,多个协程同时操作一个内存,类似于多线程操作同一个资源会发生竞争关系,共享资源会遭到破坏,因此golang出于安全的考虑,抛出致命错误
  2. 切片,map是否有序,
  3. 说下Go的map底层实现(关键点:数据结构、渐进式rehash也就是值扩容)
  4. 讲一下你对map的理解。map是有序还是无序,如何扩容的,并发安全。
  5. CurrentMap问题
  6. 有没有导致数据分配不均衡 不用hash 怎么办: 哈希冲突较严重时,或者哈希函数的质量不够高时会出现分配不均衡
  1. 可以尝试调整 map 的大小,重新分配内存空间
  2. Go 语言中,map 内部使用的哈希函数是 Go 的运行时库提供的,可以通过设置环境变量 GOMAXPROCS 来控制哈希函数的数量。如果需要更好的哈希函数,可以使用第三方库提供的哈希函数,例如 hash/fnv 包中提供的 FNV1 和 FNV1a
  3. 可以尝试使用有序的 map,例如使用 Go 语言中的 sort 包中提供的排序算法
  1. golang的hash底层实现 底层是什么吗 详细说下
  2. map如何顺序读取,map 是一种无序的数据结构,因此默认情况下是不能对 map 进行顺序读取的,可以:
  1. 将 map 中的键存储到一个数组或切片中,并排序,通过数组切片获取key,然后在map中读取
  2. 使用有序的 map:可以使用第三方库提供的有序的 map,例如使用 github.com/elliotchance/redismap
  3. 使用 sync.Map
  1. map并发读写问题?
  2. sync.Map如何解决并发问题?是通过以下两种方式实现的
  1. sync.Map 内部使用了一种基于分段锁的方法来保证并发安全。具体来说,它将整个 map 分成多个小的分段,每个分段都有一个锁来保护其中的数据。当一个 goroutine 需要访问 map 中的某个键值对时,它会根据键的哈希值找到相应的分段,并尝试获取该分段的锁。只有获取到锁之后,才能访问该分段中的数据。这种方法可以有效地降低锁的竞争程度,提高并发性能
  2. sync.Map 提供了一些并发安全的 API,例如 Store、Load、LoadOrStore、Delete 和 Range 等方法,这些方法都是并发安全的,可以在多个 goroutine 中同时访问 map,并且不会出现数据不一致的问题
  1. 讲一下你对map的理解。map是有序还是无序,如何扩容的,并发安全。

你可能感兴趣的:(#,二.,Go,常见数据结构实现原理,数据结构,golang,哈希算法)