【HashMap】

HashMap底层实现原理以及数据结构​

向HashMap中添加一个元素时,当前元素的key会调用hashCode方法来决定它在数组中存放的位置。如果这个位置没有其他元素,会把这个键值对直接放到一个node类型的数组中,这个数组就是hashmap底层基础的数据结构。如果这个位置有其他元素,会继续拿着这个key调用equals方法和这个位置已存在的元素key进行对比,对比二个元素的key。key一样,返回true,原来的value值会被替换成新的value。key不一样,返回flase,这个位置就用链表的形式把多个元素串起来存放。

jdk1.7版本的HashMap数据结构就是数组加链表的形式存储元素的,但是会有弊端,当链表中的数据较多时,查询的效率会下降。所以JDK1.8版本做了一个升级,当链表长度大于8,但是数组长度小于64时,会进行扩容操作。当链表长度大于8,并且数组长度大于64时,才会转换为红黑树。因为红黑树需要进行左旋,右旋,变色操作来保持平衡,当数组长度小于64,使用数组加链表比使用红黑树查询速度要更快、效率更高。

在HashMap源码有这样一段描述,大致意思是说在理想状态下受随机分布的hashCode影响,链表中的节点遵循泊松分布。链表中的节点数是8的概率接近千分之一,这个时候链表的性能很差,所以在这种比较罕见和极端的情况下才会把链表转变为红黑树,大部分情况下HashMap还是使用链表,如果理想情况下,均匀分布,节点数不到8就已经自动扩容了。

这就是HashMap的底层实现原理,但其实对应上面讲的实现原理有很多扩展的知识点,我大致讲六点。


第一个扩展点

HashMap的数组长度恒定为2的n次方,也就是说只会为16,32,64这种数。即便你给的初始值是13,最后数组长度也会变成16,它会取你传进来的数,最近一个2的n次方的数。

当要存入n个元素到hashmap中的时候,这n个数要散列分布到hashmap数组里面,这就需要考虑元素在hashmap数组既要呈现散列均匀分布,又不浪费空间。

那么就要求每个数通过某种算法,填入到数组中的概率是相等的,这种算法最容易想到的就是取模运算。而计算机本身就是通过位运算完成所有计算的,位运算比模运算快约27倍。

参考hashMap获取索引的算法源码:

public static int indexFor(int h, int length) {
    return h & (length-1);
}

HashMap中运算数组的位置,使用的是length-1,每次扩容会把原数组的长度*2,在二进制上的表现就是高位进1,并且后四位始终都是1111。

初始长度为16的数组,对应的length-1就是15,原数组15二进制后八位为0000 1111。
扩容后的长度为32的数组,对应的length-1就是31,二进制就变成了0001 1111。
再次扩容长度为64的数组,对应的length-1就是63,二进制是0011 1111

假设hashMap容量为16
hash值&运算:

11001110 11001111 00010011 11110001(hash值)
&
00000000 00000000 00000000 00001111(16-1的2进制)
=
00000000 00000000 00000000 00000001

hash的2进制的后4位和1111比较,hash值的后4位范围是0000-1111之间,与上1111,最后的值是在0000-1111,也就是0-15之间。这样就保证运算后的值可以落到数组的每一个下标。

如果数组长度不是2的幂次,后四位就不可能是1111,0000~1111的一个数和有可能不是1111的数进行&运算,数组的某几位下标就有可能永远不会有值,这就没法保证运算后的值可以落到数组的每个下标上面。

通过h & (length-1)的算法可以保证hash的值可能落到数组每一个下标上面,但是并不能保证hash计算后的值尽可能分散。

indexFor中的h是hashCode通过变换之后的值,是一个32位的二进制数,如果直接用如此长的二进制数和目标length-1直接进行与运算,结果会导致高位会大量丢失。

假如我们以16位为划分,任何两个高16位不一样,低16位一样的数。这两个数的hashCode与length-1做与运算(hashCode & length-1),结果会是一样的,这样的两个数,却产生了相同的hash结果,发生hash冲突。

于是hashMap想到了一种处理方式:底层算法通过让32位hashcode中保持高16位不变,高16与低16异或结果,作为新的低16位,然后用hash得到的结果(int h)传入方法indexFor获取到hashMap的索引。

计算中只有低位16位参与&运算,计算效率高,同时也保证的hash的高16位参与了索引运算,这样得到的索引能呈较为理想的散列分布,在将条目放入hashMap中时,最大限度避免hash碰撞。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//把hash值异或了hash值右移16位,即取高16位
}

绝大多数情况下length一般都小于2^16即小于65536,所以indexFor方法中return h & (length-1)的结果始终是h的低16位与(length-1)进行&运算。hashmap为了考虑性能的设计还是非常精妙的。


第二个扩展点

jdk1.7的hashmap有二个无法忽略的问题。

  • 第一个是扩容的时候需要rehash操作,将所有的数据重新计算HashCode,然后赋给新的HashMap,rehash的过程是非常耗费时间和空间的。

  • 第二个是当并发执行扩容操作时会造成环形链和数据丢失的情况,开多个线程不断进行put操作,当旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,就是因为头插法,所以最后的结果打乱了插入的顺序,就有可能发生环形链和数据丢失的问题,引起死循环,导致CPU利用率接近100%。

在JDK1.8中,对HashMap这二点进行了优化。

  • 第一点是经过rehash之后元素的位置,要么是在原位置,要么是原位置+原数组长度。不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了。在数组的长度扩大到原来的2倍, 4倍,8倍时,在resize(也就是length - 1)这部分,相当于在高位新增一个或多个1bit。

    举个例子,初始值是16,通过公式(length -1)取二进制,它的后八位是0000 1111,扩容二倍到32,通过公式(length -1)取31的二进制,它的后八位0001 1111,可以发现它的高位进的是1,然后和原来的hash码进行与操作,这样元素在数组中映射的位置要么不变,要不向后移动16个位置。

    高位上新增的是1的话索引变成原位置+原数组长度,是0的话索引没变。这样既省去了重新计算hash值的时间,而且由于高位上新增的1bit是0还是1,可以认为是随机的,复杂度更高,从而让分布性更高些。

  • 第二点,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况。

    举个例子,如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。

第三个扩展点

HashMap在高并发场景下会出现并发修改异常,导致原因:并发争取修改导致,一个线程正在写,一个线程过来争抢,导致线程写的过程被其他线程打断,导致数据不一致。

  • 第一种解决方案:使用HashTable:HashTable是线程安全的,只不过实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

  • 第二种解决方案:使用工具类Collections.synchronizedMap(new HashMap<>());和Hashtable一样,实现上在操作HashMap时自动添加了synchronized来实现线程同步,都对整个map进行同步,在性能以及安全性方面不如ConcurrentHashMap。

  • 第三种解决方案:使用写时复制(CopyOnWrite):往一个容器里面加元素的时候,不直接往当前容器添加,而是先将当前容器的元素复制出来放到一个新的容器中,然后新的元素添加元素,添加完之后,再将原来容器的引用指向新的容器,这样就可以对它进行并发的读,不需要加锁,因为当前容器不添加任何元素。利用了读写分离的思想,读和写是不同的容器。缺点也很明显,会有内存占用问题,在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存。会有数据一致性问题,CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。

  • 第四种解决方案:使用ConcurrentHashMap:ConcurrentHashMap大量的利用了volatile,CAS等技术来减少锁竞争对于性能的影响。在JDK1.7版本中ConcurrentHashMap避免了对全局加锁,改成了局部加锁(分段锁),分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。不过这种结构的带来的副作用是Hash的过程要比普通的HashMap要长。所以在JDK1.8版本中CurrentHashMap内部中的value使用volatile修饰,保证并发的可见性以及禁止指令重排,只不过volatile不保证原子性,使用为了确保原子性,采用CAS(比较交换)这种乐观锁来解决。

    CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

    volatile有三个特性:可见性,不保证原子性,禁止指令重排。
    可见性:线程1从主内存中拿数据1到自己的线程工作空间进行操作(假设是加1)这个时候数据1已经改为数据2了,将数据2写回主内存时通知其他线程(线程2,线程3),主内存中的数据1已改为数据2了,让其他线程重新拿新的数据(数据2)。
    不保证原子性:线程1从主内存中拿了一个值为1的数据到自己的工作空间里面进行加1的操作,值变为2,写回主内存,然后还没有来得及通知其他线程,线程1就被线程2抢占了,CPU分配,线程1被挂起,线程2还是拿着原来主内存中的数据值为1进行加1,值变成2,写回主内存,将主内存值为2的替换成2,这时线程1的通知到了,线程2重新去主内存拿值为2的数据。
    禁止指令重排:首先指令重排是程序执行的时候不总是从上往下执行的,就像高考答题,可以先做容易的题目再做难的,这时做题的顺序就不是从上往下了。禁止指令重排就杜绝了这种情况。


第四个扩展点

加载因子是用来判断当前HashMap<K,V>中存放的数据量,默认的加载因子是0.75。

加载因子比较大,扩容发生的频率比较低,浪费的空间比较小,发生hash冲突的几率比较大。

  • 比如,加载因子是1的时候,hashmap长度为128,实际存储元素的数量在64至128之间时间段比较多,这个时间段发生hash冲突比较多,造成数组中其中一条链表比较长,会影响性能。

加载因子比较小,扩容发生的频率比较高,浪费的空间比较多,发生hash冲突的几率比较小。

  • 比如,加载因子是0.5的时候,hashmap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个,浪费了。综合了一下,取了一个平均数0.75作为加载因子。

第五个扩展点

key调用equals方法,对比当前元素的key和已存在元素的key,二个key不一样返回flase,表示二个元素的key不一样,但是存储的位置是同一个,这个就是典型的hash碰撞。这个就是典型的hash碰撞,一般解决hash碰撞有四种办法。

  • 一种是建立公共溢出区,将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中,把不冲突的元素放到基本表里面。

  • 一种是再哈希法,当哈希地址发生冲突,再次哈希,直到不发生冲突位置。缺点很明显,每次冲突都要重新哈希,计算时间增加。

  • 一种是开放寻址法,开放寻址法就是假设当前数组位置1,它存放了一个张三,现在要把李四也保存进来,但是这个位置被占用了,那么就把李四就放到下一个位置2上去,如果2也被占用了,就继续往下找,直到找到空位置。缺点有很多,会产生堆积问题,不适合大规模的数据存储,插入时,可能会出现多次冲突的情况,删除数据时,其他数据也有影响,实现相对较为复杂。且节点规模大时,再平方探测会浪费空间。

  • 一种是拉链法,用的是链表的方式,位置1除了存放Entry还要保存一个next指针,指向数组外的另一个位置的内存地址。假设张三和李四都要放在位置1这个位置上,张三先占了这个位置,李四也想存放到位置1上,这时张三这个Entry的next指针,这个指针指向数组外的另外一个位置,将李四安排在这个位置上,也就是张三的next指针指向李四的内存地址,如果后面还有个王五也算到位置1上面去了,还是一样给王五一个新位置,然后李四Entry中的next指向王五,这样就形成了一个链表。hashmap就是使用拉链法解决hash冲突的,它的优点很明显,就是处理冲突简单,没有堆积问题现象。平均查找长度短,时间复杂度低。链表中的节点是动态申请的,适合构造表不能确定的情况,更节省空间。尾插法简单,只需要修改指针,不需要对其他冲突做处理。


第六个扩展点

对hashmap使用的优化,我个人看法有六点。

  • 第一点,建议采用短String,Integer这样的类作为键。特别是String,他是不可变的,也是final的,而且已经重写了equals和hashCode方法,契合HashMap要求的计算hashCode的不可变性要求,核心思想就是保证键值的唯一性,不变性,其次是不可变性还有诸如线程安全的问题,这么定义键,可以最大限度的减少碰撞的出现。如果hashCode不冲突,那查找效率很高,但是如果hashCode一旦冲突,要调用equals一个字节一个自己的去比较,key越短效率越高。

  • 第二点不使用for循环遍历map,而是使用迭代器遍历Map,使用迭代器遍历entrySet在各个数量级别效率都比较高。

  • 第三点使用线程安全的ConcurrentHashMap来删除Map中的元素,或者在迭代器Iterator遍历时,使用迭代器iterator.remove()来删除元素。不可以for循环遍历删除,否则会产生并发修改异常CME。

  • 第四点考虑加载因子地设定初始大小,设定时一定要考虑加载因子的存在。使用的时候最好估算存储的大小,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。Guava的做法是把默认容量的数字设置成预期大小 / 0.75F + 1.0F,可以使用Maps.newHashMapWithExpectedSize(预期大小)来创建一个HashMap,计算的过程guava会帮我们完成。

  • 第五点减小加载因子​,如果Map是一个长期存在而不是每次动态生成的,而里面的key又是没法预估的,那可以适当加大初始大小,同时减少加载因子,降低冲突的机率。毕竟如果是长期存在的map,浪费点数组大小不算啥,降低冲突概率,减少比较的次数更重要。

  • 第六点适当的使用IntObjectHashMap,HashMap的结构是Node[] table; Node下面有Hash,Key,Value,Next四个属性。
    IntObjectHashMap的结构是int[] keys 和 Object[] values。IntObjectHashMap在插入时,同样把int先取模落桶,如果遇到冲突,则不采用HashMap的拉链法,而是用开放地址法(线性探测法)index+1找下一个空桶,最后在keys[index],values[index]中分别记录。在查找时也是先落桶,然后在key[index++]中逐个比较key。所以,对比整个数据结构,省的不止是int vs Integer,还有每个Node的内容。性能IntObjectHashMap还是稳赢一点的,随便测了几种场景,耗时至少都有24ms vs 28ms的样子,好的时候甚至快1/3。

  • 16
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 9
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java程序员廖志伟

赏我包辣条呗

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值