关于Hash Table的小小思考——Touch With Next-Era HashTable

哈希表在数据结构的世界中绝对称得上是一等公民,很多语言都有自己对哈希表的实现,前段时间,go 1.24 更新后,宣布正式采用Swiss Table作为其map的底层实现,这让我非常好奇,为什么是Swiss Table?,决定一款哈希表优劣的因素是什么?
为了探究这个问题,我决定先回顾一下,java 和 go中map的实现,希望能从中横向对比出一些端倪
Java - HashMap
Java的HashMap可谓是老生常谈了,它的扩容机制和链表转树都非常优秀,但我不禁在想,如果它的设计真的非常优秀,那为什么其他语言不采用一样的设计呢?也整个链表散列,也设置个负载因子。所以我觉得,它肯定在某些场景下有它的不足,并且对于每个语言,肯定有适合语言本身的map实现,看某个设计不能光看它优秀的地方,更要看不足的地方,因为你想嘛,它不足的地方肯定是设计者也没有实现的地方,岂不是在技术难度上,更加有钻研的意义呢?
底层结构
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { // 序列号 private static final long serialVersionUID = 362498820763181265L; // 默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; // 默认的负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 当桶(bucket)上的结点数大于等于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8; // 当桶(bucket)上的结点数小于等于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; // 桶中结构转化为红黑树对应的table的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; // 存储元素的数组,总是2的幂次倍 transient Node<k,v>[] table; // 一个包含了映射中所有键值对的集合视图 transient Set<map.entry<k,v>> entrySet; // 存放元素的个数,注意这个不等于数组的长度。 transient int size; // 每次扩容和更改map结构的计数器 transient int modCount; // 阈值(容量*负载因子) 当实际大小超过阈值时,会进行扩容 int threshold; // 负载因子 final float loadFactor;}
Hash冲突解决方案
Java处理Hash冲突的方式是 拉链法,即将当Bucket中发生Hash冲突时,将插入元素挂在冲突元素的后面 ,形成一条链表。当Bucket数组长度大于64且链表长度大于8时会将此Bucket转为红黑树

讲讲我的看法,拉链法其实侧重点在于数据的写,也就是高频的插入与删除,这种情况下,链表、树的结构会更加灵活,但这样也造成了一定的内存问题,大规模存储下, 我冲突的每个Bucket都是一个长链表,每个Node除了存储KV以外还会存储next指针,内存不友好。最重要的是,Java的Map扩容依靠 resize,在大规模场景下,我认为是有点危险的,因为这里涉及到rehash,如果有一大堆写操作,那得多慢。。
扩容
Java中Hashmap的扩容就不多赘述了,capacity、threshold、loadfactory。插入时比较阈值,如果超过了,就二倍扩容容量,然后rehash。没什么好说的,八股都快背烂了。。
这里贴一下扩容的源码
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 没超过最大值,就扩充为原来的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold // 创建对象时初始化容量大小放在threshold中,此时只需要将其作为新的数组容量 newCap = oldThr; else { // signifies using defaults 无参构造函数创建的对象在这里计算容量和阈值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { // 创建时指定了初始化容量或者负载因子,在这里进行阈值初始化, // 或者扩容前的旧容量小于16,在这里计算新的resize上限 float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { // 把每个bucket都移动到新的buckets中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 只有一个节点,直接计算元素新的位置即可 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 将红黑树拆分成2棵子树,如果子树节点数小于等于 UNTREEIFY_THRESHOLD(默认为 6),则将子树转换为链表。 // 如果子树节点数大于 UNTREEIFY_THRESHOLD,则保持子树的树结构。 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // 原索引 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab;}Go - map
Go的map总给我一种十分质朴的感觉,在用户面前展露的十分有限,甚至暴露的原语都十分的少,这就是Go的设计哲学,短平快,虽然我真的超级无敌喜欢Java,但不得不说Java真的太他妈的啰嗦了,没有var关键字之前,整个多层嵌套的泛型对象声明像他妈在低魔世界观下吟唱灭世级高级魔法。所以GO写久了真的让我感觉清爽了。扯远了,GO的map基于开放寻址+多槽位桶,给人一种整整齐齐的感觉,没有拉链那么花里胡哨
底层结构
type hmap struct { //哈希表中的元素个数,len(m) 返回的就是该值 count int //状态(现在是否处于写入状态) flags uint8 //Buckets的对数 B uint8 //溢出桶的数量 noverflow uint16 //生成hash的随机种子 hash0 uint32 //指向buckets数组的指针 (如果元素为0则为nil) buckets unsafe.Pointer //如果扩容,指向老桶数组指针,len_ob = len_nb >> 1,否则为nil oldbuckets unsafe.Pointer //表示扩容进度,小于此地址的buckets代表迁移完成 nevacuate uintptr //溢出桶, 为了优化gc扫描 extra *map.extra}
type bmap struct { tophash [bucketCnt]uint8 //长度8的数组,用于快速定位key的tophash是否匹配,来确定key是否在这个bucket中}当Go在编译阶段时,会将这个bmap拓展
type bmap struct { tophash [8]uint //编译器确定map的kv存储类型 keys [8]keyType values [8]valueType //指向下一个bmap,用uintptr而不是*bmap是为了保证bmap结构中无显式指针,为了减少gc,而将溢出桶存储在extra字段中。 overflow uintptr}可以注意到的是,Go的map,k和v是分开各自存储的,但是在新版本中,kv是成对存储的, 我们之后细说
当map的key和value都不是指针类型时候, bmap将完全不包含指针,那么gc时候就不用扫描bmap。 bmap指向溢出桶的字段overflow是uintptr类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是”bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存
Hash冲突解决方案
Go的Hash冲突解决,其实采用了 拉链 + 线性探索。
拉链在于——每个bucket的容量实际上只有8个kv,当用满后,会启用溢出桶,挂载当前bmap的后面。
线性探索在于——当key被匹配时,会先根据hash_key的低B位来决定分配的bucket,然后根据高8位来快速判断该hash_key是否存在于当前bucket,如果存在,则顺序查找下一个空位,如果无空位,则分配溢出桶。
Go的map的最大的特点,我认为就是结构简单,bucket数组,每个bucket存8个kv,同时bucket之后可以挂新的bucket,比Java的kv链表拉链、转红黑树要清爽很多。。这也很符合Go的设计哲学,在我看来,Go语言给我的感觉就是—— Simple Design But Complex Applicate,我觉得优秀的设计往往如此,只提供精简的功能,复杂的需求由用户自己去编排设计。比如 ZooKeeper就是如此,如果你了解ZK,你会知道,ZK只暴露了非常简单的原语,这也印证了这个观点
扩容
在Go的map中,也有LoadFactory的概念,但不会显式地声明在结构体的内部,而是在扩容期间动态地计算
在Put后,会进行一次LF的计算,当LF > 6.5时就会触发一次扩容
loadFactor := count / (2 ^ B)另外,当overflow中的bucket过多时,也会触发扩容
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool { if B > 15 { B = 15 } return noverflow >= uint16(1)<<(B&15)}这二者各自关系着一种原因:
①元素过多,但bucket不够多 ②元素没那么多,存储密度过于集中
我们接着看扩容,值得提一嘴的是,涉及到扩容的有两个重要函数: hashGrow、growWork,前者负责开辟新空间,后者负责数据迁移。其实过程还蛮复杂的,步骤比较多
//hasGrowbigger := uint8(1)// 负载判断if !overLoadFactor(h.count+1, h.B) { // 扩容 bigger = 0 h.flags |= sameSizeGrow}
// 初始化新bucketsoldbuckets := h.buckets
//h.B + 1, 容量变成二倍//h.b + 0, 容量不变newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)//真正的迁移func growWork(t *maptype, h *hmap, bucket uintptr) { //判断old bucket是否被迁移过,没被迁移就直接迁移 evacuate(t, h, bucket&h.oldbucketmask()) if h.growing() { evacuate(t, h, h.nevacuate) }}迁移过程就好说了,循环老bucket,取元素,取tophash,然后根据tophash来决定存放位置,在扩容期间,map会将自身划分成两个区域,Xpart、Ypart,这就会有一个问题——原来本身在一个bucket的元素,可能会被分在两个不同区域的桶中,所以在 搬迁一个 cell 之前,需要知道这个 cell 中的每个 key 具体落到哪个 part。当然也很简单,重新计算 cell 中 key 的 hash,并向前“多看”一位,再决定落入哪个 part
以上就是go map 的扩容过程,其实说复杂也不复杂,就是可能会在新旧桶切换的处理上要多一些步骤,可以看到,在go的设计哲学中,这种 “折腾” 的方式很受欢迎,相同的设计在 sync.map中也得到了体现——dirty bucket,实现的读写分离,让我想到了之前写的一些算法题,比如二叉树的层序遍历,就用到了这种处理方式,这里就不多赘述了,感兴趣的朋友可以按照我的思路去串一遍,可能会有不同的理解~
(PS:好吧,再看一遍确实比较突兀,我说二叉树层序遍历是指每一层都分离的情况,这样的话可能用两个数组,交替表示相邻层的Nodes,这不就是一种读写分离吗?)
SwissTable
铺垫了这么久。。终于可以开始正题了
不过有一说一啊,在写java和go的部分的时候,确实和以前不一样,之前就是一味背八股,可能想着看一些源码, 准备跟面试官装波大的
但现在回来再看看这些设计,能注意到不一样的方面了,有在感受 “设计”了。
目光转到SwissTable,我了解到的是,这款新时代哈希表是google在cpp2017大会上发布的,但是在go上应用,却是2022年字节递交的提案,然后去年才刚通过,在1.24版本正式上线。。。可能google官方也觉得之前不太成熟,又做了一些优化吧。
废话不多说了,一起来瞧瞧这 “次世代哈希表” 吧
底层结构

type Map [K comparable, V any] struct { ctrl []metadata groups []group[K, V] hash maphash.Hasher[K] resident uint32 dead uint32 limit uint32}
type metadata [groupSize]int8
type group[K comparable, V any] struct { keys [groupSize]K values [groupSize]V}如你所见,在SwissTable中,数据的存储不像Go-1.24前,数据成对存储,而是将Key放在一起,Value放在一起。kv一起组成的group,作为map的存储单元。其中最重要的,就是Map的 ctrl字段。我们接下来就着重来看这部分
SwissTable使用一种名为 closed-hashing 的不同散列方案,与将元素收集到桶中不同,散列表中的每个键-值对被分配其自己的“槽位”。此槽位的位置由探测算法决定,其起始点由键的哈希决定。最简单的例子是线性探测搜索,它从槽位 hash(key) % size 开始,并在找到所需键或到达空槽位时停止。这种探测方法用于查找现有键和为新键找到插入位置。与内置的 Golang map 类似,SwissTable 使用“短哈希”来加速探测期间的相等性检查。但与内置的 map 不同,它的哈希元数据与KV数据分开存储。
元数据控制方案
在每一个Map中,始终维护一个元数据数组,也就是我们的 ctrl ,当我们需要对Map中的元素进行检索和修改时,优先在元数据中寻找,元数据中会记录很多信息,例如对应的 slot
是否为空、删除。最主要的是,元数据中存储着每个 slot 的低八位 hash ,为什么要这么做呢? 实际上,如果使用比较优秀的哈希函数~~(完美哈希函数)~~,那么实际上,可以通过低八位的哈希值来确定槽位是否匹配的,那如果冲突了呢?就再取用前58位比对。这种存储方式被称为 分段内存布局,即在表中探测序列,只访问元数据数组(ctrl),直到短哈希匹配,这种访问模式在探测过程中极大地利用了缓存,一旦元数据匹配,对应的键就总是匹配的
Code In 2017 [CppCon](CppCon 2017: Matt Kulukundis “Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step” - YouTube)
iterator find(const K& key,size_t hash) const{ size_t = H1(hash) % size_; while(true){ if (H2(hash)==ctrl_[pos] && key == slots_[pos]){ return iterator_at(pos); } if (ctrl_[pos]==kEmpty) return end(); pos = (pos+1) % size_; }}Code By dolthub
func (m Map) find(key) (slot int, ok bool) { h1, h2 := hashOf(key) // high and low hash bits s := modulus(h1, len(m.keys)/16) // pick probe start for { // SSE probe for "short hash" matches matches := matchH2(m.metadata[s:s+16], h2) for _, idx := range matches { if m.keys[idx] == key { return idx, true // found |key| } } // SSE probe for empty slots matches = matchEmpty(m.metadata[s:s+16]) for _, idx := range matches { return idx, false // found empty slot } s += 16 }}SSE Instruction
SSE Instruction (Stream SIMD Extention Instruction) 是Intel设计的基于 X86 架构的单指令多数据指令集拓展。在需要对多个数据对象上指令完全相同的操作的场景可以大大提高性能,大量应用于数字信号处理和图形处理
前文着重讲了数据结构上的设计,大家也可以了解到SwissTable最大的特点:更密集、更友好的缓存内存布局、更快速、更小型,所以如果 Hash Find过程中使用 SSE指令,会极大地加速键值的查找,该设计被证明非常有效,目前已被其他语言采用。Rust 的 SwissTable 移植 Hashbrown 已经被纳入 Rust 标准库,在 Rust 1.36 中推出。
当Map进行 Hash Find 时,将会在 16 个 Slot 中一起进行查找,也就使用到了SSE,也就是说,SwissTable 在进行 Hash Find时,是并行的。
扩容
swiss map的核心创新之一是采用了Extendible Hashing(扩展哈希),以支持高效的增量扩容。传统哈希表在扩容时需要全量迁移数据,而ExtendibleHashing通过多级目录和表拆分,将扩容开销分摊到多次操作中。
在 map.go 的 Map 结构体中,globalDepth 和 directory 字段是关键:
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/map.go#L194type Map struct { globalDepth uint8 // dir的全局深度 dirPtr unsafe.Pointer // dir指针(指向多个 table) // ...}dir大小为 1 << globalDepth,每个dir项指向一个 table。当某个 table 的负载过高时,会触发拆分(Split),而非全局扩容。
拆分(Split)
当 SwissTable 的单个表容量大于等于maxTableCapacity(默认1024)的时候,会将一个表拆分成两个表,流程如下
-
原表创建两个新的子表
-
使他们的
localDepth比自己大1,哈希掩码也会多一个比特位 -
将内存一分为二,分别指向两个子表
-
获取新分配的内存对象
//https://github.com/golang/go/blob/3f4164f508b8148eb526fc096884dba2609f5835/src/internal/runtime/maps/table.go#L1043func (t *table) split(typ *abi.SwissMapType, m *Map) { localDepth := t.localDepth localDepth++ // 子表的 localDepth 比原表大 1 left := newTable(typ, maxTableCapacity, -1, localDepth) right := newTable(typ, maxTableCapacity, -1, localDepth) // ...}目录更新 (Update)
拆分完成后,需要更新全局目录(
Map.directory),使原表的索引范围指向新的子表。如果原表的localDepth等于全局的globalDepth,则目录需要扩展(翻倍)。
原表拆分后 :
-
目录翻倍 :
globalDepth增加,目录大小翻倍 -
索引重映射: 将原表的目录项替换为指向子表的指针
func (m *Map) installTableSplit(old, left, right *table) { if old.localDepth == m.globalDepth { // 目录扩展:大小翻倍 newDir := make([]*table, m.dirLen*2) // 复制旧目录项并指向新表 for i := range m.dirLen { newDir[2*i] = left newDir[2*i+1] = right } m.dirPtr = unsafe.Pointer(&newDir[0]) m.globalDepth++ } else { // 不扩展目录,仅替换部分项 entries := 1 << (m.globalDepth - left.localDepth) for i := 0; i < entries; i++ { m.directorySet(uintptr(old.index+i), left) m.directorySet(uintptr(old.index+i+entries), right) } }}缺点
即便SwissTable的设计十分优秀,但还是留下来了一些问题,例如并发局限、内存碎片化、迭代器复杂化、结构体存储恶化等,感兴趣的同学可以去官网了解一下,我这里不多赘述了。还是SwissTable希望在今后的迭代中越来越好的
总结
最后聊一聊我的一些见解,我对 SwissTable 最大的印象就是这种基于元数据的分段存储,这种设计很常见,例如 MinIO 里的对象文件索引。说到底,元数据就是对数据本身的再造索引,是一种缓存的思想,和 OS 中的虚拟内存,分页是异曲同工的。
所以在存储的研发道路上,硬件一股脑地在优化存储单元,逼近摩尔定律的极限,软件则顺着硬件的脚步,把数据尽可能地往更快的内存里塞,甚至将数据分层,这真的对么?一味地吃硬件的红利,这不会太狡猾、太贪婪了吗?这真的是软件研发工程师的使命吗?基于外存存储的Kafka不也有令人瞠目结舌的吞吐,所以,我觉得存储研发的道路还道长且阻,需要很多人,很多人去投身其中。
Simon
Apri 2025