我们首先来看利用无参构造函数创建HashMap如何扩容。首先创建一个无参构造出来的hashmap
HashMap hashMap = new HashMap();
该构造函数源码如下:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
此时,该构造函数只会设置默认的加载因子,即计算阈值threshold,默认为0.75大小。此时底层的table数组是指向null的
接下来调用put方法,往hashmap里面存一个键值对:
hashMap.put(1, 1);
由于底层的table是指向null的,所以需要肯定进行扩容。调用put方法,该方法的源码如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
该方法首先会调用hash(key),来计算我们传进来的key的hash值,我们看一下hash方法的源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
该方法接收一个Object类型的key,即所有类型的参数都可以接收。key分为两种情况计算:
- 如果key为null:计算结构为0。所以hashmap可以接收key为null的键值对,这是没问题的,因为在这里做了处理,如果key为null,哈希值为0
- 如果key不为null:计算结果为 (h = key.hashCode()) ^ (h >>> 16)。这里相当于 h^(h>>>16),其中h是key的hashCode方法返回值。把h与h的右移16为后进行异或操作,高16位保持不变,低16位会进行扰动。
我们重点来看一下第二种情况,key不为null时,HashMap如何优化hashCode函数,使得Hash值散列更分散。以一个具体的数为例,假设h = 123456789 。下面我用32位二进制数来表述h:
- 原始的哈希值的二进制数: 0000 0111 0101 1011 1100 1101 0001 0101
- 无符号右移16位后的的二进制数:0000 0000 0000 0000 0000 0111 0101 1011
- 上面两个二进制数异或的结果: 0000 0111 0101 1011 1100 1010 0100 1110
由于是异或,即相同为0,不同为1,所以与0异或,结果就是本身。而无符号右移16位,说明高16位全是0,与高16为进行异或运算,说明结果不变,而低16位的值有0也有1,与低16位异或,结果就不一样了。
这种二进制级别的位操作能有效地分散哈希值,从而在哈希表中产生更好的键分布,减少潜在的哈希冲突。
接着往下走,调用 putVal(hash(key), key, value, false, true),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;
}
我们首先看该方法接收的参数:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
接收key的哈希值hash,传进来的 key,value键值对,onlyIfAbsent 是false,evict是true。后面两个参数不重要,重点是前面三个参数:
- hash 经过计算得到kye的哈希值
- key
- value
进入函数,该方法创建四个局部变量:
Node<K,V>[] tab;
Node<K,V> p;
int n
int i;
HashMap底层的数组table就是Node<K,V>类型的,即Node数组,p就是一个node节点。
定义完局部变量,会进行该方法里面的第一个if条件判断:
if ((tab = table) == null || (n = tab.length) == 0)
该语句首先将成员变量table赋值给局部变量tab:
tab = table
然后判断tab是不是为null,或tab的长度是不是为0。这两种情况都对应一个结果:当前数组放不了传进来的键值对,需要进行扩容,所以该if条件里面的代码是:
n = (tab = resize()).length;
这里出现了扩容核心方法resize(),它在putVal方法里面出现了两次,我们总结这两次出现的条件:
- 第一次是if ((tab = table) == null || (n = tab.length) == 0) 判断为真,即第一次调用put方法会触发扩容。
- 第二次是if (++size > threshold)判断为真,即hashmap里面的元素个数大于阈值。
不同条件扩容结果是不一样的,但都调用了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;
if (oldCap > 0) {
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
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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)
((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;
}
}
}
}
}
return newTab;
}
这部分代码较长,其实可以划分为两部分,从if (oldTab != null) 处划分,上面是关于扩容后的容量计算,下面是关于扩容后元素迁移。
我们首先看第一部分:
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
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
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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;
首先定义了一大堆局部变量:
- oldTab,旧表,oldTab = table
- oldCap,旧表容量,(oldTab == null) ? 0 : oldTab.length;,因为可能是第一次构造,所以oldTab 可能为null,这里用三元表达式计算值。
- oldThr,旧的阈值,oldThr = threshold
- newCap,新的容量,newCap = 0
- newThr,新的阈值,newThr = 0
然后紧跟着是一大堆判断条件:
if (oldCap > 0) {
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
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
我们先不关注判断语句里面的执行逻辑,我们看什么情况会执行到哪个逻辑代码块里。
if (oldCap > 0) {
// 这里面执行的扩容逻辑表示不是第一次put扩容,而是后面达到阈值的扩容
}
else if (oldThr > 0) {
// 运行到这里面需要oldCap = 0,表示是第一次put扩容,还需要oldThr > 0,即threshold > 0
// 说明这里面是带参构造函数构造的map的扩容,因为带参构造函数才计算threshold
}
else { // zero initial threshold signifies using defaults
// 这里面是无参构造函数map,第一次调用put扩容时的处理逻辑
}
if (newThr == 0) {
// 这里面是带参构造函数构造的map的扩容
}
为什么带参构造函数HashMap 的 threshold 不为0?
这需要去看源码了:
HashMap<Object, Object> hashMap = new HashMap<>(17);
这里构造了一个HashMap,使用带参构造函数,传入initCapacity
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
发现调用了另外一个构造函数,两个参数,需要传入加载因子,这里传入了DEFAULT_LOAD_FACTOR默认记载因子,0.75。继续进入该方法:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
前面部分代码在对传入的initialCapacity 值做合法性判断,注意看最后一句代码:
this.threshold = tableSizeFor(initialCapacity);
使用tableSizeFor计算出一个不小于initialCapacity的2的幂次,然后返回给threshold ,注意看,这里threshold的值已经被计算出来了,但是table还是null,所以会出现oldTab = 0, oldThr > 0 的情况。
计算出了newCap之后,就需要新建一个数组了:
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
此时如果是第一次扩容,就可以直接return newTab,如果不是第一次扩容,即oldTab不为null,里面还有数据,需要迁移到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)
((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;
}
}
}
}
}
首先创建了一个for循环,全部的代码都在这段for循环里面:
for (int j = 0; j < oldCap; ++j)
这段for循环是变量oldTab所有槽位的元素。
定义一个局部变量,Node<K,V> e;,因为oldTab是Node类型的,e表示oldTabl每个槽位的元素。
if ((e = oldTab[j]) != null)
先让e = oldTab[j],第j个数组元素赋值给e,在判断e是否为空,如果为空,直接判断下一个槽位。如果不为空,将oldTab[j] 迁移到 newTab某个槽位处,所以这里是最关键的代码,我们看这个if里面的代码:
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;
}
}
首先让oldTab[j] = null; 此时e是执行这个位置原本指向的节点的
紧接着就是几个if-else将e指向的该Node节点分成了三种:
- e.next == null,就是单个节点,没有形成链表。
- e instanceof TreeNode,e不是单个节点,而是树的头节点
- else,e是链表的头节点
前面两种情况都很简单:
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
对于第一种单个节点的情况,如源码所示,会用e的hash值与新容量-1的值进行与运算,即将e的hash值映射到newTab的索引上,直接然后newTab[计算出来的新索引] 指向该 Node节点。
对于第二种情况,e不是单个节点,而是树的头节点:
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
需要调用该节点的split方法,将红黑树切分为两个链表,之后进行扩容操作。
第三种情况,e是链表的头节点,处理部分的源码:
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;
}
首先定义了一些局部变量:
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
然后用一个do-while循环:
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);
第三种情况看似代码多,但比较简单。第三种情况是oldTab[j]位置的元素是一条链表,迁移到新数组上怎么做的?
通过遍历这条链上的节点e,然后根据下面的条件判断:
(e.hash & oldCap) == 0
把这条链上的所有节点分成了两块,e.hash & oldCap结果等于0,e.hash & oldCap结果不等于0。
// 定义两个链表的头和尾节点,分别用于低位链表和高位链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next; // 用于暂存当前节点的下一个节点
do {
next = e.next; // 存储当前节点的下一个节点,以便迭代继续进行
// 判断当前节点的哈希值与oldCap的关系,oldCap通常是数组的当前容量,用作重新哈希的分界线
if ((e.hash & oldCap) == 0) {
// 当前节点哈希值和oldCap按位与结果为0,表示当前节点应保持在原索引
if (loTail == null)
loHead = e; // 如果低位链表尾节点为空,即链表还未初始化,则当前节点为链表头节点
else
loTail.next = e; // 如果低位链表尾节点不为空,则将当前节点链接到链表尾部
loTail = e; // 更新低位链表尾节点为当前节点
}
else {
// 当前节点哈希值和oldCap按位与结果不为0,表示当前节点应移动到新索引位置
if (hiTail == null)
hiHead = e; // 如果高位链表尾节点为空,即链表还未初始化,则当前节点为链表头节点
else
hiTail.next = e; // 如果高位链表尾节点不为空,则将当前节点链接到链表尾部
hiTail = e; // 更新高位链表尾节点为当前节点
}
} while ((e = next) != null); // 迭代直到链表末尾
// 处理完所有节点后,设置链表尾部节点的next指针为null,表示链表结束
if (loTail != null) {
loTail.next = null; // 低位链表尾节点next设为null
newTab[j] = loHead; // 将低位链表头节点放入新表的对应位置
}
if (hiTail != null) {
hiTail.next = null; // 高位链表尾节点next设为null
newTab[j + oldCap] = hiHead; // 将高位链表头节点放入新表的偏移位置
}
我们在计算旧表中的节点e,在新表中的索引位置时,是用e.hash & (newCap - 1)计算的,为什么这里不这样计算?
在HashMap
的扩容过程中,节点在新表中的索引位置通常是通过e.hash & (newCap - 1)
来计算的。这种计算方法确保了节点根据其哈希值被均匀分布到新的哈希表中。然而,在你提到的代码片段中,使用的是e.hash & oldCap
,这里有一个特定的原因和上下文:
背景
在Java的HashMap
中,容量(cap
)始终是2的幂次方(例如,16, 32, 64等)。这样设计的主要原因是利用位运算来替代模运算,从而提高计算效率。当cap
是2的幂次方时,newCap
就是oldCap * 2
。
为什么使用e.hash & oldCap
-
掩码计算: 当使用
e.hash & oldCap
时,我们实际上是在利用oldCap
作为一个掩码。在扩容操作中,oldCap
是原哈希表的大小,而newCap
是新哈希表的大小,且newCap = 2 * oldCap
。在这种情况下,oldCap
的值实际上是一个比新容量少一位的值(如,如果oldCap = 16
,则oldCap - 1 = 15
,二进制为1111
)。 -
决定节点的分布:
- 低位桶: 如果
e.hash & oldCap
的结果是0,这意味着在oldCap
位上e.hash
是0,因此e.hash
的二进制表示中,从最低位到oldCap
位的部分没有变化。这表明,节点在新表中的索引位置与在旧表中的位置相同,即index
。 - 高位桶: 如果结果不是0,意味着在
oldCap
的位置e.hash
是1,这表明节点在新表中的索引位置是它在旧表中的位置加上oldCap
,即index + oldCap
。
- 低位桶: 如果
总结
通过这种方式,split
操作不需要对每个节点重新计算其在新表中的完整位置,而只需要决定它是留在原位置还是移动到原位置加上旧容量的位置。这简化了计算过程,并利用了已有的哈希值和位运算的性质,达到既快速又高效的重新分配节点的目的。
这种方法不仅保证了正确性,而且也使得扩容过程中的节点重新分配更为高效。
下面用一个具体的例子进行解释:
我们来通过一个具体的例子来解释这个过程。假设我们有一个HashMap
,它的原始容量(oldCap
)是16,这意味着它可以有从0到15的索引。现在这个HashMap
需要扩容,新的容量(newCap
)将会是32(即16的两倍)。
哈希表的容量和二进制表示
- 旧容量 (
oldCap
): 16,二进制表示为 0001 0000 - 新容量 (
newCap
): 32,二进制表示为 0010 0000
索引位置的决定
假设我们有一个键的哈希值为34,我们想确定它在旧表和新表中的位置。
- 哈希值: 34,二进制表示为 0010 0010
1. 旧表中的位置
在旧表中,索引是通过hash & (oldCap - 1)
得到的:
oldCap - 1
= 15,二进制表示为 0000 111134 & 15
=0010 0010 & 0000 1111
=0000 0010
= 2- 因此,哈希值为34的键在旧表中的索引是2。
2. 新表中的位置
我们可以通过hash & (newCap - 1)
计算新索引,但现在我们关心的是如何使用hash & oldCap
来决定索引位置变化:
oldCap
本身为16,二进制表示为0001 000034 & 16
=0010 0010 & 0001 0000
=0000 0000
- 因为结果是0,表示在oldCap位置上的位是0,这意味着哈希值为34的键在新表中的索引仍然是2(即保留在原位置,低位桶)。
如果哈希值是例如50,我们来看看它的处理:
- 哈希值: 50,二进制表示为 0011 0010
50 & 16
=0011 0010 & 0001 0000
=0001 0000
- 因为结果不是0,表示在oldCap位置上的位是1,这意味着哈希值为50的键在新表中的索引是它原来的索引(旧表中的索引为2)加上
oldCap
(16),也就是18(即移动到新位置,高位桶)。
- 因为结果不是0,表示在oldCap位置上的位是1,这意味着哈希值为50的键在新表中的索引是它原来的索引(旧表中的索引为2)加上
总结
这种通过hash & oldCap
来判断元素应该留在原位置还是移动到新位置的方法是高效的,因为它利用了已经计算的哈希值,并且只涉及简单的位运算。这样可以在扩容时快速重新分配元素,而无需重新计算每个元素的哈希值。
最后这个split函数的源码如下:
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;
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) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
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);
}
}
}
逻辑上和链条拆分类似,不过涉及到树的退化,有时间我在继续写吧。。。。