在哈希表和 Java 的前世今生(上),掌握HashMap看这一篇就够了!!! 中,我们讲解了哈希表的原理以及JDK7 HashMap的源码及JDK7中HashMap的注意点。接下来,我们继续我们上次的讲解,本讲的重点是JDK8 HashMap!
四、JDK8 HashMap
4.1 问题 4:JDK8 中 HashMap 的变化
- 结构变化:由数组+链表(JDK7)变成了数组+链表+红黑树(JDK8)。
-
链表长度>=8,转换为红黑树;链表长度减少为 6,红黑树再变回链表。只有总的节点数量>=64 的时候,才会有红黑树,否则直接进行主数组扩容
-
链表节点为 Node,红黑树节点为 TreeNode。Node 是 TreeNode 的父类。
-
添加到链表后面:JDK7 中新的节点是加到最前,JDK8 后新节点是加到最后(也是避免死循环的一种解决方式)
-
主数组的创建不是构造方法中搞定,而是 put 元素时通过 resize()搞定
-
哈希表扩容后原来链表节点重新散列后不改变之前顺序,也不会形成循环链表
注意:JDK8 HashMap 虽然针对 JDK7 的缺点做了某些修改,但是仍旧是线程不安全的,并发情况下建议使用 ConcurrentHashMap,或者使用 Collections 加锁。
4.2 问题 5:为什么是当链表长度>=8 后变成红黑树,而不是其他值
因为泊松分布 Poisson distribution(概率和数理统计内容)。
在理想的随机 hashCodes 下,容器中节点的频率遵循泊松分布,对于 0.75 的默认调整阈值,泊松分布的概率质量函数中参数λ(事件发生的平均次数)的值约为 0.5,尽管λ的值会因为 load factor 值的调整而产生较大变化。
即链表中出现 8 个节点的概率是非常低的,仅有 0.00000006,所以不用担心产生大量红黑树导致结构复杂的问题
4.3 源码阅读(JDK8 HashMap)
- 依旧要求主数组容量还是 2 的幂
这是一个小巧但精妙的方法,这里通过异或的位运算将两个字节的 n 打造成比cap 大但最接近 2 的 n 次幂的一个数值。例如:
- 计算哈希码的方法简单了
JDK7 中,hash 计算的时候会对操作数进行右移操作,计算复杂,目的是将高位也参与运算,减少 hash 碰撞;在 JDK8 中,链表可以转变成红黑树,所以 hash计算也变得简单。下面的图为 JDK8 中的 hash 计算和索引计算。
- 添加数据的步骤:put()
图片来自:https://blog.csdn.net/goosson/article/details/81029729
put源码分析:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 第三个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时或者 value 是 null 才会进行
// put 操作,第四个参数 evict 我们这里不关心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K, V>[] tab;//指向哈希表主数组的数组名
Node<K, V> p;//链表
int n, i; //n 永远存放数组长度,i 表示 key 在数组中的索引
// 第一次 put 值的时候,会触发下面的 resize(),第一次 resize 和后续的扩容有些不一样,
// 因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 找到具体的数组下标,如此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {// 数组该位置有数据
Node<K, V> e;
K k;
// 首先,判断该位置的第一个数据和要插入的数据,key 是不是"相等",如果是,取出这个节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果该节点是代表红黑树的节点,调用红黑树的插值方法,本文不展开说红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
else {
// 到这里,说明数组该位置上是一个链表
for (int binCount = 0; ; ++binCount) {
// 如果不存在相同 key 的,插入到链表的最后面(Java7 是插入到链表的最前面)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// TREEIFY_THRESHOLD 为 8,所以,如果新插入的值是链表中的第 8 个
// 会触发下面的 treeifyBin,也就是将链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果在该链表中找到了"相等"的 key(== 或 equals)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
break;
p = e;
}
}
// e!=null 说明存在旧值的 key 与要插入的 key"相等"
// 对于我们分析的 put 操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
- 主数组容量扩容为原来的二倍,原来元素的索引会有什么变化吗?
索引要么是原来的索引,要么是原来的索引+原来的数组容量。在 JDK8 中,不进行存储位置的重新计算,而是判断应该在原位置还是新位置。
判断条件为:
if ((e.hash & oldCap) == 0) {
//如果是原来的索引
} else{
//如果是原来的索引+原来的数组容量
}
-
扩容到底是怎么实现的 ?
扩容后,原来的一个链表可能会变成两个链表。实现思路是定义四个指针,在对原来链表进行逐个重新散列的过程中
1.Node loHead,loTail 分别指向索引不变的新链表的首节点、尾节点
2.Node hiHead,hiTail 分别指向索引改变的新链表的首节点、尾节点
-
单链表变成红黑树是怎么实现的
简单来说,是先调用 treeifyBin()方法,将单链表变成双向链表(但节点已经是红黑树的节点 TreeNode),再将双向链表转换为红黑树。