转载请注明原文地址:https://blog.youkuaiyun.com/yu749942362/article/details/106637174
HashMap源码中的常见疑问
写在前面
看过HashMap中的源码(Java8)之后,对HashMap有了一定程度的理解。代码看懂不难,但如果对其中的某些细节深究,却又道不出个所以然。比如:为什么table的容量总是2的次幂?为什么链表转红黑树时的阈值是8,而红黑树转链表时的阈值又是6?这篇文章主要是针对HashMap中的这些 “为什么?” 。对于HashMap的源码分析可以戳链接。HashMap类注释,详细介绍了它的特性和设计原理,以及使用过程中的注意事项。
一,HashMap类简介
HashMap基于哈希表实现了Map接口中所有的可选操作。其主体构成是一个数组(Node
是实现了Entry
接口的内部类):
/**
* 存放所有节点的数组
*/
transient Node<K,V>[] table;
HashMap能保证高效的get和put操作,get(key)
和put(key, value)
方法可以直接定位到目标value在table数组中的位置。当发生hash碰撞时(不同的key对应了table中相同的位置),以链表和红黑树的方式解决。HashMap的整体结构大致如下:
(图片转自:https://www.jianshu.com/p/dd06fdb2ff4d)
HashMap不能保证map的顺序,也不能保证顺序在一段时间内保持不变(下文会解释原因)。HashMap类似于Hashtable(两者的区别),但它是不同步的,并且允许空值和空键。如果多个线程同时访问一个HashMap,并且其中至少有一个线程从结构上修改了HashMap,则必须在外部对其进行同步。(结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已包含的键关联的值不是结构修改)。
HashMap在初始化的过程中涉及的一些常量:
常量 | Value | 释义 |
---|---|---|
DEFAULT_INITIAL_CAPACITY | 1<<4 (16) | 初始容量 |
MAXIMUM_CAPACITY | 1<<30 ( 2 30 2^{30} 230) | 最大容量 |
DEFAULT_LOAD_FACTOR | 0.75 | 负载因子 |
TREEIFY_THRESHOLD | 8 | 树化阈值 |
UNTREEIFY_THRESHOLD | 6 | 树退化阈值 |
MIN_TREEIFY_CAPACITY | 64 | 最小树化容量 |
二,table的容量和负载因子
HashMap的一个实例有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中的桶数(即数组 Node<K, V>[ ]
的长度)。负载因子是在哈希表的容量自动增加之前,允许哈希表获得的满容量的一个“系数”。当哈希表中的条目数超过负载因子和当前容量的乘积时,将对哈希表进行重新哈希(即重新构建内部数据结构),使哈希表的存储桶数是之前的两倍。
1,初始容量为什么是16
假设哈希函数正确地将元素分散到各个桶中,那么HashMap就为Map的基本操作(get
和put
)提供了固定时间的性能(数组访问和修改的 时间复杂度 为O(1))。其迭代时间与的“容量”(桶的数量)及其大小(键值映射的数量)成比例。如果要将许多映射存储在HashMap实例中,那么创建足够大容量的映射将比让映射根据表增长的需要自动重新散列更有效。因此,如果迭代性能很重要,那么就不要将初始容量设置得太高(或负载因子太低)。
/**
* 默认的初始化容量为16,必须是2的n次幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
注释中写明必须是2的次幂(下面会讲),那么初始容量的可选数值就是2、4、8、16、32、64 ... ...
这个值可以是16(采用1 << 4 是为了让读者能直观的看出这是2的4次幂),也可以是32,甚至64。只要满足2的次幂,我们可以根据实际需要而定,JDK为我们默认选取了16。实际上HashMap提供了一个可传入初始容量的构造方法,即使我们传入的数值不是2的次幂,在方法中也会被转换成距离传入数值最近且大于它的2次幂数。
2,最大容量为什么是 2 30 2^{30} 230
HashMap中定义了table了最大容量值:
/**
* 最大容量,当在构造方法中指定的容量大于这个值时就使用这个值
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
size
是整型int
定义的,由于int
在java
中是4字节32位的,首位用来作为符号位,按理说最大长度应该定为1<<31,但是,我们看看int
的包装类Integer
中定义的int
最大和最小值:
/**
* 最小值, -2<sup>31</sup>.
*/
@Native public static final int MIN_VALUE = 0x80000000;
/**
* 最大值, 2<sup>31</sup>-1.
*/
@Native public static final int MAX_VALUE = 0x7fffffff;
int
的最大值为1<<31 - 1,而上面说过,HashMap明确规定table的容量必须是2的次幂,因此最大容量只能定为1<<30。
3,table的扩容
table的容量是在put
方法中确定的。当Map中存储的数据量超出阈值,就会触发扩容 final Node<K,V>[] resize()
,方法比较长,这里贴出主要代码:
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY & oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
... ...
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
可以看出,table的扩容实际上就是重新创建了一个长度为旧table长度2倍的新数组作为Map的table,由于是新new出来的,就需要将旧table中的数据复制到新table中:
... ...
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
... ...
}
不难看出table的扩容操作还是很复杂的,不仅要对旧table逐一遍历,假如存在哈希冲突,可能还要做链表和红黑树的拆分,对于性能会有影响。所以,如果对于性能要求比较苛刻,那么就应该根据具体需要选取一个合适的初始容量,以尽可能的减少resize的操作。
4,为什么table的容量总是2的次幂
这个问题要拆解成两个来看:1,如何保证容量总是2的次幂? 2,容量是2的次幂有什么好处?
4.1,如何保证容量总是2的次幂?
HashMap中并没有把table的容量作为一个全局参数保存起来,类中只申明了扩容阈值 threshold
和负载因子 loadFactor
。
从构造方法中可以看到, threshold
初始值是通过方法 tableSizeFor()
得出的:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
... ...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor()
: 返回给定目标容量的2次幂,HashMap正是通过这个方法保证其容量永远是2的次幂。
/**
* 返回给定目标容量的2次幂
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这是一个获取距离目标数字最近且大于等于目标数字的2次幂数,采用位运算的方式大大提高了效率。为什么通过这个方法就能得到2的次幂呢?举个例子,2的30次幂,对应的二进制是:
0010 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000
对这个二进制数据按照上面的方式进行右移并求或:
0011 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000
n |= n >>> 1
0011 | 1100 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000
n |= n >>> 2
0011 | 1111 | 1100 | 0000 | 0000 | 0000 | 0000 | 0000
n |= n >>> 4
0011 | 1111 | 1111 | 1111 | 1000 | 0000 | 0000 | 0000
n |= n >>> 8
0011 | 1111 | 1111 | 1111 | 1111 | 1111 | 1111 | 1111
n |= n >>> 16
第一次右移1位再求或将高两位都变为1;第二次将右移2位求或可以将高4位都变为1;第三次右移4位求或将高8位都变为1;…;依次类推最终将至高位起向右的所有位都置为1,得到了2的31次方减1。
最后的n+1
就得到了2的31次方。
代码最开始的n-1
是为了防止像我例子中的这样,传入的值刚好是2的次幂时也进行了升幂。
在初始化的时候保证了初始容量是2的次幂,后续table需要扩容的时直接使用 newCap = oldCap << 1
以确保永远是2的次幂。
综上可知HashMap是的容量永远是2的次幂,那么这个设计的好处呢?
4.2,容量是2的次幂有什么好处?
参考:https://www.jianshu.com/p/ac19fd36d14d
5,默认负载因子为什么是0.75
/**
* 构造方法中未指定时使用的默认加载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
这个值应该是经过大数据测算之后得到的一个值,而并非通过什么计算公式得出的。源码中是这么解释的:默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在类的大多数操作中,包括get
和put
)。在设置map的初始容量时,应该考虑map中条目的期望数量及其负载因子,以最小化rehash操作的数量。如果初始容量大于最大条目数除以负载因子,则不会发生任何重新哈希操作。
理想情况下,在随机哈希码下,存储箱中节点的频率服从泊松分布,默认大小调整阈值为0.75时,平均参数约为0.5,但由于大小调整粒度的原因,差异较大。下面给出了桶中元素的个数和出现冲突概率的对照表。
* Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
从表中可以看出当桶中元素到达8个(为什么是8,后面在链表与红黑树的转换里会讲)的时候,碰撞概率已经变得非常小,换句话说就是用0.75作为负载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。如果链表的长度不超过8,则不需要进行链表到红黑树的转换。
三,HashMap如何避免hash冲突
避免是不可能避免的,只能尽可能的去降低发生冲突的概率,那么HashMap是怎么做的呢?首先必须要知道了key的索引是如何计算出来的。
//n是table的长度,hash是key的哈希值
p = tab[i = (n - 1) & hash]
(n - 1) & hash
就是作为key在table中的索引位置。那么这个hash值是如何生成的呢?
/**
* 方法中会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传 null 的。
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
先得到key的hashCode值h,再把h右移16位,并与原来的h进行异或。相当于对高16位和低16位进行了异或运算。由于hashCode()
方法继承自Object,它返回的是一个 int 类型的数值,可以保证同一个应用单次执行的每次调用返回结果都是相同的,在此基础上的运算也就保证了相同的key必定会有相同的hash。为什么这种方式可以尽可能降低hash冲突呢?
先看table索引位置的确定方式:(n - 1) & hash
。
使用的是数组的容量减1再和hash值做与运算,这样得出的下标也不会发生越界。由于初始容量是16,n-1即15的二进制为:
0000 1111
hash是一个32位的二进制码,与运算时前28位是什么对运算结果没有任何影响,两个不同的key只要他们的hash值末4位相同,则**(n - 1) & hash**的结果也一定相同,这样来看发生hash冲突的概率是很大的。
把h右移16位,并与原来的h进行异或,就能把高位的影响向下传播,而不是像上面例子中那种仅有后4位能影响到结果。那为什么又是使用异或而不是与运算或者是或运算呢?
与运算 | 或运算 | 异或 |
---|---|---|
0&0 = 0 | 0|0 = 0 | 0^0 = 0 |
0&1 = 0 | 0|1 = 1 | 0^1 = 1 |
1&0 = 0 | 1|0 = 1 | 1^0 = 1 |
1&1 = 1 | 1|1 = 1 | 1^1 = 0 |
可以看到两个值进行与运算,结果0和1的比例为3:1;或运算,1:3;只有异或运算是1:1的平衡态。所以,异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率也自然就更小了。
四,链表与红黑树的转换
当发生hash碰撞时,两个不同的key对应table中的同一个箱子。HashMap采用拉链法来解决哈希冲突,即链式存储。
通常情况下,table中的元素是散列分布的,发生冲突的概率是比较低的。但是由于开发者是可以重写hashCode
方法的,而且负载因子也可以由开发者自行指定,所以无法保证冲突的概率一定很低。在put操作时如果链表的长度达到一定的量(树化阈值)时就会把链表转换成红黑树(java8开始引入红黑树)。在remove操作或者resize的时候如果红黑树的节点数低于某一数值(树退化阈值)时又会将红黑树退化成链表。
树化阈值和树退化阈值:
/**
* 使用树而不是链表作为箱子的计数阈值。当将元素添加到至少有这么多节点的箱子时,箱子将转换为树。
* 该值必须大于2,并且至少应为8,以符合树移除中关于在收缩时转换回普通存储箱的假设
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 在调整大小操作期间取消(拆分)树箱的箱子计数阈值。
* 应小于TREEIFY_THRESHOLD,且最多6个网格下有收缩检测去除。
*/
static final int UNTREEIFY_THRESHOLD = 6;
为什么树化阈值是8,而退化阈值又是6呢?答案是性能。
链表查询的时间复杂度是:O(n)
红黑树的查找时间复杂度是:O(logn)
网上有一种比较流行的说法比较常见:
按照平均查找长度来说,
当 n = 8 时:链表 = 8/2 = 4;红黑树 = log2 8 = 3;红黑树的效率更高
当 n = 6 时:链表 = 6/2 = 3;红黑树 = log2 6 = 2.6;链表的效率更高
由于红黑树的生成和结构变化本身也是个复杂的操作,所以树化和退化的阈值是不同的。
看起来也有一定的合理性,但并不一定就是设计者的设计初衷。树的生成和变化比较复杂,理论上应尽量避免出现树化。回过头来我们再看关于 默认负载因子为什么是0.75 的解释,如果将这两个问题结合在一起来解释就更趋于合理化了。
当树化阈值等于8的时候,向红黑树的转化才能在平均查找性能上超过链表,为尽可能少的做树化操作应降低箱子元素数达到8的可能性,而负载因子=0.75恰好满足这一条件且在空间上也达到了相对的平衡。
负载因子=0.75时,同一箱子中发生冲突数量的概率:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
最小树化容量:
所谓最小树化容量,即table可能被树化的最小容量
/**
* table可能被树化的最小容量。(否则,如果bin中的节点太多,则会调整表的大小。)
* 应至少为4*TREEIFY_THRESHOLD,这是为了避免,数组扩容和树化阈值之间的冲突。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
在put操作发生哈希冲突时,如果所在箱子的链表长度达到了树化阈值8,这个时候会调用树化方法:treeifyBin(tab, hash)
,而在该方法的首行是先判断总容量是否达到64,否则直接扩容而不采取树化。
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
五,HashMap为什么不能保证顺序
能看到这,这个问题的答案也基本上就出来了。HashMap是由一个主表(数组)和挂在主表元素下的链表或红黑树(发生哈希冲突时)构成的。每次put一个新元素时,其被插入数组的位置是通过hash计算出来的,并不是按照升降顺序。而数组本身是有序的,在表结构不发生变化的时候对HashMap进行遍历得到的结果永远是一样的,但不一定和put时的顺序一样。所以HashMap的插入和遍历顺序不能保证一致。
假设HashMap中存在若干元素,且此时已经发生过hash冲突。这时put一个元素时可能会触发table的resize
方法,如果触发了扩容,就会发生链表和红黑树的拆分转移,并为他们计算在新的table中的位置,如此这些元素的索引就发生了变化,再对此hashMap进行遍历时原有的顺序就不能保证了。所以,当对HashMap进行增删操作时,不能保证其遍历顺序与操作之前一致。
最后再安利一篇烟雨星空的:面试官再问你 HashMap 底层原理,就把这篇文章甩给他看