hashmap源码-扩容机制

277 阅读7分钟

前言

注意,本系列文章都是基于 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 有了比较深刻的认识,如果觉得文章对你有帮助,欢迎留言点赞。