前言
注意,本系列文章都是基于 jdk 1.8
这是 hashmap 源码系列文章的第三篇,前面文章讲解了 hash 算法,以及元素 put, get 的流程,相信大家对 hashmap 容器已经有了大体上的认识,今天要讲的内容可谓是 hashmap 的重头戏——扩容机制。
内容主要涉及:
- 容器扩容
- 负载因子
- 扩容机制
容器扩容
jdk 中很多容器的底层实现使用的是数组,比如 ArrayList,还有今天要说的 Hashmap。数组的容量是一开始指定好的,为一组连续的内存空间。
在实际的开发过程中,很多时候我们没办法预估容器最终存储的元素数量,因此也就没办法事先为容器指定一个合适的容量,当然也可以初始化一个容量超大的容器,显然这不是明智的做法。这时候就需要一种动态扩容机制,当存储空间不足的时候,能够动态地扩充容器的容量,保证元素正常存储的同时,也避免了过多的内存空间浪费。
而在使用 Hashmap 的过程中,你会发现根本不用关心容量的问题,因为 Hashmap 的底层已经实现了动态扩容机制。
负载因子
前面的文章提到在一个元素成功添加到 hashmap 后,会判断容器是否需要扩容。
先看到一段测试代码:
public static void main(String[] args) throws Exception {
// 初始化容量为 8
HashMap<String, String> hashMap = new HashMap(8);
// 放入 7 个元素
hashMap.put("1", "a");
hashMap.put("2", "b");
hashMap.put("3", "c");
hashMap.put("4", "d");
hashMap.put("5", "e");
hashMap.put("6", "f");
hashMap.put("7", "g");
// 通过反射获取数组容量
Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true);
HashMap.Entry[] table = (HashMap.Entry[]) tableField.get(hashMap);
System.out.println("put 操作后 table 对象的长度:" + table.length);
}
运行结果:
从程序运行结果可以看到 hashmap 不是等到元素存储满了才扩容,回顾一下扩容的条件:
if (++size > threshold)
resize();
size 为容器存储的元素数量,而 threshold 则是扩容阈值,扩容阈值的计算公式为:
threshold = capacity * loadFactor,capacity 为数组容量,loadFactor 为负载因子
首先需要知道负载因子对 hashmap 存储的影响:
- 负载因子越大,意味着容器中元素存得越满,虽然节省了空间,但是也增加了元素哈希冲突的概率
- 负载因子越小,容器中空余的位置越多,虽然减少了元素哈希冲突的概率,但也造成了大量的空间浪费
默认的负载因子 DEFAULT_LOAD_FACTOR = 0.75,这是 jdk 默认设置的一个比较合适的负载因子,一般情况下不推荐去修改它。特殊情况下:
- 对内存空间要求比较苛刻,而对元素查找速度要求不高,可以把负载因子调高(时间换空间)
- 对程序运行速度要求苛刻,而内存空间充足的情况,可以把负载因子调低(空间换时间)
扩容机制
了解了负载因子之后,讲到重点部分——hashmap 的扩容机制。看到 resize() 这个方法,扩容的秘密全部藏在里面。
调用 resize 方法分为两种情况:
- hashmap 没有初始化,则调用 resize 方法进行初始化
- hashmap 已经初始化,调用 resize 方法进行扩容
这边将 resize 方法的代码拆分成 3 部分:
- 容量相关值计算
- 初始化数组
- 元素迁移
- rehash 算法
容量相关值计算
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) {
// oldCap > 0,则说明 hashmap 前面已经被初始化了
if (oldCap >= MAXIMUM_CAPACITY) {
// 容量已经达到最大限制,则不允许继续扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容操作,将数组长度 * 2
// 扩容阈值 * 2
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
// hashmap 没有初始化,并且声明时指定了初始容量
// 使用声明的初始容量
newCap = oldThr;
else {
// hashmap 没有初始化,声明时没有指定初始容量
// 使用默认的初始容量 16
newCap = DEFAULT_INITIAL_CAPACITY;
// 计算扩容阈值
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 初始化时没有指定初始容量,或者扩容前数组容量 < 16
// 计算新的扩容阈值 newThr
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
初始化数组
// 更新扩容阈值 threshold
threshold = newThr;
// 重新创建一个数组,容量为扩容后的容量
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 将 table 引用指向新的数组
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)
// 如果该哈希桶位置还没有形成链表,则直接将该元素 rehash 到新数组中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果该哈希桶位置已经形成红黑树,则涉及到树的拆分
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 如果该哈希桶位置已经形成了链表,则需要将链表元素迁移到新数组
// 在 jdk 1.7 中,是直接用 hash 算法将元素挨个 rehash
// 在 jdk 1.8 中,对算法进行了改进,非常巧妙,下面单独进行分析
}
}
}
}
rehash 算法
在讲新的 rehash 算法前,先回顾一下前面提到 hash 算法,hashmap 怎么进行哈希映射。
e.hash & (tableCap - 1)
需要注意一点,扩容前与扩容后,变化的是数组容量 tableCap(为原本的 2 倍),而元素的 hash 值是不变的,因此对 rehash 后的位置产生影响的是 tableCap。
简单举个例子:
// 元素 hash 值
0000 0000 0000 0100 0111
扩容前数组容量 n = 8,哈希的计算过程如下:
0000 0000 0000 0100 0111
& 111
0111
// 映射结果为 8
扩容后数组容量 n = 16,哈希的计算过程如下:
0000 0000 0000 0100 0111
& 1111
0111
// 映射结果还是为 8
// 将 hash 值低 4 位都变成 1
0000 0000 0000 0100 1111
& 1111
0111
// 映射结果为 16
可以发现规律,低位掩码本质上取的就是元素低位的 hash 值,扩容后低位掩码增大一位,意味着取到的 hash 值也多了一位,如果多出的那一位是 0,则对最终的 rehash 结果没有影响;相反如果多出的那一位是 1,则 rehash 结果也是有规律的增加。
下面看到 hash 的 rehash 算法:
re// 迁移到新数组,位置不需要变化的链表,链表头 loHead, 链表尾 loTail
Node<K,V> loHead = null, loTail = null;
// 迁移到新数组,位置发生变化的链表,链表头 hiHead, 链表尾 hiTail
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 这一步就是判断 rehash 之后,链表元素位置是否发生变化
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;
}
整个 rehash 过程将原链表拆分成了两个链表,链表 loHead 迁移后索引位置保持不变,链表hiHead 迁移后索引位置 = 原索引位置 + 原数组长度。
总结
总体来说,jdk 1.8 中 Hashmap 的 resize 过程变化还是蛮大的,暂且不提新引入的红黑树部分,最明显的变化就是 rehash 的过程,算法非常巧妙。
希望通过本篇文章,你对 Hashmap 的 resize 有了比较深刻的认识,如果觉得文章对你有帮助,欢迎留言点赞。