作者疑问???

其实对于HashMap我有一点不是特别明白。为什么一定要将链表转换成红黑树的阈值设置为8?

注释也说了,根据泊松分布,能够到达8个的已经是亿分之6,几率十分小,那为什么又要在最后的那个节点,来进行转换呢?这不是白白浪费空间?网上和很多视频都说,因为8的时候,链表的平均查找长度为4,链表的查找长度为3。但是此时红黑树会多占用很多内存空间呀。并且超过了8,等到删除元素往回走的时候,只有到了6才会变回链表,那7的时候不会浪费空间(虽然7可以理解成避免链表与红黑树的频繁转换带来的效率上的吃紧)?

我的猜想是其实两种方法平均查找长度在4的时候,已经相等。但是为了兼顾空间和时间的成本(毕竟数组长度在64以前,都是进行扩容的。此时整个数组长度已经大于64,整体的查询数量比较多),所以长度还要往后走,直到走到了8这个位置,觉得他是一个比较好的位置(即变成红黑树以后的,由于利用hash算法排列的数据是比较均匀的,大小超过64的数组,在查找数据的时间上的优势,已经能够弥补空间上的浪费),不知道对不对。有知道的码友一定要告诉答案呀!!!


本文全部是结论性概念,想要探知为什么,可以去看看我的源码剖析。希望本文对你有帮助,有不准确的地方,还请及时指正!!!
传送门:
​HashMap源码剖析(代码基于JDK11)​​

文章目录

  • ​​1、HashMap基本概述​​
  • ​​2、概念讲解​​
  • ​​2.1、HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?​​
  • ​​2.2、当两个key的hashCode值相等时会怎么样?​​
  • ​​2.3、如果两个键的hashCode值相同,如何存储键值对?​​
  • ​​2.4、为什么要引入红黑树?​​
  • ​​2.5、源码中的size(map实时数量)、threshold( 临界值) 、capacity(容量) 、 loadFactor( 加载因子 )四者什么关系​​
  • ​​2.6、loadFactor为什么默认是0.75​​
  • ​​2.7、为什么数组的长度一定是2的幂次方?传入的不是会有什么后果?​​
  • ​​2.8、为什么边界阈值要设置为8?​​
  • ​​2.9、为什么红黑树转换成链表的阈值是6?​​
  • ​​2.10、什么时候才需要扩容?​​
  • ​​2.11、resize后的hash是如何分别的?​​
  • ​​2.12、HashMap的初始化容量应该为多少?​​
  • ​​2.13、JDK1.7与JDK1.8数据插入方式有什么不同?​​

1、HashMap基本概述

在学习hashmap之前,我们要对hashmap需要记住几个基本的概念

  • HashMap是以key-value的形式进行存储
  • HashMap是线程不安全的
  • HashMap的key和value都可以是null
  • HashMap中的映射是无序的
  • HashMap在JDK1.7中是数组+链表,JDK1.8中是数组+链表+红黑树

我们先来看一下,两个不同版本JDK的HashMap图示:

JDK1.7:

HashMap原理剖析_链表

只有一种数据结构,链表。



JDK1.8:

HashMap原理剖析_数组_02

存在红黑树与链表两种数据结构。且红黑树的节点个数可以出现7个,链表节点最多只有8个。

我们应该明确一个点就是,一定是因为JDK1.7的HashMap存在某些弊端(死锁、效率低),我们才修改了它

总结:我们不难发现,在JDK1.8以前,HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”解决冲突)。在JDK1.8及以后,出现了一种新的数据结构——红黑树。他的出现,是为了解决存储的数据都在一个桶(hash桶,即最初的那个位置),这种新的数据结构,可以有效的解决数据查找时的效率问题。当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

根据源码可得,将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。主要是因为红黑树需要左旋,右旋来保持平衡继而降低效率。



2、概念讲解

2.1、HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?

对于key的hashCode做hash操作,无符号右移16位然后做异或运算。还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移16位异或运算效率是最高的(计算机是二进制计算,依靠移动来计算是常规操作)。至于底层是如何计算请查看源码


2.2、当两个key的hashCode值相等时会怎么样?

会产生哈希碰撞,若key值内容相同则替换旧的value。如果key不相同,则连接到链表后面,链表长度超过阈值8,就转换为红黑树存储或者扩容。

数据存储在数组上的索引位置,是根据key的hashCode值与数组的长度共同计算出来的

hashCode值是对象在内存中的存储位置,由于hashCode值相同,并且数组长度一定,所以就会出现对应的索引值相同,继而出现了hash碰撞


2.3、如果两个键的hashCode值相同,如何存储键值对?

其实在2.2已经给出了答案。当两个key的haCode相同时,我们就会对key的内容使用equals方法进行判断,如果内容相同,我们就认为他们是相同的key,次此key对应的value会覆盖之前已经存在了的value。如果key的内容不相同,我们就认为这是两个不同的key,继而将数据存储在红黑树上或者是链表后面。

你可能会问,key不一样,hashCode会一样?
没错,会有。如,Aa和BB他们的哈希值就是相同的,都为2112

2.4、为什么要引入红黑树?

JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n)。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。 当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,且对查询性能构成影响的时候,引入的红黑树就能够减少这种影响。使整个程序运行起来更加有效率。


2.5、源码中的size(map实时数量)、threshold( 临界值) 、capacity(容量) 、 loadFactor( 加载因子 )四者什么关系

size表示当前map集和中的数据,threshold表示集和数组中最多能够放置的桶,即索引位置,capacity表示集和数组的所有索引值(2的次幂方个),加载因子可以理解成整个集和数组的疏密程度。当map集和中的数据超过了threshold的值,就会进行扩容,对应的数据结构也会发生改变。集和能够存储的临界值 = 集和总容量 * 加载因子。threshold衡量数组是否需要扩增的一个标准


2.6、loadFactor为什么默认是0.75

loadFactor太大,数组太密集,导致查找元素效率低;太小导致数组太稀疏,数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。不建议修改


2.7、为什么数组的长度一定是2的幂次方?传入的不是会有什么后果?

向HashMap集和中添加一个元素的时候,需要根据key的hash值以及数组长度,去确定其在数组中的具体位置。 HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就需要使用一定的算法。

我们在课本上学习的算法是hash%length,但是对于计算机而言,该种方式为一个一个减,效率较为低。由于计算机中直接求余效率不如位移运算,所以在java的源码中做了优化,使用 hash&(length-1)(2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂。(具体代码查看源码运算)

如果不是2的次幂方数呢?java会帮我们进行相应的移位运算,在配合i - (i >>1)这样的一个运算,使得系统能够对我们自定义的数组长度进行一个转换,转换成比它大的最小的2幂次方数。
即我们传入12,java底层会转换为16;传入6会转换成8。


2.8、为什么边界阈值要设置为8?

因为树节点的大小大约是普通节点的两倍,所以我们只在包含了足够的节点时才使用树节点。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的链表(链表长度达到8就转成红黑树,当长度降到6就转成普通链表)。在使用分布良好的用户hashcode时,很少使用树。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布,能够存储数据到达8个的几率是亿分之6。

当hashCode离散性很好的时候,树型节点用到的概率非常小,因为数据均匀分布在每个节点中,几乎不会有节点中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。

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

即超过8个数的可能性很小,当真的超过了,浪费的那部分内存空间就可以被提升的查找时间效率所替代;当数据很少的时候,链表的查找效率又要远高于红黑树。


2.9、为什么红黑树转换成链表的阈值是6?

当树上的元素又9到8再到7时,红黑树依然存在。直到变成了6个,才进行对应的链表转换。而7和8可以理解成是一种缓冲区,毕竟红黑树生成的几率就很小,要是红黑树变成链表再变成红黑树的几率就更小了。而此时设置的两个数据缓冲区,就可以有效的避免链表与树直接的转换带来的效率上的影响。


2.10、什么时候才需要扩容?

当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容。loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能

当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表。

进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。


2.11、resize后的hash是如何分别的?

根据源码,进行了运算。数据的索引是根据数据的hash值结合数组长度进行推算的,由于数组长度变成了之前的两倍,所以数组长度的二进制的最高为会多一个1。

当我们在扩容HashMap的时候,不需要重新计算hash值,只需要看原来的hash值和新增的数组二进制最高位的对应位置,看数据本身的hash值是1还是0,如果是0则数据的索引值不改变,如果是1,则数据的索引值变成“原索引+oldCap(原位置+旧容量)。

正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于数据的hash值是根据指定的算法得出,所以在对待数组扩容后新增的那个1的位置上也就是随机的匹配,以此能够保证在resize的过程中rehash之后每个索引点上的节点数一定小于等于原来索引点上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的索引点上了。


2.12、HashMap的初始化容量应该为多少?

当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值当做初始容量。前文也说过,建议我们自己设置好对应的容量大小,避免扩容带来的性能损耗。那么,是不是我们只需要把已知的HashMap中即将存放的元素个数直接传给initialCapacity就可以了呢?

根据《阿里巴巴Java开发手册》可得,我们只需要根据进行相应的运算即可得出我们设置的初始化容量大小。

HashMap原理剖析_链表_03



2.13、JDK1.7与JDK1.8数据插入方式有什么不同?

在JDK1.7中,使用的是头插法。为了效率,当新的数据put进来的时候,程序逻辑会先去进行遍历,查看是否有key的内容相同的,如果没有相同的,数据就会替代之前链表的头节点,继而完成一次头插法。
但是这样会出现一个问题。让数组进行扩容时,数据会从链表的头节点开始重新分配索引节点,我们不难发现,原链表的头节点在转移到新的链表节点上时,是第一个进行插入的,原链表的最后一个元素,就会变成新链表的头节点(因为使用的是头插法),在这个过程中,由于代码的逻辑,会使程序进入一种死锁的状态,继而程序不断地消耗资源,直至程序崩溃。
所以在JDK1.8中就修改了插入方式,变成尾插法。使得数组集和在resize后依然能够保持原链表数据的顺序,继而避免出现死锁现象。


有时间的建议去看一下源码,不然死死的概念比较抽象,过一遍源码,那真的是不一样