热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

数据结构hashtable

@本文首发于https://yeqown.github.

@本文首发于 https://yeqown.github.io

背景

最近一直在看《redis设计与实现》,其中讲了redis中使用到的数据结构如:sds, ziplist, skiplist, hashtable, intset, linkedlist等。读完第一部分之后,再结合github上的源码redis,本着好记性不如烂笔头的理念,便准备动手撸一遍。

redis中hashtable的特点


  1. 链地址法解决hash冲突(除此以外,常见的冲突解决办法还有:再散列法/再哈希法/建立公共溢出区)

  2. 使用了murmur2哈希函数。

  3. 渐进式rehashrehash过程并不是一步到位,而是在get/set/del 等操作中,穿插着完成。

  4. 自动扩容和自动收缩,通过阀值来控制扩容和收缩。

  5. 有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
}

总结:

  1. rehashIdx 不仅用于标示hashtable是否在rehash过程中,也标示了rehash的进度;

  2. rehash过程中,新增元素直接新增到1号bucket中;

  3. 非rehash状态,则新增到0号bucket中;

  4. 侧链新增元素过程,需比较key值是否存在,如果存在则更新并返回;

  5. 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
}

总结:

  1. 渐进rehash过程与SET中一致

  2. 查询动作也需要根据rehash状而定:在rehash中则需要检查ht[0]和ht[1];反之只需要检查rehash[0]即可;

  3. 这里省略了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)
}
}

总结:

  1. 万变不离其宗,不管是SET,GET,DEL 都是先定位,再确定元素位置,再执行相应的动作;

  2. 缩容判定中,填充率也等价于装载因子;

  3. 代码中有个取巧:当使用率为0时则直接返回,避免了后续调用~

ITER

  1. 此部分代码略去;

  2. 遍历操作也需要视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

可见差距还是非常大的,这里大胆分析下导致这些差距的原因:

  1. Key类型,通过pprof分析,在hashtable.keyCompare上花费了较多时间,虽然已经通过strings.Compare来加速orz;相比下golang内置的Map使用了unsafe.Pointerpointer to unsafe.ArbitraryType (int)作为key,并针对不同的key类型来设计哈希算法。

  2. 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...;

  • 如果只是想要了解原理,参考资料中的推荐文档足以;

  • 已经实现的版本还可以继续优化,并考虑并发安全问题~

参考资料


  • https://en.wikipedia.org/wiki...

  • http://redisbook.com/


  • https://redisbook.readthedocs... 推荐


  • https://github.com/cch123/gol... 推荐

  • https://github.com/yeqown/has...


推荐阅读
  • 本文介绍了Redis的基础数据结构string的应用场景,并以面试的形式进行问答讲解,帮助读者更好地理解和应用Redis。同时,描述了一位面试者的心理状态和面试官的行为。 ... [详细]
  • 本文介绍了如何在给定的有序字符序列中插入新字符,并保持序列的有序性。通过示例代码演示了插入过程,以及插入后的字符序列。 ... [详细]
  • 本文介绍了Perl的测试框架Test::Base,它是一个数据驱动的测试框架,可以自动进行单元测试,省去手工编写测试程序的麻烦。与Test::More完全兼容,使用方法简单。以plural函数为例,展示了Test::Base的使用方法。 ... [详细]
  • 1,关于死锁的理解死锁,我们可以简单的理解为是两个线程同时使用同一资源,两个线程又得不到相应的资源而造成永无相互等待的情况。 2,模拟死锁背景介绍:我们创建一个朋友 ... [详细]
  • 个人学习使用:谨慎参考1Client类importcom.thoughtworks.gauge.Step;importcom.thoughtworks.gauge.T ... [详细]
  • [大整数乘法] java代码实现
    本文介绍了使用java代码实现大整数乘法的过程,同时也涉及到大整数加法和大整数减法的计算方法。通过分治算法来提高计算效率,并对算法的时间复杂度进行了研究。详细代码实现请参考文章链接。 ... [详细]
  • Imtryingtofigureoutawaytogeneratetorrentfilesfromabucket,usingtheAWSSDKforGo.我正 ... [详细]
  • 云原生边缘计算之KubeEdge简介及功能特点
    本文介绍了云原生边缘计算中的KubeEdge系统,该系统是一个开源系统,用于将容器化应用程序编排功能扩展到Edge的主机。它基于Kubernetes构建,并为网络应用程序提供基础架构支持。同时,KubeEdge具有离线模式、基于Kubernetes的节点、群集、应用程序和设备管理、资源优化等特点。此外,KubeEdge还支持跨平台工作,在私有、公共和混合云中都可以运行。同时,KubeEdge还提供数据管理和数据分析管道引擎的支持。最后,本文还介绍了KubeEdge系统生成证书的方法。 ... [详细]
  • IhaveconfiguredanactionforaremotenotificationwhenitarrivestomyiOsapp.Iwanttwodiff ... [详细]
  • 本文主要解析了Open judge C16H问题中涉及到的Magical Balls的快速幂和逆元算法,并给出了问题的解析和解决方法。详细介绍了问题的背景和规则,并给出了相应的算法解析和实现步骤。通过本文的解析,读者可以更好地理解和解决Open judge C16H问题中的Magical Balls部分。 ... [详细]
  • C语言注释工具及快捷键,删除C语言注释工具的实现思路
    本文介绍了C语言中注释的两种方式以及注释的作用,提供了删除C语言注释的工具实现思路,并分享了C语言中注释的快捷键操作方法。 ... [详细]
  • 知识图谱——机器大脑中的知识库
    本文介绍了知识图谱在机器大脑中的应用,以及搜索引擎在知识图谱方面的发展。以谷歌知识图谱为例,说明了知识图谱的智能化特点。通过搜索引擎用户可以获取更加智能化的答案,如搜索关键词"Marie Curie",会得到居里夫人的详细信息以及与之相关的历史人物。知识图谱的出现引起了搜索引擎行业的变革,不仅美国的微软必应,中国的百度、搜狗等搜索引擎公司也纷纷推出了自己的知识图谱。 ... [详细]
  • Spring特性实现接口多类的动态调用详解
    本文详细介绍了如何使用Spring特性实现接口多类的动态调用。通过对Spring IoC容器的基础类BeanFactory和ApplicationContext的介绍,以及getBeansOfType方法的应用,解决了在实际工作中遇到的接口及多个实现类的问题。同时,文章还提到了SPI使用的不便之处,并介绍了借助ApplicationContext实现需求的方法。阅读本文,你将了解到Spring特性的实现原理和实际应用方式。 ... [详细]
  • Google Play推出全新的应用内评价API,帮助开发者获取更多优质用户反馈。用户每天在Google Play上发表数百万条评论,这有助于开发者了解用户喜好和改进需求。开发者可以选择在适当的时间请求用户撰写评论,以获得全面而有用的反馈。全新应用内评价功能让用户无需返回应用详情页面即可发表评论,提升用户体验。 ... [详细]
  • 本文介绍了Linux Shell中括号和整数扩展的使用方法,包括命令组、命令替换、初始化数组以及算术表达式和逻辑判断的相关内容。括号中的命令将会在新开的子shell中顺序执行,括号中的变量不能被脚本余下的部分使用。命令替换可以用于将命令的标准输出作为另一个命令的输入。括号中的运算符和表达式符合C语言运算规则,可以用在整数扩展中进行算术计算和逻辑判断。 ... [详细]
author-avatar
yzkgt18688161
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有