每一个复杂知识,都是由一些分散的知识点组合而成的,想要读懂源码,首先需要了解其中所必须的原理知识。
阅读本文,需要预先了解的内容有(在文章尾部有相关知识的参考链接,可以跳转查阅):
- 哈希桶
- B树
- 红黑树
分析思路
笔者在准备阅读HashMap源码的时候,能够想到的符合思维直觉的方法,就是从HashMap的使用入手,剖析HashMap是如何利用开发者的调用,将数据存储。
先看看大部分程序员常写的HashMap代码:
// 创建对象
Map<Integer, String> map = new HashMap();
// 添加数据
map.put(0, "hello");
可以看到使用到了HashMap的两个方法:构造方法和put方法;而“哈希桶”作为阅读HashMap源码指导思想的理论,大家可以在文章尾部的参考文章中了解。
怎么存储数据的?
数组,是最常见的数据结构;而复杂数据结构,其实都是利用简单数据结构实现的,HashMap也不例外,既然通过构造方法和put方法就可以实现数据的存储,那么就可以从中找到HashMap是如何利用哈希桶这个数据结构。
下面是笔者绘制的流程图,与代码里面注释的流程编号对应,读者可以详细对照阅读。
接下来就是结合流程图,理解代码的逻辑:
/**
* 无参构造方法
* 发现并没有什么与实现“哈希桶”相关的代码
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* 实际上调用的是putVal方法
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 一个存储方法为什么逻辑会这么复杂呢?
* 其实里面就包含了,存储结构的创建、扩容、寻址、存储
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.判断table数组是否为null或者是否为空
if ((tab = table) == null || (n = tab.length) == 0)
// 2.调用resize()创建一个table
// 3.tab的引用指向resize()返回的table
n = (tab = resize()).length;
// 4.通过(n - 1) & hash 寻址新插入的k,v的下标
// 5.判断寻址到的数组位置是否为null
if ((p = tab[i = (n - 1) & hash]) == null)
// 6.向数组对应的位置存一个Node对象
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 7.如果找到的数组中的节点的hash、key、value均相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 8.记录节点
e = p;
// 9.找到的节点是否为TreeNode的示例
else if (p instanceof TreeNode)
// 10.按照树结构插入key,value
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 11.从当前节点向后遍历,直到node.next为null,将key,value以Node的形式存储
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 12.当判断到链表的长度大于等于7,将链表转换为树,跳出循环
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 13.当e的hash、key与插入的值相等,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 14.根据onlyIfAbsent,修改节点的值,并返回之前的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 15.modCount++,并且判断size大于域值后,调用resize()方法扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
跟着笔者读完了put()流程后,是不是还是没有发现HashMap的table在什么时候创建的呢?没关系,我来告诉大家,是在resize()中创建了数组以及对数组进行扩容的,下一小节,我们就看看resize()的内容。
resize()方法详解
老办法,先看流程图,对流程有个大致的印象
下面是在代码中对流程的标记:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.如果旧的容量大于0,那么就表示原来有数据,此次调用则是要进行扩容
if (oldCap > 0) {
// 2.如果旧的容量大于最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
// 3.将域值设置为最大,并返回oldTab
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 4. 如果将newCap赋值为oldCap扩容一倍,仍小于最大容量;并且oldCap大于等于初始化容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 5.就把newThr赋值为oldThr扩大一倍
newThr = oldThr << 1; // double threshold
}
// 6.如果oldCap <=0,并且oldThr大于0
else if (oldThr > 0) // initial capacity was placed in threshold
// 7.将newCap赋值为oldThr
newCap = oldThr;
// 8. 如果oldCap<=0&&oldThr<=0,表示创建新的HashMap对象
else { // zero initial threshold signifies using defaults
// 9.将newCap赋值为初始容量,newThr赋值为初始加载因子x初始容量
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 10.如果newThr为0
if (newThr == 0) {
// 11.对newThr赋值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 12.将HashMap的threshold属性赋值为newThr
threshold = newThr;
// 13.创建一个以newCap为大小的数组,并将table指向newTab
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 14.如果oldTab不为null,表示调用当前方法是为了扩容
if (oldTab != null) {
// 15.遍历旧数组,将oldTab内存储的内容移到newTab上
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 16.遍历数组中不为null的对象,即存储了内容的位置,将引用赋值给e
if ((e = oldTab[j]) != null) {
// 17.清除oldTab中原来的位置内容
oldTab[j] = null;
// 18.如果链表有一个节点,就直接在newTab中存储e
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 19.如果节点是TreeNode
else if (e instanceof TreeNode)
// 20.调用split方法,将旧数据存储到newTab
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 21. 准备两个链表,lo表和hi表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 22.遍历链表,直到e.next为null
do {
// 23.将next赋值为e.next
next = e.next;
// 24.存储在原来链表上hash与oldCap为0的元素,存到lo表中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 25.与oldCap为1的元素,存到hi表中
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 26.将原来存储在一个链表上元素,在新表上根据与oldCap的情况,存在两个位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
这部分代码中,比较重要的就是将oldTab的数据移到newTab,尤其是链表上的数据移动,下面给出示意图,供读者参考(下图来自作者ChiuCheng的“深入理解HashMap系列文章”):
总结
通过上面的分析,从编程时HashMap的使用习惯,分析了HashMap是如何存储数据的,包含:
- 建表
- 扩容
- 数据迁移
- 数据插入(a. 表中节点为null;b. 表中节点为TreeNode;c. 表中节点为链表)
其实万变不离其宗,了解了数据的存储,在不同的情况下,数据的插入、删除动作就比较好理解和实现了,大家可以自行阅读源码了解其中的逻辑,我就不继续展开了,谢谢大家的阅读!!!文章中有什么疑问,可以提出来交流^^
参考文章:
数据结构之哈希表(包含哈希桶)_无聊星期三的博客-CSDN博客_哈希表有14个桶
Java HashMap为什么通过(n - 1) & hash 获取哈希桶数组下标?_hmi1024的博客-CSDN博客_hashmap的hash桶
HashMap evict 放逐之旅_一钱夏柘半代赭的博客-CSDN博客_hashmap的evict
HashMap实现LRU(最近最少使用)缓存更新算法_zhaohong_bo的博客-CSDN博客_hashmap lru