

数据结构 - hashtable
source link: https://studygolang.com/articles/25348
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

@本文首发于 https://yeqown.github.io
背景
最近一直在看《redis设计与实现》,其中讲了redis中使用到的数据结构如: sds
, ziplist
, skiplist
, hashtable
, intset
, linkedlist
等。读完第一部分之后,再结合 github上 的源码 redis ,本着好记性不如烂笔头的理念,便准备动手撸一遍。
redis中hashtable的特点
- 链地址法解决hash冲突(除此以外,常见的冲突解决办法还有:再散列法/再哈希法/建立公共溢出区)
- 使用了
murmur2
哈希函数。 - 渐进式
rehash
,rehash
过程并不是一步到位,而是在 get/set/del 等操作中,穿插着完成。 - 自动扩容和自动收缩,通过阀值来控制扩容和收缩。
- 有2个bucket,其中0号bucket是最常用的,而1号只会在rehash过程中使用,一旦rehash完成,便不再使用。
解析和实现
hashtable:是根据Key直接访问在内存存储位置的数据结构。如何根据key得到内存中的位置,就需要使用hash函数来从旁协助了。
hash函数:是一种从任何一种数据中创建小的数字“指纹”的方法。简单的说:hash(input) = 1122334455。
这里选择了golang来实现;murmur3 hash算法;
数据结构
一图以蔽之:
// 对外暴露的hashtable type LinkedDict struct { // 2个存储桶,0号正常使用,1号在rehash过程中使用;rehash完成之后,1号赋值给0号然后重置1号。 ht [2]*hashtable // 初始值 -1,表示没有在rehash rehashIdx int } // 存储桶 type hashtable struct { // 底层“数组” table []*dictEntry size int sizemask int used int } // hashtable 元素定义 type dictEntry struct { key string value interface{} next *dictEntry }
方法集
hashtable常用的方法有 GET/SET/DELETE/ITER ,接下来会在SET和DEL中介绍rehash的详细过程。
SET
func (d *LinkedDict) Set(key string, value interface{}) { if d.ht[0].table == nil { d.ht[0].init(InitTableSize) } // isRehashing 判定:`rehashIdx != -1` // needRehash 判定: 装载因子 used / size > 1.0 时触发扩容rehash if !d.isRehashing() && d.needRehash() { // rehashGrowup 表示本次rehash是需要扩容,在配置ht[1].table时会扩展为当前的2倍 // 反之则会缩减内存空间 // startrehash 会设置 rehashIdx = 0, 标志rehash的进度 d.startrehash(rehashGrowup) } if d.isRehashing() { // 如果在rehash过程中,则完成一部分任务: // 根据rehash的进度rehashIdx,选择搬移 d.ht[0].table[rehashIdx]部分的数据,添加到d.ht[1]中 d.steprehash() } // 上述工作完成之后,就可以考虑新增数据了 hashkey := d.hashkey(key) if d.isRehashing() { // 如果在rehash过程中,毋庸考虑,直接新增到d.ht[1]中 d.ht[1].insert(hashkey, newDictEntry(key, value, nil)) return } // 反之,不在rehash过程中,则直接新增到d.ht[0]中 d.ht[0].insert(hashkey, newDictEntry(key, value, nil)) return } // 渐进式rehash,根据rehashIdx确定,要搬移那一部分的数据。 func (d *LinkedDict) steprehash() { entry := d.ht[0].table[d.rehashIdx] // 如果rehashIdx指向的侧链为空,则rehashIdx自增,直到找到有数据的侧链或者数据均搬移完成 for entry == nil { d.rehashIdx++ if d.rehashIdx > d.ht[0].sizemask { d.finishrehash() return } entry = d.ht[0].table[d.rehashIdx] } // 开始搬移动作 // 遍历链表,将所有数据,新增到d.ht[1]中 next := entry.next for entry != nil { entry.next = nil d.ht[1].insert(d.hashkey(entry.key), entry) if next == nil { break } entry = next next = next.next } // 释放d.ht[0].table[rehashIdx]链表:避免干扰查询;释放内存 d.ht[0].table[d.rehashIdx] = nil d.rehashIdx++ if d.rehashIdx > d.ht[0].sizemask { // 是否已经结束,如果结束则: // d.ht[0] = d.ht[1] // d.ht[1] = newHashTable() // rehashIdx = -1 d.finishrehash() } } // 新增一个元素到到存储桶: // 根据hash函数的结果(hashkey)对存储桶大小(size)取模得到结果(pos);ht.table[pos]完成对链表的新增工作。 func (ht *hashtable) insert(hashkey uint64, item *dictEntry) { pos := hashkey % uint64(ht.size) entry := ht.table[pos] last := entry if entry == nil { ht.used++ ht.table[pos] = item return } for entry != nil { if ht.keyCompare(entry.key, item.key) { // 如果key已经存在则覆盖旧值 entry.value = item.value return } last = entry entry = entry.next } ht.used++ last.next = item }
总结:
- rehashIdx 不仅用于标示hashtable是否在rehash过程中,也标示了rehash的进度;
- rehash过程中,新增元素直接新增到1号bucket中;
- 非rehash状态,则新增到0号bucket中;
- 侧链新增元素过程,需比较key值是否存在,如果存在则更新并返回;
- rehash过程中,rehashIdx不是只会增加1单位,而是根据侧链情况来更新;
GET
func (d *LinkedDict) Get(key string) (v interface{}, ok bool) { // 同上SET,不过多赘述 if d.isRehashing() { d.steprehash() } hashkey := d.hashkey(key) v, ok = d.ht[0].lookup(hashkey, key) if !d.isRehashing() { // 如果不在rehash过程中 d.ht[0]中检索的结果便是最终结果 return } else if ok { // 如果在rehash过程中且命中,也返回结果 return v, ok } // 反之 rehash过程中,但在d.ht[0]中没找到,却不代表该key真的不存在, 还需要在d.ht[1]中确定 v, ok = d.ht[1].lookup(hashkey, key) return }
总结:
- 渐进rehash过程与SET中一致
- 查询动作也需要根据rehash状而定:在rehash中则需要检查ht[0]和ht[1];反之只需要检查rehash[0]即可;
- 这里省略了lookup部分的代码,是因为查询和新增在原理上是一致的: 定位 -> 遍历检查 -> 比较key -> 动作 。
DEL
// Del to delete an item in hashtable. func (d *LinkedDict) Del(key string) { if d.ht[0].used == 0 && d.ht[1].used == 0 { return } // 这里相比Set,区别在于:判定的内容不是是否需要扩容而是缩容 // 缩容判定:d.ht[0]的内存空间大于初始值4且“填充率”少于 10% // d.ht[0].size > 4 && (d.ht[0].used*100/d.ht[0].size) < 10 if !d.isRehashing() && d.needShrink() { d.startrehash(rehashShrink) } if d.isRehashing() { d.steprehash() } hashkey := d.hashkey(key) d.ht[0].del(hashkey, key) if d.isRehashing() { d.ht[1].del(hashkey, key) } }
总结:
- 万变不离其宗,不管是SET,GET,DEL 都是先定位,再确定元素位置,再执行相应的动作;
- 缩容判定中,填充率也等价于装载因子;
- 代码中有个取巧:当使用率为0时则直接返回,避免了后续调用~
ITER
- 此部分代码略去;
- 遍历操作也需要视rehash情况而定;
测试
这里我使用了golang内置的Map做了对比测试,结果如下:
builtinMap_1000 cost: 0ms builtinLinkedDict_1000 cost: 0ms getMap_1000 cost: 0ms getLinkedDict_1000 cost: 0ms builtinMap_10000 cost: 4ms builtinLinkedDict_10000 cost: 6ms getMap_10000 cost: 2ms getLinkedDict_10000 cost: 5ms builtinMap_100000 cost: 76ms builtinLinkedDict_100000 cost: 108ms getMap_100000 cost: 56ms getLinkedDict_100000 cost: 131ms builtinMap_1000000 cost: 1053ms builtinLinkedDict_1000000 cost: 1230ms getMap_1000000 cost: 581ms getLinkedDict_1000000 cost: 915ms builtinMap_10000000 cost: 13520ms builtinLinkedDict_10000000 cost: 17137ms getMap_10000000 cost: 8663ms getLinkedDict_10000000 cost: 14271ms
可见差距还是非常大的,这里大胆分析下导致这些差距的原因:
- Key类型,通过pprof分析,在
hashtable.keyCompare
上花费了较多时间,虽然已经通过strings.Compare
来加速orz;相比下golang内置的Map使用了unsafe.Pointer
pointer to unsafe.ArbitraryType (int) 作为key,并针对不同的key类型来设计哈希算法。 - bucket(数据结构)的使用:在我的实现中只使用了2个bucket,常用的只有1个bucket,在定位上:hash后的结果使用取模的方法定位;相比之下,map采用了多个bucket,每个bucket只存放8个元素,在定位上:hash后用
low bits & bucketMask
定位buckets和high 8bits
找到对应的位置,效率更高;
总结
- 实现一个hashtable并不难,难点在于:hash算法的选用(均匀分布);如何降低hash冲突(rehash时机);
- 当完成上述工作的时候,我再去阅读go内置的
map
的实现会容易很多orz,仅相似部分;map比上述hashtable的实现要复杂得多; - 文中所有代码均在 https://github.com/yeqown/has... ;
- 如果只是想要了解原理,参考资料中的 推荐 文档足以;
- 已经实现的版本还可以继续优化,并考虑并发安全问题~
参考资料
Recommend
-
64
首先请先阅读这两个的源码。一、hashMap、hashTable都是Map接口的实现类,但是hashMap类继承自抽象类abstractMap类,hashTable继承自Dictionary类,该类在jdk中这样描述:可见该类已经过时。二、hashTable里面的方法都是...
-
76
-
51
-
38
An HashTable is a simpl...
-
44
Hashtable、HashMap、TreeMap都是比较常见的一些Map实现,它们都是 key-value 键值对的形式存储和操作数据的容器类,同时他们的元素中不能有重复的key,一个key也只能映射一个value值。 下面我从不同的维度来分别...
-
49
本篇博客主要讲解Map接口的4个实现类HashMap、Hashtable、LinkedHashMap、TreeMap的使用方法以及四者之间的区别。 注意:本文中代码使用的JDK版本为1.8.0_191 值得注意的是,Map接口是独立的接口,并没有继...
-
34
你知道的越多,你不知道的越多 点赞再看,养成习惯 本文 GitHub github.com/JavaFamily 已收录,有一线大厂面试点思维导图,也整理了很多我的文档,欢迎Star和完善,大家面试可以参照考点复习,希望我们一起有点东西。 前言 作为
-
19
之前的俩篇文章 深入理解PHP7内核之zval 和
-
46
深入理解PHP7内核之HashTable 本文地址: https://www.laruence.com...
-
18
June 17, 2020Go generics draft design: building a hashtableIn 2018, I implemented a toy hashtable in Go
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK