Golang map源码浅析

设计概述

map源码位于runtime/map.go

map的设计概述如下:

  • map是一个哈希表;
  • 数据被组织成bucket数组,每个bucket最多存8个键值对;
  • 哈希值的低位用于选择bucket数组的下标(取余)。每个bucket包含哈希值的若干高位,用于定位一个bucket内的键值对;
  • 如果多于八个键被哈希到同一个bucket中,将一个额外的bucket连接到其后面(拉链法,额外的bucket被称为overflow bucket);
  • 当哈希表需要扩容时,分配一个两倍大小的bucket数组;
  • 在扩容后,bucket被增量地(incrementally)从旧的bucket数组移到新的bucket数组;
  • map的迭代器遍历bucket数组并根据遍历顺序返回键;
  • 为了保证迭代器不会访问同一个键两次,map不会在一个bucket内移动一个键;
  • 当在正在发生迁移的map中遍历时,迭代器在旧的bucket数组上迭代,并检查键是否已经被迁移(evacuated)。

map的首部

先粗略看一下map中的各个字段。

// A header for a Go map.
type hmap struct {
   
	count     int // map的大小,也就是对map使用len()的值
	flags     uint8	// 状态标识,用于控制goroutine写入和扩容的状态,详见下文
	B         uint8  // 桶的数量,2^B个
	noverflow uint16 // 溢出桶(overflow)个数
	hash0     uint32 // 哈希因子

	buckets    unsafe.Pointer // 2^B个bucket的数组
	oldbuckets unsafe.Pointer // 扩容后的旧bucket数组
	nevacuate  uintptr        // 迁移计数器,此指针之前的所有桶已被迁移,即,nevacuate指向桶数组已迁移桶的最高下标

	extra *mapextra // 可选的域
}
// bmap表示一个桶
type bmap struct {
   
	// tophash包含该bucket中键的哈希值的高八位
	// 如果tophash[0] < minTopHash,tophash[0]表示桶迁移状态。
	tophash [bucketCnt]uint8	// bucketCnt == 8,一个桶有八个位置
	// 跟在tophash后面的事八个键和八个值。
	// 将所有键、值分别放在一起,即以|k|k|k|k|v|v|v|v|的方式保存,可以避免内存对齐造成空间浪费。
	// 如map[int64]int8,为了做内存对齐,占一字节的int8需要用7个字节填充对齐。
}

上述bmap结构体定义只有一个tophash保存桶中各键的哈希值的高八位,实际上bmap还保存了各个键值对。根据编译期间的 cmd/compile/internal/gc.bmap 可以重建其结构:

type bmap struct {
   
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}
type mapextra struct {
   
	// 如果键值都不包含指针,并且允许内联,会将bucket类型标志为不包含指针。这样做避免了GC扫描整个map。
	// 为了保证空闲的overflow bucket在GC过程中存活,将各个指向溢出桶的指针保存到overflow和oldoverflow中。
	// overflow和oldoverflow只在键值均不包含指针时使用。
	// overflow包含了hmap.buckets的溢出桶,oldoverflow包含了hmap.oldbuckets的溢出桶。
	// 这种间接寻址使得可以在hiter(用于对map进行迭代)中保存slice的指针。
	overflow    *[]*bmap	// 指向一个元素为*bmap的slice的指针
	oldoverflow *[]*bmap

	// nextOverflow为空闲溢出桶的指针
	nextOverflow *bmap
}

map的初始化

map的初始化有两种:

m := make(map[string]int)
// 指定map长度
m := make(map[string]int, 8)

其实际调用的是makemap函数。

func makemap64(t *maptype, hint int64, h *hmap) *hmap {
   
	// 当hint类型为int64时校验将其转换为int再转换为int64是否值不变,
	// 如果hint的值发生变化,则将hint设置为0.
	if int64(int(hint)) != hint {
   
		hint = 0
	}
	return makemap(t, int(hint), h)
}

// 当hint<=8且map需要在堆上分配时调用该方法创建map.
// 该方法仅分配一个hmap首部,初始化哈希因子后返回。
func makemap_small() *hmap {
   
	h := new(hmap)
	h.hash0 = fastrand()
	return h
}

// makemap实现了标准的map初始化动作。
// 如果编译器确定map或者第一个bucket可以在栈上创建,h和bucket可能不是nil。
// 如果h != nil,map可以在h中直接创建。
// 如果h.buckets != nil,指向的桶可以用作第一个桶。
func makemap(t *maptype, hint int, h *hmap) *hmap {
   
	// 将hint和t.bucket.size相乘,并检查乘积是否溢出。
	// mem即hint个t.bucket.size大小的桶所需的内存大小。
	mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
	// 如果乘积溢出,或所需内存超过最大可分配内存,将hint设为0.
	if overflow || mem > maxAlloc {
   
		hint = 0
	}
	// 分配hmap首部
	if h == nil {
   
		h = new(hmap)
	}
	// 初始化哈希因子
	h.hash0 = fastrand()

	// 找到可以容纳所需元素数的参数B。
	B := uint8(0)
	// 计算B值。计算方法:
	// 假设将hint个元素放到2^B个桶中,检查每个桶的平均元素数是否大于最大加载因子6.5
	// 将B自增直至满足平均每个桶的元素数<=6.5.
	for overLoadFactor(hint, B) {
   
		B++
	}
	h.B = B
	
	// 分配哈希表
	// 如果B == 0,桶被延迟分配。
	// 如果hint很大,分配内存需要花费一定时间。
	if h.B != 0 {
   
		var nextOverflow *bmap
		// makeBucketArray初始化并返回一个桶数组。
		// 它有可能会预分配一些溢出桶,即 nextOverflow。
		// 如果预分配了溢出桶,把它挂到h.extra.nextOverflow。
		h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
		if nextOverflow != nil {
   
			h.extra = new(mapextra)
			h.extra.nextOverflow = nextOverflow
		}
	}

	return h
}

由上述初始化方法可以看到,当创建的map容量很小时(hint <= 8),仅初始化map首部和哈希因子,而没有为桶数组分配空间。否则进行如下动作:

  1. 参数判断,检查创建数组大小是否合法;
  2. map首部和哈希因子进行初始化;
  3. 根据传入的 hint 计算出桶的数量。具体地,找到 容纳hint个元素,且每个桶的平均负载量<=6.5个元素 的数组大小;
  4. 调用makeBucketArray为桶数组分配空间。

最后返回h,它的类型是*hmap,说明平时使用的 map其实是一个指针。相比之下 slice创建返回的是slice结构体,如下:

func makeslice(et *_type, len, cap int) slice

slice结构体如下:

// runtime/slice.go
type slice struct {
   
    array unsafe.Pointer // 元素指针
    len   int // 长度 
    cap   int // 容量
}

两者的区别是,当map作为函数参数时,在函数内部对map的操作会影响调用者持有的map;而slice作为参数时,在被调用函数内对slice进行append或者移除末尾元素对调用函数内的slice不会产生影响。

map元素的访问

要查找一个map的值,首先要给出key,对这个key进行哈希,得到哈希值。根据得到的hash对桶数组取余,确定key应该落在哪个桶中。然后顺序比较桶中的八个key(先比较hash高八位,相等时再比较键),找到key就能够定位要查找的value。
如果没有找到,且还有溢出桶,则跳到溢出桶继续上述比较。
示意图如下:
map查找和插入过程
插入过程与上述类似,只不过是在桶中找到第一个空位,将键值对插入。

源码中,mapaccess1mapaccess2mapaccessKmapaccess1_fatmapaccess2_fat均为map元素的访问方法。

在Go语言编译的类型检查期间,会根据接受参数的个数决定使用的运行时方法:

  • 当接受参数仅为一个时,会使用runtime.mapaccess1,该函数只返回一个指向目标值的指针;
  • 当接受两个参数的时候,会使用runtime.mapaccess2,除了目标值,还会返回一个用于表示当前目标值是否存在的布尔值;
  • mapaccessK只用于map迭代器;
  • _fat方法则用于当值占用空间大于zeroVal数组,需要返回一个额外的零值。
mapaccess1

mapaccess1是访问map元素主要方法,其源码及解析如下:

// mapaccess1返回指向h[key]的一个指针。mapaccess1永远不会返回nil,
// 而会返回一个零对象的引用,用于当键不在map中时代表元素的类型。
// 因为返回的指针会使整个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)
	}
	
	// 如果map首部为nil或map中没有元素,返回零值。
	if h == nil || h.count == 0 {
   
		// 如果该maptype的哈希函数会panic,运行一下该maptype的哈希函数,然后将一个空字节返回。
		if t.hashMightPanic() {
   
			t.hasher(key, 0) // see issue 23734
		}
		return unsafe.Pointer(&zeroVal[0])
	}
	
	// 并发读写,直接panic.
	if h.flags&hashWriting != 0 {
   
		throw("concurrent map read and map write")
	}
	
	// 根据键和哈希因子求哈希值
	hash := t.hasher(key, uintptr(h.hash0))
	
	// m是B个二进制1,通过hash&m能对哈希值进行取余操作。
	m := bucketMask(h.B)
	// hash&m相当于hash%tableSize,即哈希值对数组大小取余,定位到键应该位于第几个桶。
	// (hash&m)*uintptr(t.bucketsize)则偏移了(hash&m)个桶的内存位置。
	// 桶数组起始地址+偏移值定位到桶的内存位置。
	b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
	
	// 如果有oldbuckets,则说明当前map仍在迁移中
	if c := h.oldbuckets; c != nil {
   
		// 如果不是容量不变的迁移,说明现在的数组大小比之前增长了一倍,则将掩码右移一位得oldbuckets的掩码。
		if !h.sameSizeGrow() {
   
			m >>= 1
		}
		// 数组起始地址+偏移值在旧数组中定位到桶的内存位置。
		oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
		// 如果还没迁移,就在旧数组中找键值对。
		// 由上文map首部知,evacuated()实际是检查了桶oldb的tophash域的第一个byte的值。
		if !evacuated(oldb) {
   
			b = oldb
		}
	}
	
	// 根据哈希值计算tophash值,tophash即哈希值的高8位。
	// 由于小于minTopHash的tophash用于指示桶是否已经迁移,
	// 如果tophash<minTopHash,则tophash+=minTopHash.
	top := tophash(hash)
	// 从定位到的bucket及其溢出链查找key
bucketloop:
	for ; b != nil; b = b.overflow(t) {
   	// b.overflow(t)跳到下一个溢出桶
		// 遍历一个桶的八个位置
		for i := uintptr(0); i < bucketCnt; i++ {
   
			// 先比较tophash
			if b.tophash[i] != top {
   
				// 如果tophash值为emptyRest,则往后的位置已经没有元素了,
				// 也没有更多溢出桶了。
				if b.tophash[i] == emptyRest {
   
					break bucketloop
				}
				
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值