聊一聊不同技术栈中hashmap扩容机制

前言

hash简介

        作为后端开发,说HashMap是我们最经常接触到的数据结构都不为过,而HashMap如其名最主要依赖的算法就是hash散列算法来存储和读取数据。
        以关键码值K为自变量,通过一定的函数关系h(K)(称为散列函数),计算出对应的函数值来,把这个值解释为结点的存储地址,将结点存入到此存储单元中。检索时,用同样的方法计算地址,然后到相应的单元里去取要找的结点。通过散列方法可以对结点进行快速检索。
        按散列存储方式构造的存储结构称为散列表(hash table)。散列表中的一个位置称为槽(slot)。散列技术的核心是散列函数(hash function)。 对任意给定的动态查找表DL,如果选定了某个“理想的”散列函数h及相应的散列表HT,则对DL中的每个数据元素X。函数值h(X.key)就是X在散列表HT中的存储位置。插入(或建表)时数据元素X将被安置在该位置上,并且检索X时也到该位置上去查找。由散列函数决定的存储位置称为散列地址。 因此,散列的核心就是:由散列函数决定关键码值(X.key)与散列地址h(X.key)之间的对应关系,通过这种关系来实现组织存储并进行检索。
在这里插入图片描述

        没有完美的hash散列函数,当元素变多的时候必然会出现slot冲突的问题,那么hash散列后的元素冲突解决也是一个问题,常见的散列方法有:线性探测法、二次散列、拉链法等。如下图所示是一个常用的链地址法解决hash冲突的示意图,在9的slot中,后续进入的key为29和9的元素,会通过生成一个链表链接在该slot上。
在这里插入图片描述

正文

        上面有说到hashmap的一些基本实现原理及解决冲突的方式,那么可以看到hash函数在元素数量过多时会采用不同的冲突解决方案,很明显会对hashmap (o1)的查找插入时间复杂度带来挑战,因此除了优化hash散列算法,在恰当的时候(即元素个数负载因子达到一定的阈值,比如slot为10,元素达到7的时候)对散列表hash table进行扩容也是一个必须采用的方式,这篇文件就想跟大家一起聊一聊在不同领域(语言、软件)中hashmap扩容实现机制。

扩容机制

        本文章中想要探索的hashmap扩容机制,例子来源于当前主流编程语言JavaGolang、内存数据库Redis中对于hashmap中扩容相关实现的不同。

Java中HashMap扩容

        首先看一下Java中hashmap扩容,如下图所示,当进行put操作导致整个哈希表的负载因子因此到达阈值后,会进入一个阻塞的扩容流程中,先通过复制一个两杯容量的entry数组,然后将原有entry中的元素进行遍历执行rehash,jdk1.8使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。因此在执行rehash的时候只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
在这里插入图片描述
        由于整个扩容操作是同步的,面对较大容量hashmap扩容是一个相当耗时的过程,会阻塞当前线程的其他操作。因此我们在java中使用较大容量的hashmap时,常常需要预估初始化hashmap的初始容量,指定一个2的N次方初始值进行优化。

Golang中HashMap扩容

        hashmap的实现原理都差不多,使用hash entry数组+链地址法解决冲突的单链表,因此golang中的hashmap扩容操作也与java中的基本一致,都是新建一个为之前容量2倍的哈希表,然后进行rehash,将元素重新填充。只是在golang这里为了缩短map容器的响应时间,采用了增量扩容的思想。扩容会建立一个大小是原来2倍的新的表,将旧的bucket搬到新的表中之后,并不会将旧的bucket从oldbucket中删除,而是加上一个已删除的标记。正是由于这个工作是逐渐完成的,这样就会导致一部分数据在old table中,一部分在new table中, 所以对于hash table的insert, remove, lookup操作的处理逻辑产生影响,只有当所有的bucket都从旧表移到新表之后,才会将oldbucket释放掉。
        即在进行get操作时会在old table和new table中均进行查找,但是在进行insert、remove时会对该hash table中的slot(bucket)进行扩容迁移,如果当前扩容动作未完成又来了一个新的扩容请求,便进行二次迁移。

Redis中HashMap扩容

        上文中提到的golang增量扩容的思想在redis中的实现更是直接。redis中除了字典的数据结构是使用hashtable存储以外,本身的一整个redis string key的存储也是使用hashtable。对于hashtable的增量式扩容,有个渐进式hash的叫法。redis初始化时就创建了两个hashtable,h0和h1,如下图所示(图引用自《redis设计与实现》),其中h0为默认哈希表对外提供服务,h1默认为空。
在这里插入图片描述

        接着当哈希表的容量到达一个阈值后,需要进行扩容,于是执行渐进式扩容,如下两个图,当有修改操作到当前slot时对旧hashtable中的元素进行rehash至新的hashtable,直至所有元素迁移完成后,再将h0的指针指向h1,h1重置为null,完成操作。
在这里插入图片描述
在这里插入图片描述

        在进行渐进式哈希扩容的过程中,redis会同时使用h0和h1两个哈希表。对于查找操作,会先在h0进行查找,如果没找到的话,会继续在h1的表里进行查找、而新增操作则会都保存到h1中,也保证了h0中的数量不会继续增加。

总结

        整篇文章描述了不同语言或软件中对hashmap扩容这个比较头疼的话题实现方式,Java在看起来是相对比较“僵硬”的同步扩容,如果使用不当会导致当前工作线程阻塞的情况,但还好有hashmap容量初始化的方式可以避免;而golang和redis中的哈希表基本围绕着增量、渐进式扩容的思想展开去实现,相对Java实现这样的一个好处就是对于put操作导致的短暂停扩容,大大缩减了put操作的响应时间,但同时给其他的get和delete操作也新增了实现的复杂度。总之各有所长。 当我只了解Java时,我曾以为扩容都是阻塞式,之后对于其他容器实现的学习也扩展了视野。

参考资料:

《redis设计与实现》4.5节渐进式哈希
golang map扩容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值