java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java HashMap扩容机制

一篇文章彻底拆解Java HashMap扩容机制

作者:Anastasiozzzz

在Java中HashMap是一个非常常用的数据结构,基于哈希表实现,它通过键值对的形式存储数据,这篇文章主要介绍了Java HashMap扩容机制的相关资料,文中通过代码介绍的非常详细,需要的朋友可以参考下

今天来给大家再讲解一下HashMap的扩容机制。一个小小的数据结构里面隐藏着许多优化与细节!

一、为什么需要扩容?

HashMap 的底层数据结构是 数组 + 链表(JDK 1.8 引入了红黑树)。

数组的长度是固定的。随着我们不断执行 put 操作,越来越多的键值对(Entry/Node)被放入容器。为了解决哈希冲突,链表会越来越长。

核心参数

思考:为什么负载因子是 0.75? 这是一个空间与时间的折中(Trade-off)。

二、扩容的触发时机

在 put 方法存入数据后,HashMap 会检查当前元素个数 size 是否大于 threshold

// 伪代码逻辑
if (++size > threshold)
    resize();

JDK 1.8 是先插入数据,再判断是否需要扩容;而 JDK 1.7 是先判断是否需要扩容,再插入数据。

三、扩容流程

当我们的HashMap中的元素个数(size)达到负载因子*容量之后,会触发扩容

此时会建一个新数组,数组容量为old数组的俩倍 :newCap = oldCap*2

扩容分三种情况:

接下来分情况讨论

3.0 高低位拆分原理

JDK 8 利用容量为 2 的幂次方的特性,通过 (e.hash & oldCap) == 0 判断元素在新数组中的位置:

为什么呢?

我们在进行索引计算的时候,是使用hash(k) & (cap - 1) 计算的,那么这里面其实有个规律

我们每次扩容都是容量*2,假设旧的容量为16,那么新容量为16 * 2 = 32

原本是通过hash(k) & (01111)进行计算索引的(01111是16-1的二进制),新数组的索引是通过hash(k) & (011111) 进行计算的,会发现其实是否迁移新的位置取决于第原本hash的

log(cap) + 1位,对于16就是4 + 1 = 5位,也就是说,如果我们的hash&cap == 0就放置原索引,如果hash&cap != 0就放到原索引+oldcap

3.1 仅有一节点

这是最简单的情景,只要把这个节点迁移到新数组即可

if (e.next == null)
    newTab[e.hash & (newCap - 1)] = e;

假设原桶(索引 3)中有一条链表:A → B → C → D → null

3.2 已形成链表

迁移步骤:

  1. 初始化两条空链表
    • 低位链表:loHead=null, loTail=null
    • 高位链表:hiHead=null, hiTail=null
  2. 逐节点判断并拆分

    节点

    hash & 16 结果

    操作

    A

    ==0(低位)

    loHead=A, loTail=A → 低位链表:A → ?

    B

    !=0(高位)

    hiHead=B, hiTail=B → 高位链表:B → ?

    C

    ==0(低位)

    loTail.next=C, loTail=C → 低位链表:A → C → ?

    D

    !=0(高位)

    hiTail.next=D, hiTail=D → 高位链表:B → D → ?

  3. 断尾与安置
    • loTail.next = null → 低位链表变为 A → C → null
    • hiTail.next = null → 高位链表变为 B → D → null
    • 低位链表放入 newTab[3]
    • 高位链表放入 newTab[19]

对于每个形成链表的桶都会重复这个步骤

也就是说会通过对每个桶的链表通过hash&oldCap的情况来分成俩个链表,然后再进行迁移

3.3 已形成红黑树

红黑树节点(TreeNode)同时维护两种结构

扩容时只利用双向链表进行拆分,不操作树结构。

阶段 1:拆分为两条 TreeNode 链表

假设原桶(索引 3)中有 9 个 TreeNode 节点,按双向链表顺序遍历:

  1. 初始化:
    • 低位:loHead=null, loTail=null, lc=0
    • 高位:hiHead=null, hiTail=null, hc=0
  2. 逐节点拆分(与链表逻辑一致,但维护 prev 指针):

    节点

    hash & 16

    操作

    低位链表

    高位链表

    lc/hc

    N1

    ==0

    插入低位

    N1

    -

    lc=1

    N2

    !=0

    插入高位

    N1

    N2

    hc=1

    N3

    ==0

    插入低位

    N1→N3

    N2

    lc=2

    N4

    !=0

    插入高位

    N1→N3

    N2→N4

    hc=2

    ...

    ...

    ...

    ...

    ...

    ...

    N9

    ==0

    插入低位

    N1→N3→N5→N7→N9

    N2→N4→N6→N8

    lc=5, hc=4

    每次插入时:

    • 设置 e.prev = loTail(维护双向链表)
    • loTail.next = e(连接到尾部)
    • loTail = e(更新尾指针)
    • e.next = null立即断开原链表引用,防止内存泄漏)

阶段 2:计数完成后的决策

拆分结束后,根据每条链表的节点数做决策:

链表

节点数

决策逻辑

结果

低位

5

5 <= 6(退化阈值)

调用 untreeify() → 转为普通 Node 链表

高位

4

4 <= 6

调用 untreeify() → 转为普通 Node 链表

退化阈值 = 6UNTREEIFY_THRESHOLD
树化阈值 = 8TREEIFY_THRESHOLD
6 < 8 的设计是为了防止"抖动":避免扩容拆分后频繁在树/链表间切换

阶段 3:树化条件(仅当节点数 > 6 时触发)

若某条链表节点数 > 6,还需满足两个条件才重建红黑树:

  1. 节点数 > 6(已满足)
  2. 数组总长度 ≥ 64MIN_TREEIFY_CAPACITY

满足条件后执行 treeify()

若数组长度 < 64(如刚扩容到 32),即使节点数 > 6 也不树化,保持为 TreeNode 链表。原因:容量仍较小,下次扩容可能又拆分退化,避免无效树化。

也就是拆分成俩个链表->判断是否树化->迁移至新桶

3.4 Talk is cheap Show me code

看看源码加注释吧

单节点、链表迁移

// HashMap.resize() 方法片段 - 链表迁移
for (int j = 0; j < oldCap; ++j) {          // 遍历旧数组每个桶
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {          // 桶非空
        oldTab[j] = null;                   // 清空原桶,便于GC
        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 {                              // ===== 链表迁移核心逻辑 =====
            // 初始化两条新链表的头尾指针
            // lo = low(低位),hi = high(高位)
            Node<K,V> loHead = null, loTail = null;  // 低位链表:新索引 = 原索引
            Node<K,V> hiHead = null, hiTail = null;  // 高位链表:新索引 = 原索引 + oldCap
            Node<K,V> next;
            // 遍历原链表,逐节点拆分
            do {
                next = e.next;  // 1. 保存当前节点的next,防止断链后无法继续遍历
                // 2. 高低位判断:(hash & oldCap) == 0 ?
                //    oldCap是2的幂(如16=0b10000),此操作等价于判断hash的第5位是否为0
                //    - 为0:新索引 = 原索引(低位)
                //    - 非0:新索引 = 原索引 + oldCap(高位)
                if ((e.hash & oldCap) == 0) {
                    // ===== 低位链表插入(尾插法)=====
                    if (loTail == null)       // 首次插入:设置头节点
                        loHead = e;
                    else                      // 非首次:尾节点的next指向当前节点
                        loTail.next = e;
                    loTail = e;               // 更新尾指针为当前节点
                } else {
                    // ===== 高位链表插入(尾插法)=====
                    if (hiTail == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                }
            } while ((e = next) != null);    // 移动到下一个节点,继续遍历
            // 3. 断尾操作:必须将两条链表的尾节点next置为null!
            //    原因:遍历时未断开原链表引用,若不置null可能形成跨桶环形引用
            if (loTail != null) {
                loTail.next = null;          // 低位链表尾部断开
                newTab[j] = loHead;          // 放入新数组的原索引位置
            }
            if (hiTail != null) {
                hiTail.next = null;          // 高位链表尾部断开
                newTab[j + oldCap] = hiHead; // 放入新数组的原索引+oldCap位置
            }
        }
    }
}

红黑树迁移

/**
 * 红黑树拆分方法(定义在 TreeNode 内部类中)
 * 
 * @param map   当前 HashMap 实例
 * @param tab   扩容后的新数组
 * @param index 原桶在旧数组中的索引
 * @param bit   旧容量 oldCap(2的幂,如16)
 */
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    // 1. 获取当前桶的树根节点(同时也是双向链表头节点)
    TreeNode<K,V> b = this;
    // 2. 初始化两条新链表的头尾指针(注意:此时仍是TreeNode,非普通Node)
    TreeNode<K,V> loHead = null, loTail = null;  // 低位链表
    TreeNode<K,V> hiHead = null, hiTail = null;  // 高位链表
    // 3. 计数器:记录高低位链表的节点数量,用于后续退化判断
    int lc = 0, hc = 0;
    // ===== 阶段1:按双向链表顺序遍历拆分 =====
    // 注意:这里遍历的是双向链表(通过next指针),不是红黑树结构!
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        // 保存当前节点的next指针(双向链表的next,非树结构的right)
        next = (TreeNode<K,V>)e.next;
        // 【关键】断开原链表引用!
        // 原因:防止残留引用导致内存泄漏,也为后续重建树做准备
        e.next = null;
        // 高低位判断(与链表迁移逻辑完全相同)
        if ((e.hash & bit) == 0) {
            // 低位链表插入(维护双向链表的prev指针)
            // e.prev = loTail:设置当前节点的prev指向原尾节点
            // 若loTail为null(首次插入),则e.prev = null
            if ((e.prev = loTail) == null)
                loHead = e;          // 首次插入:设置头节点
            else
                loTail.next = e;     // 非首次:原尾节点的next指向当前节点
            loTail = e;              // 更新尾指针
            ++lc;                    // 低位计数+1
        } else {
            // 高位链表插入(逻辑同上)
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;                    // 高位计数+1
        }
    }
    // ===== 阶段2:低位链表处理 =====
    if (loHead != null) {
        // 退化判断:节点数 ≤ 6 时退化为普通链表
        // UNTREEIFY_THRESHOLD = 6(定义在HashMap类中)
        if (lc <= UNTREEIFY_THRESHOLD) {
            // untreeify():将TreeNode链表转换为普通Node链表
            // 过程:遍历每个TreeNode,创建对应的Node对象,丢弃树结构指针
            tab[index] = loHead.untreeify(map);
        } else {
            // 节点数 > 6:保留为TreeNode,准备重建红黑树
            tab[index] = loHead;
            // 优化:仅当高位链表非空时才重建树
            // 原因:若高位为空,说明所有节点都在低位,树结构未被破坏,无需重建
            if (hiHead != null) {
                // treeify():将TreeNode链表重建为红黑树
                // 过程:按链表顺序逐个插入,每次插入后进行平衡调整
                ((TreeNode<K,V>)loHead).treeify(tab);
            }
        }
    }
    // ===== 阶段3:高位链表处理(逻辑同低位)=====
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD) {
            tab[index + bit] = hiHead.untreeify(map);
        } else {
            tab[index + bit] = hiHead;
            if (loHead != null) {
                ((TreeNode<K,V>)hiHead).treeify(tab);
            }
        }
    }
}

四、总结

会发现Java众多数据结构中的一个HashMap就已经有很多优化了,通过一次次版本的迭代,优化成了现在的样子!

到此这篇关于Java HashMap扩容机制的文章就介绍到这了,更多相关Java HashMap扩容机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文