java8 HashMap的变化

本文介绍了Java 8中HashMap的变化,主要关注树形桶的引入以及Hash表实现的改进。Java 8采用红黑树处理冲突,当链表长度超过阈值时转换为树形结构,提升查找效率。此外,文章还分析了Java 7与Java 8在扩容策略上的不同,以及多线程环境下可能导致的问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在java容器中map是我们经常会用到的一种容器。而map中HashMap是我们最长用的map之一,而且在java面试中考察容器时HashMap的实现也基本是必考之一。并且我们常常会听到考察HashMap时会问到是否是线程安全?为什么HashMap在多线程并发的时候可能造成CPU利用率变成100%?在解析HashMap的这些文章的中,都是基于Java 8之前的的实现来讲解的。那么Java 8与Java 8之前的HashMap有什么变化呢,这一篇文件会讲解一下Java 8 HashMap的关键变化。

树形桶

在对比java 8与java 7的HashMap源码实现部分。可以发现Java8的源码中静态变量多了几个:

    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    static final int MIN_TREEIFY_CAPACITY = 64;

从这些变量的命名中可以看出都与tree(树)有关。那么这些变量是做什么用的,为什么与树有关呢。这就是在java8中的一个变化。如果有很多Entry都属于一个桶,原来的java7的实现是使用一个双向列表,属于一个桶的Entry都是连接在一起,那么在桶中查找对应的Entry时查找时间是线性的。
那么如果出现一种极端情况所有的Entry的hash值都是一样的,属于同一个桶,那么这个HashMap就会退化成一个LinkList,查找速度是很慢的。所以在java8中做了一些改变,当一个桶的Entry数量太多了,那么就会使用红黑树来保存这个桶的这些Entry。如果所有的Entry都是一个值,那么在Java8中HashMap就会退化成一个红黑树,相比于列表查找速度会更快。
上面的静态变量解释如下:

  • TREEIFY_THRESHOLD: 一个桶要从列表变为树存储的Entry数量的阀值。
  • UNTREEIFY_THRESHOLD:一个桶要从树变回列表存储的Entry数量的阀值,注意这个值必须比 TREEIFY_THRESHOLD小。
  • MIN_TREEIFY_CAPACITY:要树化存储Entry时Map的容量最小值必须达到的阀值。如果map的容量不满足这个值,桶的Entry数量过多就通过resize解决。这个值至少应该是4 * TREEIFY_THRESHOLD,用来避免冲突。

之前有人做过java 7与java 8 HashMap在Entry hash极不均匀时候的get方法性能对比,如下:
在这里插入图片描述
从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这样会将时间复杂度从O(n)降为O(logn)。

Hash表实现的改变

java 7的实现

在java 7中HashMap的实现使用的是chained hash。在桶发生碰撞的时候就使用链表将同一个桶的Entry链接在一起。
在这里插入图片描述
java 7一个新的Entry插入一个桶时所做的操作如上图所示。将新的Entry作为桶的第一个Entry,原来的第一个Entry通过新的Entry的next可以访问到。
当Java7 HashMap要扩容的时候操作如下所示:
在这里插入图片描述
在java 7中假设上面的hash表要进行扩容并且resize,那么扩容的容量会是原来的容量的两倍。并且原来的Entry都需要进行重新hash然后移动到新的桶中。Entry key=3重新hash移动到了桶3,Entry key=7重新hash也要移动到桶3,根据插入的方法Entry key=7会称为桶3第一个Entry。然后插入Entry key=5移动到了桶1,这就是Java 7中HashMap扩容是resize的操作。
上面resize操作的过程如果处于多线程并发的时候,有可能造成循环引用而使得cpu占用率变成100%。
在这里插入图片描述
如上图所示如果有两个线程同时对hashMap进行put操作后,同时都判断要进行resize。当thread1准备进行resize的时候,调度器将thread1调度出去引入thread2进行执行,thread2完成resize后就如上图所示。
在这里插入图片描述
然后这时候thread2被切换出去,thread1重新获得cpu开始运行。这时候thread1就继续它的resize操作,原来它的本地变量e指向Entry key=3,那么Entry key=3重新hash后,需要移动到thread1新创建的hash表的桶3,那么这时候就形成了上图所示的情况。
在这里插入图片描述
thread1继续resize,在thread1中Entry key=3的next是指向Entry key=7,所以这时候就要对Entry key=7进行重新hash插入新的hash表中,那么Entry key=7插入的也是桶3,那么对于thread1和thread2就形成了上图的情况。这时候由于之前thread2先完成了resize,将Entry key=7的next指向了Entry key=3,那么这时候thread1获取的Entry key=7的next有回到了Entry key=3。
在这里插入图片描述
那么这时候thread1继续处理next,又回到了Entry key=3,又将Entry key=3插入,形成了上面的情况,出现循环指针。Entry key=3的next是null,那么结束了插入。
这时候如果我们调用map.get(11),就会陷入循环指针的查找,cpu占用率会变成100%而且get方法不会返回。

java 8的实现

从上面java 7的chained hash的实现中,我们可以看到这种实现在hash表扩容的时候所有的Entry都要进行重新hash移动到新的桶中,这种操作在resize的时候会比较消耗性能。
java 8中不再使用chained hash这种实现而是改用为extensible hash。extensible hash在扩容的时候无需所有的Entry进行重新hash。
在这里插入图片描述
如上图,假设有4个Entry,Entry的hash值的二进制表示中最低两位分别是00,01,10,11。在扩容前hash表有两个桶,在二进制表示中只需要一位就能表示这两个桶,所以在插入上面4个Entry到两个桶时,只需要看每个Entry的最低位与桶的位数是否匹配。如00,10两个Entry最低位都是0那么就插入位数为0的桶,01,11两个Entry插入位数为1的桶。
当扩容的时候,hash表都是2倍扩容。这时桶的二进制表示都要比原来的桶多用一位来表示,比如2个桶变4个桶要用2位来表示。这时候要使Entry进入正确的桶,不用对所有Entry进行重新hash,只用查看一下Entry的hash值更高一位是否为1,如果为1就需要移动,如果为0就不需要移动。
比如对于00,10两个Entry在2个桶的时候只需要看最低一位,因为都为0到所以是到位数为0的桶,而扩容后有4个桶,那么对于这两个entry就需要看更高一位是否为1,00的更高一位还是0,所以不用动,而10的更高一位是1所以会移动,移动到哪呢,就是移动到10位这个桶中。01,11两个Entry也是一样的道理。
为什么更高位是0不用移动而1要移动呢,因为2倍扩容后,需要多用一位,新的桶的高位都是1,原来的桶的高位都是0,Entry更高位只有为1的时候&才会是1,所以才需要移动到新的桶,0进行&的时候都是0,所以不改变所在桶的位置。
虽然Java8的resize方式完全改变,可能不会出现java7时候resize的循环引用的问题,但是不会改变HashMap是线程不安全的本质。在Java8中HashMap仍然是线程不安全的,索引不应该在多线程并发的时候使用HashMap,而应该使用ConcurrentHashMap。

HashMap的一些关键常量

HashMap有些常量是需要记住的,对于更好的使用HashMap是有帮助的。

  • DEFAULT_INITIAL_CAPACITY:默认的HashMap容量,大小是16。如果在构造函数中没有传入这个参数,默认就是16。
  • MAXIMUM_CAPACITY:HashMap的最大容量默认是2^30 = 1073741824。
  • DEFAULT_LOAD_FACTOR:默认的加载因子为0.75。当HashMap存储的容量达到CAPACITY * DEFAULT_LOAD_FACTOR的值就进行扩容。可以通过构造函数改变。

总结

相比与java7,java8对HashMap的实现做了一些关键性的改变,这可以提高HashMap的性能。有人已经对java7与java8的HashMap在hash值比较均匀时对get方法进行了性能测试,结果如下:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值