深度解析GoLand map原理及实现,手撕源码!(一)——基本介绍,初始化,读

深度解析GoLand map原理及实现,手撕源码!(一)——基本介绍,初始化,读

一、map

Map 是一种无序的键值对的集合。

Map 最重要的一点是通过 key 来快速检索数据,key 类似于索引,指向数据的值。

读、写、删除操作控制,时间复杂度为O(1)

(1) map的初始化:

map中,key的数据类型必须是可比较的类型 slice,map,func不可比较

//使用make
m := makemap[string]int//使用字面量
m := map[string]int{}

(2) map的基本方法:

  • 获取元素: 如果键不存在,ok 的值为 false,value值为该类型的零值
value1 := m["first"]
value2,ok := m["second"]
  • 修改元素
m["first"] = 100
  • 删除元素
delete(m,"first")
  • 遍历map:是无序的
for k, v := range m {
    fmt.Printf("key=%s, value=%d\n", k, v)
}
  • 获取map长度
len := len(m)
  • 并发

map 不是并发安全的数据结构 具体规则是:
(1)并发读没有问题;
(2)并发读写中的“写”是广义上的,包含写入、更新、删除等操作;
(3)读的时候发现其他 goroutine 在并发写,抛出 fatal error;
(4)写的时候发现其他 goroutine 在并发写,抛出 fatal error.

fatal("concurrent map read and map write")
fatal("concurrent map writes")

此处并发读写会引发 fatal error,是一种比 panic 更严重的错误,无法使用 recover 操作捕获.

(3) map的核心原理

基本上hash map的核心原理可以总结为以下三步

  1. 通过哈希方法获得key的哈希值
  2. 哈希值对桶数组的长度取模,确定其所属的桶
  3. 在桶中插入 key-value对

当然了,桶数组是有限的,而数据量却是无限的,必然会发生哈希冲突,现在通常使用的有两种方法:

  • 拉链法——简单常用;无需预先为元素分配内存
  • 开放寻址法——无需额外的指针用于链接元素;内存地址完全连续,可以基于局部性原理,充分利用 CPU 高速缓存。

实际上,在map解决冲突问题的时候,结合了这两种思路,在后文详解源码的时候说,这里线暂时按下不表。

光解决hash冲突问题还是不够的,为了保证常数的时间复杂度,map还引入了扩容机制,常用的有两种,增量扩容和等量扩容。

  • 当桶内 key-value 总数/桶数组长度 > 6.5 时发生增量扩容,桶数组长度增长为原值的两倍;
  • 当桶内溢出桶数量大于等于 2^B 时( B 为桶数组长度的指数,B 最大取 15),发生等量扩容,桶的长度保持为原值;
  • 为了避免性能抖动,采取了渐进扩容的方式

(4) map源码详解

源码位置在 \src\runtime\map.go
主要分为六大流程

  • 初始化
  • 遍历
  • 扩容

4.1 结构体

在进行源码分析之前,我们先要了解其中用到的一些结构体

4.1.1 hmap

源代码带有部分注释,下面是翻译+补充后的注释

type hmap struct {
	// 注意:hmap 的格式也在 cmd/compile/internal/reflectdata/reflect.go 中编码。
	// 请确保此处与编译器定义保持同步。
	count     int // map 中的 key-value 总数  必须放在第一位(被 len() 内置函数使用)
	flags     uint8 // map 状态标识,可以标识出 map 是否被 goroutine 并发读写;
	B         uint8  // 桶数组长度的指数 (可以容纳 loadFactor * 2^B 个元素)
	noverflow uint16 // 溢出桶的近似数量;有关详细信息,请参见 incrnoverflow
	hash0     uint32 // hash 随机因子,生成 key 的 hash 值时会使用到;

	buckets    unsafe.Pointer // 2^B 个 桶数组。如果 count==0,则可能为 nil。
	oldbuckets unsafe.Pointer // 老桶数组,大小为当前大小的一半,仅在扩容时非 nil
	nevacuate  uintptr        // 扩容时的进度标识,index 小于 nevacuate 的桶都已经由老桶转移到新桶中;
	extra *mapextra // 预申请的溢出桶.
}
4.1.2 mapextra
type mapextra struct {
	// 如果键和元素都不包含指针并且是内联的,那么我们将标记 bucket 类型为不包含指针。
	// 这避免了扫描这样的映射。
	// 然而,bmap.overflow 是一个指针。为了保持溢出桶的存活,
	// 我们在 hmap.extra.overflow 和 hmap.extra.oldoverflow 中存储指向所有溢出桶的指针。
	// overflow 和 oldoverflow 仅在键和元素不包含指针时使用。
	// overflow 包含 hmap.buckets 的溢出桶。
	// oldoverflow 包含 hmap.oldbuckets 的溢出桶。
	// 间接性允许在 hiter 中存储指向切片的指针。
	overflow    *[]*bmap
	oldoverflow *[]*bmap

	// nextOverflow 保存指向一个空闲溢出桶的指针
	nextOverflow *bmap
}

上面这个注释可能看不太明白,简单解释一下:

  • mapextra.overflow:供桶数组 buckets 使用的溢出桶;

  • mapextra.oldoverFlow: 扩容流程中,供老桶数组 oldBuckets 使用的溢出桶;

  • mapextra.nextOverflow:下一个可用的溢出桶;

4.1.3 bmap
type bmap struct {
	// tophash 通常包含此桶中每个键的哈希值的顶部字节。
	// 如果 tophash[0] < minTopHash,
	// 那么 tophash[0] 就代表一个桶扩容状态。
	tophash [bucketCnt]uint8
	// 之后是 bucketCnt 个键,然后是 bucketCnt 个元素。
	// 注意:将所有键打包在一起,然后是所有元素打包在一起,使得
	// 代码比交替 key/elem/key/elem/... 更复杂一些,但它允许
	// 我们消除可能需要的填充,例如,对于 map[int64]int8。
	// 之后是一个溢出指针。
}

这个可能更抽象了,简单解释一下:

  • bmap就是map,存储了8个键值对以及一个溢出桶指针
  • 键值对不仅仅包含 key 和 value ,还包含了一个顶哈希值(就是代码中的tophash),这个值主要方便后续通过内存地址偏移的方式来寻找key数组、value数组以及溢出桶指针
  • 源码中只展示了tophash部分,为方便理解,给大家一个补全后的源码:
const bucketCnt = 8  // 每个桶可容纳的最大的键值对的数量
//源码中是这样定义这个常量的:
//bucketCnt = abi.MapBucketCount
//MapBucketCount = 1 << MapBucketCountBits
//MapBucketCountBits = 3
//合成一下就是 bucketCnt = 8 
type bmap struct {
    tophash [bucketCnt]uint8
    keys [bucketCnt]T
    values [bucketCnt]T
    overflow uint8
}

4.2 初始化 —— makemap

map的初始化是通过调用makemap方法实现的,下面是源码:

// makemap 用于实现 Go 语言中的 map 创建,对应 make(map[k]v, hint)。
// 如果编译器确定 map 或第一个 bucket 可以在栈上创建,
// 那么 h 和/或 bucket 可能是非 nil。
// 如果 h != nil,map 可以直接在 h 中创建。
// 如果 h.buckets != nil,指向的 bucket 可以用作第一个 bucket。
func makemap(t *maptype, hint int, h *hmap) *hmap {
	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()

	// 找到能够容纳请求元素数量的大小参数 B。
	// 对于 hint < 0,overLoadFactor 返回 false,因为 hint < bucketCnt。
	B := uint8(0)
	for overLoadFactor(hint, B) {
		B++
	}
	h.B = B

	// 分配初始哈希表
	// 如果 B == 0,buckets 字段会在稍后懒加载(在 mapassign 中)
	// 如果 hint 很大,清零这块内存可能需要一段时间。
	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
}

主要就是6步走:

4.2.1 hint 为 map 拟分配的容量;在分配前,会提前对拟分配的内存大小进行判断,倘若超限,会将 hint 置为零;
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
   hint = 0
}
  • 先将hint转为无符号整型(uintptr),乘上每个桶的大小(t.bucket.size),得到map分配的总内存大小(mem);并判断结果是否超出无符号整型(uintptr)所能表示的最大值,将这个布尔值赋给overflow
  • 如果超出了无符号整型的最大值,或者超出了最大可分配内存大小(maxAlloc),就将hint赋值为0,方便后续操作
4.2.2 通过 new 方法初始化 hmap;
if h == nil {
   h = new(hmap)
}
4.2.3 调用 fastrand,构造 hash 因子:hmap.hash0;
h.hash0 = fastrand()
4.2.4 调用overLoadFactor,基于 log2(B) >= hint 的思路,计算桶数组的容量 B;
B := uint8(0)
for overLoadFactor(hint, B) {
	B++
}
h.B = B
4.2.5 调用 makeBucketArray 方法,初始化桶数组 hmap.buckets;
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
4.2.6 倘若 map 容量较大,会提前申请一批溢出桶 hmap.extra.
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)

源码中涉及到了两个新方法:overLoadFactor、makeBucketArray

overLoadFactor

通过 overLoadFactor 方法,对 map 预分配容量和桶数组长度指数进行判断,决定是否仍需要增长 B 的数值:

const loadFactorNum = 13
//这个数实际应为12 为方便理解,可以看作13
//计算过程如下:
// loadFactorNum = (bucketCnt * 13 / 16) * loadFactorDen
// bucketCnt =  8  前面计算过这个值了
// loadFactorDen = 2
//所以 loadFactorNum = (8 * 13 / 16) * 2 = 12
const loadFactorDen = 2
const goarch.PtrSize = 8
const bucketCnt = 8
// overLoadFactor 判断将 count 个元素放入 1<<B 个桶中是否超过了负载因子
func overLoadFactor(count int, B uint8) bool {
	return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

// bucketShift返回1<<b,为代码生成优化。
func bucketShift(b uint8) uintptr {
	// 通过掩码操作来限制移位的数量,可以省略溢出检查。
	return uintptr(1) << (b & (goarch.PtrSize*8 - 1))
}
  • 倘若 map 预分配容量小于等于 8,B 取 0,桶的个数为 1;

  • 保证 map 预分配容量小于等于桶数组长度 * 6.5

map 预分配容量、桶数组长度指数、桶数组长度之间的关系如下表:

kv 对数量桶数组长度指数 B桶数组长度 2^B
0 ~ 801
9 ~ 1312
14 ~ 2624
27 ~ 5238
2^(B-1) * 6.5+1 ~ 2^B*6.5B2^B
makeBucketArray

makeBucketArray 方法会进行桶数组的初始化,并根据桶的数量决定是否需要提前作溢出桶的初始化:

// makeBucketArray为map桶初始化一个后备数组。
// 1<<b是要分配的最小桶数。
// dirtyalloc应该是nil,或者是之前由具有相同t和b参数的makeBucketArray分配的桶数组。
// 如果dirtyalloc为nil,则将分配一个新的后备数组,否则将清除dirtyalloc并将其重用作后备数组。
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
	base := bucketShift(b)
	nbuckets := base
	// 对于较小的b,溢出桶的可能性较小。
	// 避免计算的开销。
	if b >= 4 {
		// 添加上估计的溢出桶的数量
		// 以插入具有此b值的中位数元素所需的数量。
		nbuckets += bucketShift(b - 4)
		sz := t.Bucket.Size_ * nbuckets
		up := roundupsize(sz)
		if up != sz {
			nbuckets = up / t.Bucket.Size_
		}
	}

	if dirtyalloc == nil {
		buckets = newarray(t.Bucket, int(nbuckets))
	} else {
		// dirtyalloc先前由上述newarray(t.Bucket, int(nbuckets))生成,但可能不为空。
		buckets = dirtyalloc
		size := t.Bucket.Size_ * nbuckets
		if t.Bucket.PtrBytes != 0 {
			memclrHasPointers(buckets, size)
		} else {
			memclrNoHeapPointers(buckets, size)
		}
	}

	if base != nbuckets {
		// 我们预先分配了一些溢出桶。
		// 为了将跟踪这些溢出桶的开销降到最低,
		// 我们使用的约定是,如果预先分配的溢出桶的溢出
		// 指针为nil,则通过增加指针来获得更多的溢出桶。
		// 我们需要一个安全的非nil指针,用于最后一个溢出桶;只需使用buckets。
		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
}
  • makeBucketArray 会为 map 的桶数组申请内存,在桶数组的指数 b >= 4时(桶数组的容量 >= 52 ),会需要提前创建溢出桶,通过 base 记录桶数组的长度,不包含溢出桶;通过 nbuckets 记录累加上溢出桶后,桶数组的总长度.
base := bucketShift(b)
nbuckets := base
if b >= 4 {
   nbuckets += bucketShift(b - 4)
}
  • 如果没有提供现有的桶数组,那么就调用newarray函数来创建一个新的桶数组。
buckets = newarray(t.bucket, int(nbuckets))
  • 如果已经有一个先前分配的桶数组。这个数组可能已经包含了一些数据,所以需要被清空。(清空方式根据桶中是否有指针类型字段来判断:有指针就选用memclrHasPointers,没指针就选用memclrNoHeapPointers)
buckets = dirtyalloc
size := t.Bucket.Size_ * nbuckets
if t.Bucket.PtrBytes != 0 {
	memclrHasPointers(buckets, size)
} else {
	memclrNoHeapPointers(buckets, size)
}
  • 如果需要创建溢出桶,会基于地址偏移的方式,通过 nextOverflow 指向首个溢出桶的地址。
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 //返回桶数组的起始地址和下一个溢出桶的地址。

backets:桶数组的起始地址
base:当前桶的索引
uintptr(t.bucketsize):每个桶的大小
nbuckets:桶的总数

  • 如果不需要创建溢出桶,会在将最后一个溢出桶的 overflow 指针指向 buckets 数组,以此来标识申请的溢出桶已经用完。
func (b *bmap) setoverflow(t *maptype, ovf *bmap) {
    *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-goarch.PtrSize)) = ovf
}

unsafe.Pointer(b): 当前桶b的指针
goarch.PtrSize:当前操作系统架构下指针的大小

4.3 读 —— mapaccess

读流程主要分为以下几步:

  1. 根据 key 取 hash 值;
  2. 根据 hash 值对桶数组取模,确定所在的桶;
  3. 沿着桶链表依次遍历各个桶内的 key-value 对;
  4. 命中相同的 key,则返回 value;倘若 key 不存在,则返回零值;

源码如下:

// mapaccess1返回一个指向h[key]的指针。它永远不会返回nil,如果
// key不在map中,它将返回一个引用elem类型的零对象。
// 注意:返回的指针可能会使整个map保持活动状态,所以不要
// 长时间持有它。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	// 如果启用了竞态检测并且h不为nil,进行竞态检测。
	if raceenabled && h != nil {
		callerpc := getcallerpc()
		pc := abi.FuncPCABIInternal(mapaccess1)
		racereadpc(unsafe.Pointer(h), callerpc, pc)
		raceReadObjectPC(t.Key, key, callerpc, pc)
	}
	// 如果启用了内存清理检测并且h不为nil,进行内存读取检测。
	if msanenabled && h != nil {
		msanread(key, t.Key.Size_)
	}
	// 如果启用了地址清理检测并且h不为nil,进行地址读取检测。
	if asanenabled && h != nil {
		asanread(key, t.Key.Size_)
	}
	// 如果h为nil或者h的计数为0,返回零值的引用。
	if h == nil || h.count == 0 {
		if t.HashMightPanic() {
			t.Hasher(key, 0) // 见 issue 23734
		}
		return unsafe.Pointer(&zeroVal[0])
	}
	// 如果map正在写入,抛出致命错误。
	if h.flags&hashWriting != 0 {
		fatal("concurrent map read and map write")
	}
	// 计算key的哈希值。
	hash := t.Hasher(key, uintptr(h.hash0))
	// 计算桶掩码。
	m := bucketMask(h.B)
	// 定位到key所在的桶。
	b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.BucketSize)))
	// 如果有旧桶存在,检查是否需要从旧桶中查找。
	if c := h.oldbuckets; c != nil {
		if !h.sameSizeGrow() {
			// 旧桶数量是当前桶数量的一半;向下调整一个二的幂次。
			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
			}
			// 定位到key的存储位置。
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
			if t.IndirectKey() {
				k = *((*unsafe.Pointer)(k))
			}
			// 如果key匹配,定位到value的存储位置并返回。
			if t.Key.Equal(key, k) {
				e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
				if t.IndirectElem() {
					e = *((*unsafe.Pointer)(e))
				}
				return e
			}
		}
	}
	// 如果没有找到key,返回零值的引用。
	return unsafe.Pointer(&zeroVal[0])
}

核心代码详解:

4.3.1 倘若 map 未初始化,或此时存在 key-value 对数量为 0,直接返回零值;
if h == nil || h.count == 0 {
    return unsafe.Pointer(&zeroVal[0])
}
4.3.2 倘若发现存在其他 goroutine 在写 map,直接抛出并发读写的 fatal error;其中,并发写标记,位于 hmap.flags 的第 3 个 bit 位;
 const hashWriting  = 4
 
 if h.flags&hashWriting != 0 {
        fatal("concurrent map read and map write")
 }
4.3.3 通过 maptype.hasher() 方法计算得到 key 的 hash 值,并对桶数组长度取模,取得对应的桶
 hash := t.hasher(key, uintptr(h.hash0))
 m := bucketMask(h.B)
 b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize))

其中,bucketMast 方法会根据 B 求得桶数组长度 - 1 的值,用于后续的 & 运算,实现取模的效果:

eg: %8和&7效果是一致的,都是返回除以8的余数 解释如下:

&是位与运算符,它对两个数的二进制表示进行位与操作。当使用a & 7时,由于7的二进制表示是0111,这个操作实际上保留了a的二进制表示的最后三位,并将其他位设置为0。这相当于计算a除以8的余数,因为最后三位二进制数表示的值的范围也是从0到7

func bucketMask(b uint8) uintptr {
    return bucketShift(b) - 1
}
4.3.4 在取桶时,会关注当前 map 是否处于扩容的流程,倘若是的话,需要在老的桶数组 oldBuckets 中取桶,通过 evacuated 方法判断桶数据是已迁到新桶还是仍存留在老桶,倘若仍在老桶,需要取老桶进行遍历.
 if c := h.oldbuckets; c != nil {
    if !h.sameSizeGrow() {
        m >>= 1
    }
    oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
    if !evacuated(oldb) {
        b = oldb
    }
 }

在取老桶前,会先判断 map 的扩容流程是否是增量扩容,倘若是的话,说明老桶数组的长度是新桶数组的一半,需要将桶长度值 m 除以 2.

const (
    sameSizeGrow = 8
)


func (h *hmap) sameSizeGrow() bool {
    return h.flags&sameSizeGrow != 0
}

取老桶时,会调用 evacuated 方法判断数据是否已经迁移到新桶. 判断的方式是,取桶中首个 tophash 值,倘若该值为 2,3,4 中的一个,都代表数据已经完成迁移.

const emptyOne = 1 //空元素的顶部哈希值
const evacuatedX = 2
const evacuatedY = 3
const evacuatedEmpty = 4 
const minTopHash = 5 //最小的有效顶部哈希值

func evacuated(b *bmap) bool {
    h := b.tophash[0]
    return h > emptyOne && h < minTopHash
}
4.3.5 取 key hash 值的高 8 位值 top. 倘若该值 < 5,会累加 5,以避开 0 ~ 4 的取值. 因为这几个值会用于枚举,具有一些特殊的含义.
const minTopHash = 5

top := tophash(hash)

func tophash(hash uintptr) uint8 {
    top := uint8(hash >> (goarch.PtrSize*8 - 8))
    if top < minTopHash {
        top += minTopHash
    }
    return top
4.3.6 开启两层 for 循环进行遍历流程,外层基于桶链表,依次遍历首个桶和后续的每个溢出桶,内层依次遍历一个桶内的 key-value 对.
bucketloop:
for ; b != nil; b = b.overflow(t) {
    for i := uintptr(0); i < bucketCnt; i++ {
        // ...
    }
}
return unsafe.Pointer(&zeroVal[0])

内存遍历时,首先查询高 8 位的 tophash 值,看是否和 key 的 top 值匹配.

倘若不匹配且当前位置 tophash 值为 0,说明桶的后续位置都未放入过元素,当前 key 在 map 中不存在,可以直接打破循环,返回零值.

const emptyRest = 0
if b.tophash[i] != top {
    if b.tophash[i] == emptyRest {
          break bucketloop
    }
    continue
}

倘若找到了相等的 key,则通过地址偏移的方式取到 value 并返回.

其中 dataOffset 为一个桶中 tophash 数组所占用的空间大小.

if t.key.equal(key, k) {
     e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
     return e
}

倘若遍历完成,仍未找到匹配的目标,返回零值兜底.

  • 12
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值