当前位置: 首页 > news >正文

分析下HashMap容量和负载系数,它是怎么扩容的?

很好,我们继续深入分析 HashMap容量(capacity)负载因子(load factor),以及它是如何进行 扩容(resize) 的。


🧱 一、容量(capacity)与负载因子(load factor)

🔹 容量(capacity)

  • 表示 哈希表的桶数量,也就是底层数组的长度。

  • 初始容量可以通过构造函数指定,如:

    new HashMap<>(16);
    
  • 容量始终为 2 的幂次方,如:16, 32, 64…

    这是为了简化定位桶的算法:

    index = (n - 1) & hash  // 高效替代 hash % n
    

🔸 负载因子(loadFactor)

  • 表示哈希表 允许的装载程度(桶元素的密度),默认是 0.75
  • 定义:
    threshold = capacity × loadFactor
    
  • 一旦实际元素数量 > threshold,就会触发扩容(resize)

✅ 为什么选 0.75?

  • 时间和空间效率的折中
  • 值太小 ⇒ 空间浪费大;
  • 值太大 ⇒ 容易哈希冲突,链表/红黑树加长,降低查询效率。

🔄 二、扩容机制(resize)

🛠️ 触发时机

  • size >= threshold(即元素数量超过阈值)时触发扩容。

🧩 扩容流程详解(JDK 8)

void resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = oldTab.length;
    int newCap = oldCap * 2;
    Node<K,V>[] newTab = new Node[newCap];
    
    // 重新分配每个 bucket 中的节点
    for (int i = 0; i < oldCap; ++i) {
        Node<K,V> e = oldTab[i];
        if (e != null) {
            oldTab[i] = null; // help GC
            if (e.next == null) {
                // 单个节点直接重新映射
                newTab[e.hash & (newCap - 1)] = e;
            } else if (e instanceof TreeNode) {
                // 红黑树重新分配
                ((TreeNode<K,V>)e).split(...);
            } else {
                // 链表节点再分成两个链表
                // 一个保持原索引 i,另一个转移到 i + oldCap
                Node<K,V> loHead = null, hiHead = null;
                // 遍历链表分类
            }
        }
    }
    table = newTab;
    threshold = newCap * loadFactor;
}

🚀 三、核心优化点:链表拆分

扩容后链表会分成两组:

  • 原位置不变:index = i
  • 新位置偏移 oldCap:index = i + oldCap

为什么这样可以避免重新计算 hash?

因为 newCap = oldCap * 2,我们只需要看新增的那一位是否为 1:

// e.hash & oldCap == 0 => 原桶位置
// e.hash & oldCap != 0 => 新桶位置(i + oldCap)

这是一种巧妙的 位运算拆桶优化,非常高效,避免重新计算 hash 和大量的内存分配。


📊 四、扩容代价分析

扩容代价是 O(n),因为:

  • 要遍历所有桶;
  • 将每个节点重新分配;
  • 链表 → 红黑树 也可能要转换。

因此,在大量插入操作中:
👉 尽量 提前设定合适的初始容量,避免频繁扩容!

// 例如插入 1 万个元素
Map<String, Object> map = new HashMap<>(16384, 0.75f);

✅ 总结一下

概念默认值说明
capacity16必须为 2 的幂次方
loadFactor0.75平衡查询效率与空间浪费
threshold16 × 0.75 = 12超过该值将触发扩容
resizeO(n)将所有元素重新分桶

我们写一个简单的 Java 示例来演示 HashMap 的 resize 过程,包括扩容前后容量的变化、触发阈值等。我们还会插入调试打印语句以观察内部行为。


🔧 示例代码:手动触发 HashMap 的扩容

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class HashMapResizeDemo {
    public static void main(String[] args) throws Exception {
        // 初始化 HashMap,容量为 4,负载因子为默认 0.75
        Map<Integer, String> map = new HashMap<>(4);

        // 获取内部 threshold 和 table 大小(通过反射)
        printCapacityAndThreshold(map, "初始");

        // 连续添加元素,观察扩容发生
        for (int i = 1; i <= 10; i++) {
            map.put(i, "Value" + i);
            System.out.println("插入第 " + i + " 个元素后:");
            printCapacityAndThreshold(map, "状态");
        }
    }

    // 反射查看 HashMap 的容量和阈值
    private static void printCapacityAndThreshold(Map<Integer, String> map, String tag) throws Exception {
        Class<?> hashMapClass = map.getClass();

        // 获取 threshold 字段
        Field thresholdField = hashMapClass.getDeclaredField("threshold");
        thresholdField.setAccessible(true);
        int threshold = (int) thresholdField.get(map);

        // 获取 table 数组长度(容量)
        Field tableField = hashMapClass.getDeclaredField("table");
        tableField.setAccessible(true);
        Object[] table = (Object[]) tableField.get(map);
        int capacity = (table == null) ? 0 : table.length;

        System.out.println("[" + tag + "] 当前容量: " + capacity + ", 阈值: " + threshold + ", size: " + map.size());
        System.out.println("-------------------------------------------------");
    }
}

🧪 示例运行结果(核心片段)

[初始] 当前容量: 0, 阈值: 0, size: 0
插入第 1 个元素后:
[状态] 当前容量: 16, 阈值: 12, size: 1
插入第 2 个元素后:
[状态] 当前容量: 16, 阈值: 12, size: 2
...
插入第 13 个元素后:
[状态] 当前容量: 32, 阈值: 24, size: 13

🔍 可以看到:

  • 初始创建的 HashMap 并没有立刻分配数组(懒加载);
  • 第一次插入时,底层数组变为默认的 16;
  • 插入超过 12 个元素时,容量从 16 → 32,说明触发了扩容;
  • 扩容后的 threshold 从 12 → 24。

✅ 小结

  • 这个例子展示了 HashMap 的 懒加载机制resize 过程
  • 使用反射可以观察 thresholdtable.length 等关键内部变量;
  • 你可以尝试不同初始容量和负载因子,看 HashMap 何时扩容。

我们用一个图示 + 示例代码来直观展示 HashMap 扩容过程中,链表是如何拆分并重新分布的。这个拆分逻辑是 JDK 8 里的一个优化点,非常重要。


🌐 一、拆分链表的背景

HashMap 发生扩容(数组长度从 N → 2N)时,桶中原有的链表节点会被拆分到新数组的两个位置:

  • 原索引 i
  • 新索引 i + oldCap

这是通过下面这个位运算实现的:

if ((e.hash & oldCap) == 0) {
    // 原地保留在 index i
} else {
    // 移动到 index i + oldCap
}

📊 二、可视化拆分流程

假设旧容量是 8(oldCap = 8),扩容后为 16:

旧索引(index = 3)处的链表:
  A(hash=3), B(hash=11), C(hash=19), D(hash=27)

哈希值(二进制):
  A = 0000_0011
  B = 0000_1011
  C = 0001_0011
  D = 0001_1011

  oldCap = 0000_1000

位运算: hash & oldCap
  A: 3  & 8  = 0  → 继续放在索引 3
  B: 11 & 8  = 8  → 放在新索引 3 + 8 = 11
  C: 19 & 8  = 0  → 索引 3
  D: 27 & 8  = 8  → 索引 11

结果如下:
新数组:
  index 3:  A → C
  index11:  B → D

📌 结论:

  • 拆分后每个链表最多分成 2 段;
  • 不需要重新计算 hash,只需根据一位是否为 1 判断目标桶;
  • 效率极高,是扩容过程的核心优化。

💡 三、Java 代码模拟链表拆分

public class HashMapSplitDemo {
    static class Node {
        int hash;
        String key;
        Node next;
        Node(int hash, String key, Node next) {
            this.hash = hash;
            this.key = key;
            this.next = next;
        }
    }

    public static void main(String[] args) {
        int oldCap = 8;
        Node head = new Node(3, "A",
                    new Node(11, "B",
                    new Node(19, "C",
                    new Node(27, "D", null))));

        Node loHead = null, loTail = null;
        Node hiHead = null, hiTail = null;

        for (Node e = head; e != null; e = 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;
            }
        }

        // 断尾
        if (loTail != null) loTail.next = null;
        if (hiTail != null) hiTail.next = null;

        System.out.print("原地链表: ");
        printList(loHead);
        System.out.print("迁移链表: ");
        printList(hiHead);
    }

    private static void printList(Node head) {
        Node p = head;
        while (p != null) {
            System.out.print(p.key + "(" + p.hash + ") → ");
            p = p.next;
        }
        System.out.println("null");
    }
}

🧪 输出示例:

原地链表: A(3) → C(19) → null
迁移链表: B(11) → D(27) → null

刚好和我们前面图示的拆分一致 ✅


✅ 总结:链表拆分的意义

优点描述
效率高只用一次位运算,无需重新计算 hash
减少冲突重新分布节点,有助于均衡哈希桶分布
内存复用节点对象不重建,仅更换位置

相关文章:

  • wordpress 上传中文文件天津seo排名效果好
  • 做网站使用独享服务器的优点如何查看百度指数
  • wordpress添加图标广州seo网站排名
  • 未来前景比较好的行业有哪些进一步优化
  • 网站建设超链接字体变色代码广告推广 精准引流
  • 珠海市城乡规划建设局网站/新闻稿
  • 底盘---全向轮(Omni Wheel)
  • 重温Java - Java基础二
  • 无人设备遥控器之通信链路管理篇
  • C++ 创建静态数组出现栈满程序崩溃的问题
  • 【虚拟机栈中的栈帧是什么?有什么作用?局部变量表、操作数栈、动态链接和方法返回地址是什么?有什么作用?为什么要放在栈帧里?】
  • Ubuntu24.04 编译 Qt 源码
  • 一个可以在Android手机上运行的Linux高仿window10的应用
  • Python中的AdaBoost分类器:集成方法与模型构建
  • VT01N/VT02N进行交货的时候,对装运点加权限控制的增强
  • 原生SSE实现AI智能问答+Vue3前端打字机流效果
  • 【语法】C++的list
  • 模糊测试究竟在干什么
  • 41、web前端开发之Vue3保姆教程(五 实战案例)
  • 结合大语言模型整理叙述并生成思维导图的思路
  • C语言--常用的链表操作
  • 分布式存储怎样提高服务器数据的安全性?
  • Vue3+Vite+TypeScript+Element Plus开发-09.登录成功跳转主页
  • CentOS8.5 LLaMA-Factory训练模型
  • ChatDBA:一个基于AI的智能数据库助手
  • 基于C8051F340单片机的精确定时1S的C程序