底层结构
源码:src/internal/runtime/maps
使用Directory管理多个table,Directory是 Table的数组 ,大小为2^globalDepth。如果globalDepth=2,那Directory最多有4个表,分为0x00、0x01、0x10、0x11。Go通过key的hash值的前 globalDepth 个bit来选择table。这是一种“extendible hashing”,这是一种动态哈希技术,其核心特点是通过动态调整使用的哈希位数(比如上面提到的globalDepth)来实现渐进式扩容。比如:初始可能只用1位哈希值来区分,需要时可以扩展到用2位,再需要时可以扩展到用3位,以此类推。
关键源码:
|
|
|
|
|
|
|
|
特性
多个 directory index 可以指向同一个 table
directory 里存的只是 指针
初始化流程
关键源码:
|
|
源码解读:初始化时会根据 hint 的值进入不同的初始化逻辑:
-
hint<=8,小map
对于小map,指定容量hint<=8,做了优化,不再分配一个table数组,dirPtr直接引用分配
一个group -
hint>8
a> 首先计算目标容量
targetCapacity,算出需要多少slot,才能在“平均负载”下容纳 hint 个元素b> 计算
directory大小dirSize(table 数)( 需要多少个 table,才能容纳 targetCapacity )c> 计算总的
group数量和总内存。d> 设置 globalDepth / globalShift
e> 分配directory并
创建table(初始化时table内部的localdepth=globalDepth)f> directory
写入Map(unsafe) ,最后返回Map。
添加流程
关键源码:
|
|
源码解读:
-
首先进行并发写检测,保障线程安全。
-
然后计算key的hash,并标记写入状态,防止并发写。
-
判断当前
map是否已经分配了,如果没有分配,会先分配一个group(小map)。a. 如果当前map是小map
-
如果是小表且未满,直接插入,返回元素槽指针。
-
如果小表已满,升级为大表(分配table并迁移数据)
b. 如果当前map是大map
-
根据H1计算目录索引,定位table(与查找相同)
-
调用大map的插入函数,若table扩容则重试
-
插入成功返回与元素槽指针
-
-
再次检测并发写,防止并发写破坏数据。
小map写入细节
-
首先获取group的引用,并结合H2对所有控制字进行比对。
-
若存在相同的,比对是否存在相同key,如果存在则返回对应value槽指针。
-
不存在相同的,查找空槽,准备写入新key,如果没有空槽,说明并发写入或者逻辑错误。
-
写入key和value。
-
更新控制字和used计数。
-
返回value槽指针。
大map写入细节
-
首先根据hash的高位H1和group的数量定位探查序列。
-
定义变量,记录第一个已删除槽,以便后续使用。
-
探查循环,遍历group,查找与H2匹配的槽。
-
若有匹配的槽,对比key,若key需要更新,则拷贝新key
-
无匹配槽,查找空槽或已删除槽(tombstone)。若有空槽且growthleft>0,优先复用空槽,设置控制字,更新计数。若growthleft耗尽(表已满),触发rehash(扩容或分裂),返回nil,false,提示上层重试。
搜索流程
关键源码:
|
|
源码解读:
-
根据map的
used字段快速判断,如果是空map,直接返回nil。 -
其次对map的
并发读写进行检测,存在并发读写直接报错。 -
计算key的hash(
H1-高57位,用于定位group。H2-低7位,用于对比控制字)值。 -
根据
dirLen判断是否是小map。a. 如果是小map
-
进入小map的搜索流程,直接从初始化中的
单个group中进行查找 -
获取当前hash的H2,然后
遍历group,逐个对比控制字中的H2部分 -
不匹配就继续下一个,匹配之后,再继续对
key校验(只用7位进行校验,存在hash冲突的可能性,虽然很低,1/128) -
若key相同,找到val直接返回,若遍历结束也没有匹配项,返回nil结束。
b. 如果不是小map
-
先根据key的hash的高位(globalDepth)计算出当前 map 的“目录数组”(
directory)中的索引,从而定位到应该查找或操作的 table(分表)。(如果目录长度为 1,说明 map 只有一个 table(未分表),所有 key 都在这一个 table 里。否则说明已经分表,用hash的高位来选择table。例如globalDepth = 3,则目录长度为8,globalShift = 61,目录索引为hash»61,即hash的最高3位) -
根据hash的
H1部分计算出table内部遍历的group的起始位置。(起始位置offset:uint64(H1) & mask,用 h1(hash) 的低若干位(由 mask 决定)作为 group 的初始索引。其中mask = group 数量 - 1。例如group数量为8,mask=0b111,则只取H1的最低3位。) -
然后开始查找group内的元素,通过
H2与group的8个控制字做并行匹配(复制H2为8份),如果存在匹配的,则继续对key进行比较,如果遇到空槽,说明key不存在,返回nil,false,如果没有找到且没有空槽,继续查找下一个group。
-
关键点
q:为什么遇到空槽代表不存在key?
a:在插入新key时会沿着探查序列查找第一个空槽或者已删除的空槽,查找时同样沿着探查序列查找,如果key曾经被插入过,一定会在遇到空槽之前被找到(插入时遇到空槽就插入了)
删除过程
关键源码:
|
|
源码解读:
-
判断当前map是否为空,如果为空,则删除操作无效。同时检查key的类型是否合法,如果不合法直接
panic。 -
检测并发写状态,保证线程安全。
-
计算key的hash,如果key类型不匹配则panic。
-
设置并发写标志。
-
判断map的大小。
a. 如果是小map
-
获取group引用,匹配H2。
-
若有匹配的,再对比key。
-
找到之后, 计数-1,然后清理key和value。
-
标记slot为空,小map没有探查序列,不需要tombstone直接将控制字设为empty。
-
如果没有匹配的key,则什么都不做。
b. 如果是大map
-
通过H1和表容量构造探查序列。
-
结合H2遍历group,若存在H2匹配的,再对比key。
-
清理key和value。
-
设置控制字,如果当前group还有空槽,直接将ctrl(控制字)设置为empty,并回收growthleft(可插入槽数)。否则,设置为deleted(墓碑),墓碑会在rehash时被清理。
-
如果group没找到目标key,遇到空槽则终止。如果group有空槽,说明key不存在,直接返回。
-
扩容过程:
小map扩容
扩容节点:小map只能存放8个元素,当插入第9个元素时会触发扩容。调用growToTable
关键源码:
|
|
源码解读:
-
分配新表,新表容量为16,原来2倍,localdepth\globaldepth都为0,index为0。
-
遍历原来的8个slot,如果非空,重新计算hash,插入到新表。
-
构建目录指向新表,长度为1。
-
map进入表模式,后续插入查找都走table逻辑。
大map扩容
扩容节点:在插入流程中,如果当前为大表,且在插入时发现没有空槽,就会出发rehash。
关键源码:
|
|
源码解读:
-
首先判断预期的
新容量(旧容量的两倍)是否达到了单表的最大group容量(maxTableCapacity=1024) -
如果没超过,则进入扩容流程(
grow) -
如果超过了,则进入分裂流程(
split)
扩容流程详解(grow)
-
首先创建新table(但还未替换)。
-
判断旧table是否为空,迁移所有元素(新建table时capacity=0,判断避免空表扫描)。
-
遍历所有group,遍历每个group内的8个slot,遇到delete或者empty的直接跳过,获取存活的key\elem,重新计算hash。
-
插入新table(unchecked)(新table是一定有足够空间的)。
-
完整性校验(checkInvariants)(used数量、ctrl状态、 growthLeft 、 map.used 一致性 )。
-
替换旧table(replaceTable)。
-
标记旧table失效(t.index = -1)。
分裂流程详解(split)( Extendible Hashing )
-
当一个table已经到达最大容量( maxTableCapacity ),但还是要继续插入时会进入split。
-
table的localdepth加1(localdepth表示当前table负责的hash范围,区别globaldepth表示当前directory使用的hash的范围)。
-
创建左右两个新table。
-
计算分流掩码mask(用于后续决定元素流向哪个table)。
-
扫描旧table,逐个slot进行分流。跳过delete\empty,取key\elem并重新hash。
-
根据mask,用新增的那一位hash来决定该slot流入哪个table(见上方源码)。
-
插入新table,不需要检查,保证成功。
-
将split的结果加入到directory中(如果directory的globalDepth位数不够用会扩容)。
-
废弃当前table。
分裂过程中第8步详解
判断当前directory的globalDepth位数是否够用。(split会使得localdepth+1,如果超过的globalDepth位数则需要扩容directory)
如果需要翻倍,创建新的dir数组,原来的2倍容量。
a. 复制并维护index。
b. 更新 globalDepth / globalShift 。
c. 原子替换 directory(将原来的dir指针指向新的数组)。
left覆盖原来old table负责的起始位置。
计算right table的index偏移量并设置right index。