JAVA集合面试分享三:HashMap实现原理详解

目录

一、HashMap是什么?

二、底层实现方式是什么?

1、为什么选择6个而不是8个呢?

2、为什么Map桶中结点个数超过8才转为红黑树?

三、扩容方式是什么?

1、为什么负载因子设置为0.75?

2、初始化值为什么是16?

3、为什么2的倍数呢?

四、Java 1.7和1.8中的HashMap实现有一些关键的区别?

五、HashMap在多线程环境下为什么是不安全的?


一、HashMap是什么?

HashMap 是一种常用的哈希表实现,它将键(key)映射到值(value)上。它使用哈希函数将键映射到哈希表中的索引,以便快速查找键值对。HashMap 的实现基于数组和链表或红黑树,它通过散列函数来确定每个键值对在数组中的位置。

二、底层实现方式是什么?

JDK1.8之前是数组+链表;

JDK1.8版本后HashMap的数据结构改为为 :数组 + 链表+红黑树;

因为链表的特性,当链表过长时性能会有影响,所以经过大量的测试,选择了数组 + 链表+红黑树结合的方式(使用链表的查找性能是 O(n),而使用红黑树是 O(logn))。

  • 数组+链表(JDK8之前的结构)

  • 数组+链表+红黑树(JDK8后的结构)

HashMap是用链表+红黑树的形式来解决hash冲突的,就是把同一hash值但不相等的数据,归到一个集合内成为哈希桶,内部的元素由链表形式组织起来,hash表中存放链表的头,当链表元素数量达到设置值后就将链表转换成红黑树形式。

在插入时,链表节点超过8个、且数组大小超过64个则将链表转换成红黑树,否则不转换。其中数组小于64时会进行扩容不会转化为红黑树。在移除数据时,当红黑树的节点移除到剩6个时,将红黑树转换成链表。

1、为什么选择6个而不是8个呢?

是为了避免频繁进行红黑树和链表的转换,造成性能的损耗。

2、为什么Map桶中结点个数超过8才转为红黑树?

当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而jdk又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不理想情况下随机hashCode算法下所有bin中结点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的槪率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是随便決定的,而是裉据概率统计决定的。甶此可见,发展将近30年的java每一项改动和优化都是非常严谨和科学的。

也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数。

补充:

  • Poisson分布(泊松分布),是一种统计与概率学里常见到的离散[概率分布]。泊松分布的概率函数为:
    公式
    泊松分布的参数A是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数。

  • 以下是我在研究这个问题时,在一些资料上面翻看的解释,供大家参考:

    红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8) = 3,链表的平均查找长度为n/2,当长度为8时,平均查找长虔为8/2 = 4,这才有转换成树的必要;链表长度如果是小于等于6, 6/2 = 3,而log(6) = 2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

三、扩容方式是什么?

默认的数组大小 initialCapacity 16,加载因子loadFactor 为0.75,扩容的阈值=默认初始大小 * 加载因子 ,默认情况下阈值为 16 * 0.75 = 12; (加载因子0.75是一个合适的值,如果太小则需要不断扩容,太大会导致扩容少,冲突增多)
​ 数组的容量为2n,在达到扩容阈值后,扩容后大小为当前的2倍(计算机的位操作是最快的,2的幂换成2进制就是1后面跟的都是0)

HashMap扩容的步骤:

  • 根据老表的扩容阈值等条件,计算出新表的容量和阈值
  • 然后创建新表,准备进行数据的迁移
  • 如果原Node数组不为空,遍历原Node数组
  • 遍历的该Node元素的下一个为null,则说明该Node元素后边既没有链表又没有红黑树,则将该Node元素直接存于新Node数组的指定位置
  • 如果遍历的该Node元素后边跟着的是一个红黑树结构,则在新的Node数组中,将该红黑树进行拆分,拆分后子树的节点小于等于6个,将其转为链表结构
  • 如果遍历的该Node元素是链表的情况下,对链表进行遍历,将链表中的Node元素迁移到新的Node数组中(分为需要更换数组下标的,和不需要的);
1、为什么负载因子设置为0.75?
  • loadFactor越趋近于1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。
  • 如果希望链表尽可能少些,要提前扩容。有的数组空间有可能一直没有存储数据,负载因子尽可能小一些。
  • 所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。
2、初始化值为什么是16?
  • HashMap在Java中是一种非常常用的数据结构,它用于存储键值对数据。HashMap的初始容量大小是一个可以设置的参数,而在没有明确设置的情况下,HashMap默认的初始容量大小是16。
  • 为什么是16而不是其他数字呢?这个选择与性能和空间效率的权衡有关。初始容量大小的选择会影响到HashMap的空间效率和性能。一个较小的初始容量可能导致频繁的rehashing(重新散列),这会消耗更多的计算资源。而一个较大的初始容量可能会浪费空间,因为可能并不需要那么多空间来存储元素。
  • 16这个数值是一个相对较小的2的整数次幂,这样的设计有利于HashMap使用位操作来进行快速的计算和定位。同时,这个默认值在实际应用中能够提供较好的性能和空间效率,适用于许多场景。
  • 需要注意的是,HashMap在实际使用中,如果元素数量超过了当前容量的某个阈值(负载因子*当前容量),会触发扩容操作,以保证HashMap的操作性能。所以,虽然初始容量是16,但实际上HashMap的容量会根据需要动态调整。
3、为什么2的倍数呢?
  • 性能考虑:当HashMap的长度为2的n次幂时,不同的key产生哈希冲突的几率会相对小一些。这是因为HashMap在计算哈希值对应的数组下标时,会使用位运算(&)来替代取模运算(%)。位运算的速度比取模运算快很多,从而提高了HashMap的性能。而当HashMap的长度为2的倍数时,位运算的结果会更均匀地分布在各个桶中,减少了哈希冲突的可能性。
  • 均匀分布数据:扩容为原来的两倍能够使得数据更均匀地分布,减少哈希碰撞,进而降低链表或者红黑树的长度,提高查询效率。
  • 减少扩容频率:扩容需要消耗一定的时间和空间资源,设置为2倍可以尽量减少扩容次数,提高HashMap的效率。

四、Java 1.7和1.8中的HashMap实现有一些关键的区别?

        以下是一些主要的不同点

  1. 数据结构:在Java 1.7中,HashMap使用数组+链表的数据结构。而在Java 1.8中,HashMap使用了数组+链表+红黑树的数据结构。
  2. 链表与红黑树的转换:在Java 1.8中,当链表长度大于一定阈值(默认为8)时,链表就转化为红黑树,这样可以减少查找时间复杂度。当红黑树节点小于某个阈值(默认为6)时,红黑树又转化为链表。这个变化在Java 1.7中是不存在的。
  3. hash算法:Java 1.8中HashMap的hash算法相比于Java 1.7有所改变,减少了碰撞的可能性,使得数据分布更均匀,提高了性能。
  4. 扩容优化:在Java 1.8中,当HashMap的容量大于64时,才会进行扩容,而在Java 1.7中,这个阈值是16。此外,Java 1.8中引入了位运算进行hash值的计算,优化了性能。

总的来说,Java 1.8对HashMap进行了许多优化,提高了其性能。然而,这些改变也带来了一些新的问题,例如在使用不当的情况下可能会出现链表过长或者红黑树退化的问题,但是这些问题在正常使用的情况下出现的概率并不高。

五、HashMap在多线程环境下为什么是不安全的?

  1. 数据不一致:当多个线程同时对HashMap进行写操作时,可能会导致数据的不一致性。例如,一个线程正在对某个键值对进行修改,而另一个线程可能同时读取或修改同一对键值,导致数据混乱。
  2. 数据丢失:在并发写入的情况下,如果多个线程同时修改了HashMap中的某个键值对,可能会导致其中一个线程的写入结果被覆盖,从而导致数据丢失。
  3. 死循环:在某些情况下,如HashMap的扩容过程中,多线程的并发操作可能会导致链表出现循环引用,进而产生死循环。不过这个问题在JDK1.8中已经得到解决。

        由于 JDK 1.7 中 HashMap 的底层存储结构采用的是数组 加 链表的方式。
        而 HashMap 在数据插入时又采用的是头插法,也就是说新插入的数据会从链表的头节点进行插入。这是插入的原理:

因此,HashMap 正常情况下的扩容就是是这样一个过程。
我们来看,旧 HashMap 的节点会依次转移到新的 HashMap中,旧 HashMap转移链表元素的顺序是A、B、C,而新 HashMap使用的是头插法插入,所以,扩容完成后最终在新 HashMap中链表元素的顺序是 C、B、A。

接下来我们看下造成死循环的原因:

第一步: 线程启动,有线程 T1 和线程 T2 都准备对HashMap 进行扩容操作,此时 T1 和 T2 指向的都是链表的头节点 A,而 T1 和 T2 的下一个节点分别是 T1.next 和 T2.next,它们都指向 B 节点。

第二步: 开始扩容,这时候,假设线程 T2 的时间片用完,进入了休眠状态,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才被唤醒。因为 HashMap 扩容采用的是头插法,线程 T1 执行之后,链表中的节点顺序发生了改变。但线程 T2 对于发生的一切还是不可知的,所以它指向的节点引用依然没变。如图所示,T2 指向的是 A 节点,T2.next 指向的是 B 节点。

当线程 T1 执行完成之后,线程 T2 恢复执行时,死循环就发生了。

因为 T1 执行完扩容之后,B 节点的下一个节点是 A,而T2 线程指向的首节点是 A,第二个节点是 B,这个顺序刚好和 T1 扩容之前的节点顺序是相反的。T1 执行完之后的顺序是 B 到 A,而 T2 的顺序是 A 到 B,这样 A 节点和 B 节点就形成了死循环。

解决方案:
避免 HashMap 发生死循环的常用解决方案有三个:

1)、使用线程安全的 ConcurrentHashMap 替代 HashMap,个人推荐使用此方案。

2)、使用线程安全的容器 Hashtable 替代,但它性能较低,不建议使用。

3)、使用 synchronized 或 Lock 加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。

HashMap 死循环只发生在 JDK1.7 版本中,主要原因是JDK1.7 中的 HashMap,在头插法 加 链表 加 多线程并发 加 扩容这几个情形累加到一起就会形成死循环。多线程环境下建议采用 ConcurrentHashMap 替代。在 JDK1.8 中,HashMap 改成了尾插法,解决了链表死循环的问题

总的来说,为了保证线程安全,当多个线程并发读写HashMap时,应该使用Collections.synchronizedMap方法或者ConcurrentHashMap来保证线程安全。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

之乎者也·

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值