HashMap put
方法执行逻辑及部分问题的分析
数据结构可视化网站:
https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
HashMap - 核心原理与知识点记录(中)
HashMap - 核心原理与知识点记录(下)
本文主要通过一下几个问题进行探讨:
- 什么是
HashMap
? HashMap
为什么要使用这样的数据结构?解决了什么问题?- 1.8 版本中
hashcode
为什么要和 hash高16位做异或运算 ? HashMap
执行put()
内部做了些什么事情 ?- 如何进行扩容的,扩容大小为什么总是
2
的幂次大小 ? - 采用红黑树的目的 ?
1. 什么是 HashMap
?
HashMap
主要是存放 key,value
的数据结构,通过 key 值获取到 value ,key 是不允许重复的并且 key 值允许为 null,在 Java 1.7 版本(包含1.7)之前使用的是使用的数组加链表的形式进行存储,在 1.8 版本升级了 HashMap,将其新增了红黑树的转换,并且还优化了 hash 冲突的可能性。
public static void main(String[] args) {
Map<String,String> map = new HashMap<>();
map.put(null, "1");
map.put(null, "2");
String val = map.get(null);
// 输出结果为 2, 第一次put被第二次覆盖了
System.out.println(val);
}
2. HashMap
为什么要使用这样的数据结构?解决了什么问题?
在这里我们主要从 1.8 版本的数据结构进行分析,因为 1.8 版本也包含了 1.8 之前的数据结构
从 HashMap
的源码中,我们可以得知主要底层的支撑的是一个叫 Node<K,V>[] table
的定义,而这个 table
数组我们可以理解为桶,每只桶里可以装很多个 k v
结构的数据,分析到这里似乎出现了问题:怎么用数组的一个节点去存储多个 KV
,显然这里肯定是不满足要求的,所以我们得思考怎么让一个桶里面可以存储多个 KV
的数据,此时毫无疑问链表出现在了我的面前,正好链表也可以使用一个 Node
对象来代替,完美的适配了数组 + 链表对桶的描述:
但是,在 1.8 版本中新增了红黑树数据结构存储,那么为什么要新增红黑树,这样又达到了什么样的目的?
根据图片中得到的信息,这是一个链表,那么我们想通过链表得到一个数的时候,那么需要对链表进行遍历,如果链表的深度为10000且刚好我们想要匹配的值就在最后一个节点上,那么这个链表将会被遍历10000次才能正确的拿到我们想要的结果,时间复杂度为 O(n) 级别,非常的耗费性能,接下来我们在看看红黑树:
通过上图中,如果我要查询数字 7,那么只需要遍历树节点四次即可得到,相对于链表节省了3次,如果有 10000 条数据,那么我们也不需要再像链表一样需要遍历 10000次,这种效率高了接近一半。但是红黑树也不是完全没有缺点,接下来来说一下链表与红黑树各自的优缺点。
链表与红黑树各自的优缺点:
链表:插入数度快,但查询的速度慢(需要遍历所有的节点进行判断)
红黑树:查询速度快,插入速度慢(每次插入数据的时候,都需要将节点进行平衡,满足左小右大的特性)
3. 1.8 版本中hashcode
为什么要和 hash高16位做异或运算 ?
进入 HashMap#put()
中,调用了 hash()
方法,代码如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
接下来重点分析一下 (h = key.hashCode()) ^ (h >>> 16)
这句代码:
h
:计算得到的 key 对应的 hash 值
>>>
:左移运算
^
:异或运算
假设 key 为 “baidu” 通过计算得出
93498907 (十进制)
00000101 10010010 10101110 00011011(二进制)
00000000 00000000 00000101 10010010 (左移16位)
00000101 10010010 10101011 10001001 (左移后和左移之前进行异或运算)
93498249(异或的结果转换为十进制)
00000000 00000000 00000000 00001111(数组的长度减一为15)
00000101 10010010 10101011 10001001(长度和hash码做与运算)
00000101 10010010 10101011 10001001(最终算出来落到数组上的下标二进制)
9 (十进制的数组下标位置)
通过以上的计算,可以看出执行了左移运算和异或运算后,得到的结果也携带上了高 16 位的特性,而并非原 hashcode
的原始值,这样做的目的是有效的降低了 hash
碰撞的可能性。如果单纯的拿低16位做运算,那么有很多组数的低16位可能会相同,但是高16位就不一定相同。
4. HashMap
执行 put()
内部做了些什么事情 ?
接下来进入源码环节,主要探讨一下 put 方法的执行逻辑:
public V put(K key, V value) {
// 执行 putVal 之前,先计算出 hash 值,正如小结3所示
return putVal(hash(key), key, value, false, true);
}
4.1 putVal
全部源代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4.2 初始化 table
数组
如以上代码所示,我们来一句句的分析:
// 声明变量,毫无疑问
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table 是成员变量,在小结2中已经描述了,代表 HashMap 的底层支撑数组
// 此时如果 table == null 说明这里并没有对这个数组进行初始化,那么直接执行了数组的初始化
// 并且返回初始化的数组大小,根据 resize 方法代码的查看,我们知道默认初始化是16个桶的数量
// 后续的 resize 方法会详细分析
if ((tab = table)== null || (n = tab.length) == 0)
// 直接执行初始化
n = (tab = resize()).length;
执行以上代码,会初始化16个长度的 table 数组
4.3 在数组中存储未被占用的节点数据
// 这里 tab 在上一个判断中已经进行了赋值为成员变量 table,那么此时的 tab 变量一定是不为 null的
// n - 1 & hash 主要是为了给当前存入的 KV 寻找对应的桶,前面提到了桶是数组,那么这里计算的就是数组的下标定位桶
// 此时的这个桶被赋值给了 p 变量,而 i 变量则是当前桶所在数组的下标位置
if ((p = tab[i = (n - 1) & hash]) == null)
// 在 if 判断中计算出了桶所在的下标,那么当前下标的位置一定是没有被占用并且,直接把将要放入的 KV 放入该桶的位置
tab[i] = newNode(hash, key, value, null);
4.4 数组已经被初始化,并且计算出来的数组下标位置所在的节点上已被占用的情况
以下是 else
部分,继续切分
// 既然你进入了 else,那么说明了上一步计算出来的 i 所在的下标位置的第一个桶已经被别的 KV 占用了
// 既然进入了,那么分为几种情况,要么 key 是同一个,要么就不是同一个
// 这里就先判断了 key 到底是不是同一个,如果是同一个,这里直接将当前的这个 key 对应的 node 节点赋值给了 e 变量
// 主意此时的 k 和 e 两个变量已经被赋值了
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
4.5 如果生成的当前节点是一个红黑树节点
// 如果说当前节点类型是红黑树,那么执行红黑树的操作
else if (p instanceof TreeNode)
// 红黑树put值,没啥好讲的,已经脱离了 HashMap 的本之了
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
4.6 针对链表节点的插入
如果既不是相同的 key
,也不是红黑树,那么一定是链表咯,想插入到链表中,那么必须要遍历链表,判断 next
指针指向为 null
的节点即可作为当前数据存储的节点
else {
// 无条件循环,根据内部执行的结果来跳出循环
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
4.6.1 如果当前链表的深度达到了设定的阈值,则转换为红黑树
// 第一次循环中,p就是当前桶中第一个节点,如果第一个节点就没有下一个节点,那么就把当前的 KV 生成一个 Node 放入到 p 节点的下一个节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果当前链表的深度大于了设定的阈值-1,那么就直接转换为红黑树
// 这里为什么要减一:阈值是从1开始的,1-8共8位,而for循环是从0开始,则0-7才是8个数,所以这里需要 -1
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 执行完链表转红黑树的操作后,直接跳出当前循环
break;
}
4.6.2 每次循环都需要与当前的 node
节点进行比对
// e 变量是在 4.6.1 小结中赋值的,是 p.next
// 如果 p 是第一个节点,那么 e 就是 p 的下一个节点
// 这里主要是判断了当前的 key 和 hash 是不是与 e 节点完全一致
// 如果完全一致,则说明当前传入的 kv 与 e 节点的 key 相同,相同则跳出
// 这里跳出但是 e 依然被附上了值,后续再来一同研究
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
4.7 当 e
变量被赋值时,节点使用新值替换老值
if (e != null)
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
相同 key
插入之前如图: 插入前
相同 key
插入之前如图:插入后
4.8 put
方法收尾
// modCount 主要是用来迭代器快速失败的
++modCount;
// size 插入的 node 节点个数
// threshold 扩容阈值,在 resize 方法中被计算出来的,第一次计算结果是 12
// 如果当前的个数大于了12,那么就会调用 resize 方法,但是不一定会触发扩容
if (++size > threshold)
resize();
// 本方法在 HashMap 中并无实质作用
afterNodeInsertion(evict);
5. 总结
由于缩短文章篇幅,将 resize
方法的解剖放到了下文中:HashMap - 核心原理与知识点记录(中)