HashMap源码详解(一文掌握所有核心知识)
Posted MrDeng886
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap源码详解(一文掌握所有核心知识)相关的知识,希望对你有一定的参考价值。
大纲
前言
HashMap底层是使用数组来实现哈希表的。之所以哈希表存储和查询快,是因为往哈希表里面放东西的时候,会先通过key算出一个哈希值(hash),然后利用这个hash值来与哈希表的容量进行取余运算(取余和取模是由区别的),然后就得到了准确的数组下标,然后就可以快速地进行插入操作;同理查询也一样,通过准确的数组下标就可以快速得到结果。
HashMap解决了什么问题
查询速率不高的问题
因为HashMap是基于数组的,而数组查询速率是很高的,因为:
- 每一个元素的内存地址在空间中是连续的
- 每一个元素类型一样,所以占有空间大小一样
- 知道第一个元素的内存地址,知道了每一个元素的空间大小,又知道了要查的那一个元素的下标,所以通过数学表达式就可以计算出那个元素的准确内存地址,从而直接通过内存地址来定位那个元素,所以数组的查询效率是最高的。
哈希冲突
所谓的哈希冲突就是在插入元素的时候,通过计算得到的数组下标上已经存在元素(一个或多个)了,并且这个元素的key与要插入元素的key值不一样**(通过hash和equal()来判断)**,但又不能把原先那个元素的value给覆盖掉,所以就发生了冲突。HashMap解决这个冲突是同过链表和红黑树来解决的。如果不懂什么是红黑树也没关系,等下源码分析的时候会顺带讲到,现在只要知道它是一个平衡二叉树,并且效率要比AVL树要高就行了。
自动扩容
因为HashMap是基于数组来实现的,但虽然数组查询效率高,但它的长度是固定的,所以如果存的元素多了,很容易造成哈希冲突,达不到一个很好的散列效果。所以必须要对数组进行扩容,所以HashMap里面就会涉及到数组的自动扩容。
从put()方法开始了解源码
二话不说,学习源码之前先放一张UML图,来明确HashMap的继承关系
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kQOLtjC7-1635150735122)(HashMap源码详解.assets/image-20211024143751356.png)]
首先编写一段简单的代码,往HashMap里面放元素
public static void main(String[] args) {
Map<String,String> map = new HashMap<>();
map.put("hello","hello");
}
然后我们点进去看,因为这个方法是在Map接口里面声明了,而HashMap实现了该接口,所以来到HashMap类
public V put(K key, V value) {
//算出key的hash值
return putVal(hash(key), key, value, false, true);
}
我们发现真正执行添加元素的方法是putVal(),并且在这一步我们算出了key对应的hash值。然后继续
下面是执行添加元素的核心方法,遇到长代码先不要慌,先听我分析一下再看。首先putVal()方法主要做的事情就是算出要添加的元素要放到的数组下标是什么,算出下标后然后看看下标对应的那个位置有没有存在的元素,如果为空则直接放进去就行,如果不是则再分析这个原本就存在节点的key值到底和要添加的元素的key值一不一样,如果一样则会有对应操作,否则再继续判断这个节点是不是一棵树的根节点,如果是就再执行对应操作,否则就当成链来处理。然后请大家耐心看完并理解下面的代码。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//定义了tab数组用来存放值
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//重新计算数组的长度
n = (tab = resize()).length;
//在这里jdk8使用了‘&’位运算来替代了jdk7的'%'取余运算符,因为位运算的效率更高
//用'&'取余是有限制的,必须是2的n次幂才行,所以规定初始容量必须是2的幂次
//等下会分析一下用'&'的可行性
if ((p = tab[i = (n - 1) & hash]) == null)
//如果对应的数组下标上没有节点,则直接赋一个新的节点上前去就好了
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//第一种情况是存在的key与现在要插进去的key相同(通过hash和equal()方法进行判断)
//理论上,如果一个类的equals方法重写了,那么hashCode()方法必须重写。
//并且equals方法返回如果是true,hashoCode()方法返回值必须一样
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);
//如果链的长度大于等于8的时候 ,就会变成树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//在遍历过程中看看有没有key一样的元素,如果有则退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//经过上面那些步骤,如果e不等于空,则说明存在key值与要添加元素的key值相同的情况
//所以接下来就要考虑到底是保留旧值还是用新值覆盖旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//这里说明如果onlyIfAbsent为true的话,说明如果旧值存在,则不进行替换。
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//此说明此hashMap被修改的次数加一,在迭代器建立视图的时候会用到
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
笔者第一次看见’&'这个位运算符的时候,最后在网上查了多篇资料然后搞懂了,下面我整理一下我查到的资料。
首先用’&‘取余运算是有限制条件的:使用’&'运算的两个数必须有一个是2的n次幂才行,所以java规定了容量都必须为2的幂次
之所以一定是2的幂次,是因为对于一个数x,它要被除以2的n次方,也就是在位运算中相当把x右移了n位,而刚好被移出去的n位就是我们要求的余数。
其实这也不难理解,我们可以用十进制去理解一下,比如有一个数 1001,然后因为二进制被除以2就可以类比于十进制除以了10,都是向右移了一位,所以1001向右移了一位就是100,然后移出去的那一位是1,所以1001除10的余数就是1。
然后观察一下下面的例子
11%4=3
=> 1011 & (0011) //因为11除4的余数,相当于11化为二进制后向右移动两位,移出去的那两位就是余数。所以用0011与11进行按位('&')与运算,刚好就能得到后两位
=> 1011 & (0100 - 1)
=> 11 & (4 - 1) //在这里把4看所哈希表的容量,就可以理解为什么要规定容量为2的幂次了
综上所述,用 x&(2的n次幂-1) 可以实现取余操作
我们发现在putVal()方法里面有resize()方法,它就是用来实现自动扩容的。
resize()实现扩容的关键
老规矩,先讲诉一下下面的代码主要是干什么的,然后再请读者认真看看下面的代码。首先,进行扩容最棘手的问题就是如何把因为发生了哈希冲突而形成的链或者树重散列映射到新的数组去,无论是树还是链都有一个共通的做法,就是形成一条低位链和高位链,把低位链放在新数组的旧下标下,也就是在旧数组是什么下标在新数组就是什么下标;而高位链则放在新数组的(旧数组下标+旧数组容量)的下标上
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//在一开始阈值是被设0的
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//如果大于2的30次方,则无法再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新的容量等于旧的容量的两倍,新的值域也是旧的值域的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold(两倍的阈值)
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//这里表示旧容量和阈值都为0
newCap = DEFAULT_INITIAL_CAPACITY;//被赋值16
//DEFAULT_LOAD_FACTOR 是0.75
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//被赋值12
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//用新的容量来新建一个数组
table = newTab;
if (oldTab != null) {
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) //对树进行处理 split()等下会介绍,不用着急
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //如果当前下标下的元素既不是单个,也不是一棵树的根节点,则当作链表处理
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//下面的循环就是把原来的一条链分为两条,一条是低位链,一条是高位链。
do {
next = e.next;
//下面这个(e.hash & oldCap) == 0 难理解不用怕,下面会讲诉一下
//如果等于零,则说明在新数组中的索引不变
//否则,在新数组中的索引等于就索引加上旧容量
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;
}
}
}
}
}
return newTab;
}
(e.hash & oldCap) == 0 到底是什么?
首先先放结论,通过这个算法可以巧妙地把节点分为两类
- 等于0时,该头节点放到新数组时的索引位置等于其在旧数组时的索引位置,它们记为低位 low
- 不等于0时,该头节点放到新数组时的索引等于其在旧数组的索引位置再加上数组的长度。
其实记住上面结论就可以了,但也不妨看看分析。
当(e.hash & oldCap) == 0时的推导
oldCap = 2^4 = 10000
e.hash = 01010 //这个是随便列的,只要满足(e.hash & oldCap) == 0即可
(2*oldCap-1)= 011111
(oldCap - 1) = 01111
//然后我们就会惊奇地发现(2*oldCap-1)&e.hash 与 (oldCap-1)&e.hash的结果是一样的,因为决定结果的(2*oldCap-1)和(oldCap-1)倒数四个低位都是1
//而(2*oldCap-1)&e.hash 和 (oldCap-1)&e.hash 刚好是分别用来算新数组下标和旧数组下标的,然后它们两个算出的结果一样,所有下标在新数组里没有变。
//所以有头节点放到新数组时的索引位置等于其在旧数组时的索引位置的结论
当(e.hash & oldCap) != 0时的推导
oldCap = 2^4 = 10000
e.hash = 11010 //这个是随便列的,只要满足(e.hash & oldCap) == 0即可
(2*oldCap-1)= 011111
(oldCap - 1) = 01111
(2*oldCap-1)&e.hash结果如下
011111
& 11010
----------
11010
(oldCap-1)&e.hash结果如下
01111
& 11010
----------
01010
我们发现,下面的结果加上一个旧容量(10000)就等于上面的结果了,所以也就印证了结论
//不等于0时,该头节点放到新数组时的索引等于其在旧数组的索引位置再加上数组的长度。
然后我们看看resize()中处理树的方法,split()
split()扩容时对红黑树的处理
还是先说说这个方法做了啥。其实这个方法不涉及任何有关红黑树的知识,大家可以无压力阅读,这个方法主要是基于红黑树各个节点所形成的双链表来实现的,而这个双链表是在由单链表节点转为红黑树的时候顺便实现的。所以下面的方法其实跟上面resize()处理链表没什么本质区别,核心都是把链分为低位和高位链。
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
//因为红黑树的节点之前除了组成了一棵树的关系外,它们还组成了一条双链表
//所以可以通过简单的遍历这条双链表,来把它分为低位链和高位链
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//形成一个低位双链表
//bit就是容量
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
//如果小于建树的阈值(6),就把这个由TreeNode组成的双链表换成由Node组成的单链表
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) //否则,则可以进行树化
loHead.treeify(tab);
}
}
//高位双链表同样也会这样处理
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
通过看上面的方法,我们还会发现如果红黑树的双链表被分为两条后,如果它们没有到达指定条件去重新生成一棵树的话,就会重新变为普通的单链表。
在将treeify()方法来建立红黑树之前,先介绍红黑树的基础知识
关于红黑树的最少必要知识
首先先列一下红黑树的特性:
- 每个节点要么是红色,要么是黑色;
- 根节点永远是黑色的;
- 所有的叶节点都是是黑色的(注意这里说叶子节点其实是上图中的 NIL 节点);
- 每个红色节点的两个子节点一定都是黑色;
- 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
红黑树插入后保持平衡
关于往红黑树里插入新的元素,首先按照普通的二叉树的方法来找到正确的位置并插进去(新插进去的节点都会被标记成红色),然后再分析插完节点后是否有不满足上诉规则的,如果有不满足,则要通过某些方法来使它满足,从而是红黑树重新达到平衡的状态。(下面的图都是子树而不是一棵完整的红黑树!)
- 第一种情况,插入的节点的父节点和其叔节点的颜色都是红色的。(N是新插入的节点)
这种情况很明显不满足上诉的第四条规则,所有要转换一下,变成下图所表示
MySQL一文掌握索引事务的核心内容