HashMap在JDK8前后的区别

 

背景

       目前,部门的很多JAVA项目使用的还是JDK7,其实JDK8的升级进行了很多优化,而且目前最新的JDK版本已经已经到了JDK12,版本帝真的很可怕。其实也不用很慌,因为从JDK9开始就是每半年发布一个版本,2019年JDK就会到JDK13,更多的关注重大功能变更就好了。但是对于HashMap来说,JDK8的优化还是有可以看一下源码的意义的,本文的目的就是针对于这个优化画一下重点。

JDK7的HashMap原理

       在JDK7中,HashMap的实现方式是数组+链表。put过程,首先通过对key取hash值,然后根据hash值定位该key在数组中的索引查找数据。为了解决哈希碰撞问题,数组的每一个元素又是一个链表,这样hash值相同但是实际不相同的key会在同一个链表上。而get过程大致相同,先根据key取hash值,根据hash值拿到数组的索引,通过索引拿到链表,遍历链表找到key对应的节点获取数据。

JDK8针对HashMap的优化

        JDK7对于HashMap的设计也会有相应的问题。首先无论多么优秀的哈希算法也无法避免大量key集中到一个链表上,我们也知道对于一个链表来说,一次查询的时间复杂度为O(n),如果将一个节点新加到链表的Head的话时间复杂度为O(1)。因此JDK8就是通过使用红黑树在某些情况下代替链表来提高HashMap的查询性能。总的来说,就是数组+链表 to 数组+链表+红黑树的优化方案。

 红黑树原理

       使用红黑树(Red Black Tree)在某些情况下替代链表是HashMap优化的中心思想,接下来我们就简单介绍一些红黑树的原理,分析一下红黑树代替链表的好处。

       红黑树是一棵只有红黑节点且近似平衡的二叉搜索树,红黑树任意一个节点的左右子树的高度差不会超过一倍。我个人觉得这是对红黑树一个比较标准的定义。首先红黑树是一棵二叉搜索树,其次它并不是一棵完美平衡的二叉树,而是近似平衡。红黑树在各个语言的基础数据结构中都得到了广泛的应用,例如:1.Java的1.8版本的HashMap和TreeMap。2. python的集合(set)数据类型等。下图就是一颗标准的红黑树:

红黑树

        红黑树既然是一棵二叉搜索树(BST),那我们就先来回顾一下二叉搜索树的性质:

       1.假如左子树不空,则左子树上所有结点的值均小于它的根节点的值;

       2.假如右子树不空,则右子树上所有结点的值均大于它的根结点的值;

       3. 左、右子树也分别为二叉排序树;

       因此红黑树也满足二叉搜索树的一些性质,例如:1.查询的平均时间复杂度为O(logn)。2. 插入的平均时间复杂度为O(logn)。AKA,二叉搜索树并不是平衡二叉树,所以在某些情况下会有较坏的查询效率,这要是红黑树较二叉搜索树更有优势的地方。红黑树也有一些自己的性质,这些性质能够保证红黑树在修改或者删除节点的时候做到自平衡,从而从头到尾保证自己是一棵近似平衡的二叉查找树并拥有较好的最坏查询性能。这些性质可以总结为5条:

1. 每个节点要么是黑色,要么是红色。

2. 根节点是黑色。

3. 每个叶子节点(Nil)是黑色。

4. 每个红色结点的两个子结点一定都是黑色。

5. 任意一结点到每个叶子结点的路径都包含数量相同的黑结点。

      红黑树的这5个性质保证了它的自平衡性。红黑树在修改以及删除节点的时候往往会破坏性质4和性质5,为了继续满足红黑树的性质会通过改变节点颜色、左旋(Rotate Left)和右旋(Rotate Right)进行调整,从而达到自平衡。左旋和右旋是二叉搜索树的两种调整方式,其作用就是在不改变二叉搜索树的性质的前提下,将一个节点的左右子树高度进行平衡,详细原理这里不阐述。性质4以及性质5能保证红黑树是一棵近似平衡的二叉搜索树,也就是任意一个节点的左右子树高度相差不会超过一倍。

        红黑树是一种比较复杂的数据结构,如果想要彻底搞懂会非常的费力,满足树自调整的条件就有8个之多,每种条件使用何种方式调整也比较难理解。同时,也劝导大家不用钻牛角尖,毕竟如果不涉及到项目中设计一棵红黑树以及调优,很多细节搞懂了也很容易遗忘。这里,只讨论红黑树的优势以及在何处使用。红黑树的自平衡性使其查找、修改和删除的平均以及最坏的时间复杂度都为O(logn)。对于链表来说,查找以及修改的时间复杂度为O(n), 对于双向链表来说,增加一个Node到链表头或者尾的时间复杂度都是O(1)。因此,对于HashMap来说,使用红黑树替代链表综合来看,确实有部分性能提升。

HashMap源码解读

        通过以上分析,HashMap的结构大致如下图:

       接下来,我们要对JDK1.8里的源码进行一下解读,剖析一下其实现原理。首先,来看一下HashMap的几个初始参数的含义:

HashMap初始化参数

      上文展示的几个参数都是HashMap底层非常重要的参数。其中加载因子和数组初始容量是可以通过HashMap的构造函数修改的,其他的参数对于使用JDK的人来说都是默认的。对于树化阈值和反树化阈值的定义也别有一番深意。对于长度为6链表来说,get时需要遍历的最多节点数为6,而put一个节点却非常容易,只需要找到链表头,然后增加节点。对于节点数量为8的红黑树来说,查找一个节点最多需要遍历的节点数为3,插入或者删除一个节点需要遍历的节点数也为3,如果综合一次get和一次put操作来说,需要遍历的节点数也为6。因此选择6和8作为两个转换过程的临界值。另外,6和8中间还有一个整数7,对于一个频繁put节点和删除节点的HashMap来说,7能够在一定程度上避免链表和红黑树中间频繁转换而带来的性能消耗。但是,这也告诉我们HashMap的主要优势还是根据key查找value的效率非常高,对于频繁的put和delete操作来说红黑树的性能其实存在很大瓶颈,使用时如果发现HashMap出行性能瓶颈,可以往这方面考虑优化。

         然后,我们来看一下HashMap的几个构造函数:

 

HashMap提供的构造函数

       从构造函数可以看出,在创建HashMap时可以指定哈希表容量和加载因子。其中加载因子loadFactor应该是一个0-1的浮点数。initialCapacity是一个HashMap设计者绞尽脑汁提高HashMap性能的参数。首先,上文提到,initialCapacity参数有默认值:

DEFAULT_INITIAL_CAPACITY = 1>>4

这个参数如果直接定义为16,计算机会将10进制数16最终转换为二进制10000存储,中间的转换过程肯定要比使用二进制右移的方式要消耗性能。对于initialCapacity这个参数,其实有一个要求:该参数必须是2的n次幂。对于为什么有这样的要求,要从HashMap的原理说起。之前已经说过,哈希表的原理就是根据key的哈希值定位该key所指定的value在数据中索引的位置,这其中有很多方法,针对于数组长度取余就是常用的一种。但是HashMap里使用如下所示:index = hashCode&(length-1)。其中length=initialCapacity。首先肯定的是与操作要比取余操作的效率更高。什么原理?举例来说明。如果length=8,那么对于lenth取余的结果只能是0-7,转换为二进制为0000-0111,意味着任何数num对8取余,都等于num&0111(也就是7)的结果。如果hashCode为12,二进制为1100,对8取余结果为1100&0111=0100。从这个例子就可以看出,如果HashMap的底层数组长度length的长度为2的n次幂,那么计算hashCode对应在数组的索引index的值就可以通过hashCode&(length-1)的方式代替取余操作,从而提高效率。

但是呢,在使用HashMap构造函数的时候,我们不需要考虑initialCapacity参数是否为2的n次幂的问题,只管随意指定一个正整数a即可,只不过HashMap本身会做一些转化,结果为2的ceil(log2(a)次幂。关键在于方法tableSizeFor,输入是一个整数n,输出是2的n次幂,例如:输入8,输出8。输入9,输出16。如果我来实现这函数,我的思路就是计算a = ceil(log2(n)),然后计算2的a次幂值。根据前文的描述,HashMap的创作者仍然是使用二进制位移的防范解决这个问题。基本思路就是:如果n为2的整数次幂,那么n == (n-1)|n>>>m结果为true。这里我不用更多的篇幅来解释,有兴趣的同学可以自己查看源码。

      最后,我们来看一下另一个可能会严重影响HashMap性能的方法resize:

 

resize方法图1

 

resize方法图2

       resize方法做了两件事儿:1.对旧的底层数组进行扩容。2.把旧的数组内的所有Node取出,重新分配到新的数组内。整个过程所耗费的空间复杂度为O(n), 最优情况下的时间复杂度为O(n),其中n为哈希表内的key-value对个数。嗯,没错resize方法整体对于性能的消耗还是非常大的。那我们再看一下都哪些地方使用到了resize方法:

 1. putMapEntries方法。该方法是使用HashMap的构造函数,并使用了一个实现了Map接口的对象m作为参数时候调用的。当m.size>threshold时候,HashMap要进行扩容操作。

 2. 数组内的链表达到树化阈值,却发现tab.length< MIN_TREEIFY_CAPACITY时。此时可能出现了大量的哈希碰撞,需要resize方法重新将哈希表内的Node散列。

 3. 余下的情况基本上都是,向HashMap内加入新的key-value时,如果size>threshold时需要调用resize方法。

        综上所述,如果想要避免HashMap内频繁的调用resize方法,应该避免在有频繁的put操作时,却定义了较小的initCapacity参数(感觉大家都懂)。

总结

        以上就是本人觉得HashMap内需要详细了解的一些知识。其实还有很多细节没有讲,比如如何创建Hash的迭代视图、红黑树的实现细节。我发现如果把这些东西全都弄明白并写下来,可以写成一本书的一章了。钻牛角尖需要大量的时间和精力,而且也容易遗忘,我这里就不在深究了。以后如果用到项目中想要实现一棵红黑树,强烈推荐直接到HashMap里参考红黑树的实现原理。反正写了这些东西之后,感觉自己对红黑树还是比之前了解的更多了,收工。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值