领先的免费Web技术教程,涵盖HTML到ASP.NET

网站首页 > 知识剖析 正文

深入拆解 Java HashMap:从数据插入到扩容的完整技术流程

nixiaole 2025-09-15 00:10:44 知识剖析 1 ℃

作为 Java 开发中最常用的集合类之一,HashMap 几乎贯穿了我们日常开发的方方面面 —— 从接口参数接收,到业务数据缓存,再到复杂业务逻辑中的临时数据存储,它都扮演着关键角色。但就是这个天天在用的工具,很多开发者对其底层原理的理解却停留在 “键值对存储” 的表层,尤其是数据插入时的哈希计算、冲突解决,以及触发扩容后的数组迁移过程,往往一知半解。

今天,我们就从源码角度出发,结合实际开发中的常见场景,一步步拆解 HashMap 的数据插入流程与扩容机制,帮你彻底搞懂 “我们存进去的数据,到底经历了什么”。

先搞懂基础:HashMap 的核心结构

在分析插入和扩容前,我们必须先明确 HashMap 的底层结构 ——数组 + 链表 / 红黑树的组合结构(JDK 1.8 及以后),这是理解后续流程的关键。

  • 数组(Node [] table):也叫 “哈希桶”,是 HashMap 的核心存储容器。数组中的每个元素是一个 Node 对象,Node 包含 key、value、next 指针(用于链接链表节点)和 hash 值。
  • 链表:当多个 key 通过哈希计算得到相同的数组索引时,会通过链表将这些 Node 连接起来,解决 “哈希冲突” 问题。
  • 红黑树:当链表长度超过阈值(默认 8),且数组长度大于等于 64 时,链表会转换为红黑树。这是因为链表查询时间复杂度为 O (n),而红黑树为 O (logn),能极大提升查询效率。

这里有个容易被忽略的细节:HashMap 的数组长度始终是 2 的幂次(如 16、32、64),这一设计并非偶然,而是为了后续哈希计算和扩容时的 “高效取模” 与 “数据迁移” 埋下伏笔,我们后面会详细解释。

数据插入流程:从 put () 方法开始的完整拆解

当我们调用hashMap.put(key, value)方法时,数据并非直接存入数组,而是经历了 “哈希计算→索引定位→冲突判断→数据存储” 四个核心步骤。我们以 JDK 1.8 的 HashMap 源码为例,逐行拆解关键逻辑。

第一步:计算 key 的哈希值(hash () 方法)

要将 key 存入数组,首先需要确定它在数组中的位置 —— 这就需要通过 key 的哈希值计算索引。但 HashMap 并没有直接使用 Object 类的hashCode()方法返回的哈希值,而是做了一次 “二次哈希” 处理:

static final int hash(Object key) {
    int h;
    // 1. 若key为null,哈希值为0;否则先获取key的hashCode()
    // 2. 将hashCode的高16位与低16位进行异或运算(h ^ (h >>> 16))
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要做 “高 16 位与低 16 位异或”?这是为了减少哈希冲突。因为 Object 的 hashCode () 返回的是 32 位整数,而 HashMap 的数组初始长度只有 16(默认),如果直接用 hashCode 取模,实际上只用到了低 4 位(16 是 2^4),高 28 位的信息被浪费,容易导致不同 key 的低 4 位相同,从而引发冲突。

通过 “高 16 位异或低 16 位”,可以将高 16 位的特征融入低 16 位,让哈希值的分布更均匀,间接降低冲突概率。

第二步:通过哈希值计算数组索引(定位哈希桶)

得到二次哈希后的 hash 值后,下一步就是计算该 key 对应的数组索引。源码中并没有使用我们熟悉的 “hash % 数组长度” 来取模,而是用了这样一行代码:

// n为当前数组长度(2的幂次),i即为最终的数组索引
int i = (n - 1) & hash;

为什么用 “(n-1) & hash” 代替 “hash % n”?原因有两个:

效率更高:位运算(&)的执行速度远快于取模运算(%);

结果等价:当 n 是 2 的幂次时,“hash % n” 与 “(n-1) & hash” 的结果完全相同。

举个例子:假设数组长度 n=16(2^4),n-1=15(二进制 1111)。若 hash 值为 25(二进制 11001),则 “25 & 15”=9(二进制 1001),而 “25%16” 也等于 9,结果完全一致。这也解释了为什么 HashMap 的数组长度必须是 2 的幂次 —— 为了让 “高效位运算” 与 “正确取模” 同时成立。

第三步:处理哈希冲突,存入数据

找到数组索引 i 后,需要根据索引位置的元素情况,分三种场景处理数据插入:

场景 1:索引位置为空(无哈希冲突)

如果table[i] == null,说明当前索引没有数据,直接创建一个新的 Node 对象存入即可:

table[i] = newNode(hash, key, value, null);

这是最简单的情况,无需处理冲突,直接完成插入。

场景 2:索引位置有数据,且 key 已存在(更新 value)

如果table[i]不为空,首先要判断当前索引处的 Node 是否与待插入的 key “相等”(即hash值相同,且key == 待插入key,或key.equals(待插入key))。

如果相等,说明是 “更新操作”—— 用新的 value 替换旧的 value,并返回旧 value:

Node<K,V> e; K k;
// 判断当前桶的第一个节点是否与待插入key相等
if (p.hash == hash && 
    ((k = p.key) == key || (key != null && key.equals(k)))) {
    e = p; // 记录旧节点
}
// 后续逻辑中,若e不为null,则执行value更新:e.value = value

这里需要注意:HashMap 判断 key 是否相同的标准是 “hash 值相等 + equals () 方法返回 true”,二者缺一不可。如果只重写了 equals () 而没重写 hashCode (),可能会出现 “key.equals () 为 true,但 hash 值不同” 的情况,导致数据无法正确更新,甚至出现重复存储。

场景 3:索引位置有数据,且 key 不存在(处理冲突)

如果当前索引处的 Node 与待插入 key 不相等,说明发生了 “哈希冲突”,需要根据当前节点的类型(链表节点或红黑树节点),分别处理:

情况 A:当前节点是链表节点(Node)

遍历链表,判断链表中是否存在与待插入 key 相等的节点:

源码核心逻辑如下:

for (int binCount = 0; ; ++binCount) {
    // 遍历到链表尾部,插入新节点
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        // 链表长度超过8,尝试转换为红黑树
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(table, hash);
        break;
    }
    // 链表中存在相同key,跳出循环执行更新
    if (e.hash == hash && 
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

若存在,执行 value 更新;

若不存在,在链表尾部插入新节点。插入后,判断链表长度是否超过阈值(默认 8),如果超过,且数组长度 >=64,则将链表转换为红黑树。

情况 B:当前节点是红黑树节点(TreeNode)

调用红黑树的putTreeVal()方法,在红黑树中插入或更新节点。红黑树会通过自平衡机制(旋转、变色)维持树的平衡,确保查询效率。

至此,HashMap 的数据插入流程就完成了。但这里还有一个关键问题:什么时候会触发扩容?扩容时数据又该如何迁移?

扩容机制:当 HashMap 装不下数据时会发生什么?

HashMap 有两个核心参数控制扩容:容量(capacity)负载因子(loadFactor)

  • 容量:即数组的长度,默认初始容量为 16,最大容量为 2^30。
  • 负载因子:默认值为 0.75,是 “扩容阈值” 与 “当前容量” 的比值。扩容阈值 = 容量 × 负载因子(如 16×0.75=12)。

当 HashMap 中的元素个数(size)超过扩容阈值时,就会触发扩容操作 ——将数组长度翻倍(变为原来的 2 倍),并将旧数组中的所有数据迁移到新数组中

为什么负载因子默认是 0.75?

这是一个 “时间复杂度” 与 “空间复杂度” 的平衡选择:

  • 若负载因子太小(如 0.5),则扩容阈值低,数组会频繁扩容,虽然哈希冲突少、查询快,但会浪费大量内存空间;
  • 若负载因子太大(如 1.0),则数组利用率高,但哈希冲突会剧增,链表 / 红黑树会变得过长,查询效率会大幅下降。

0.75 是 Java 开发团队经过大量测试后确定的最优值,能在 “内存占用” 和 “查询效率” 之间取得最佳平衡。

扩容的核心流程:数组翻倍 + 数据迁移

扩容的核心方法是resize(),整个过程分为两步:创建新数组迁移旧数据

第一步:创建新数组

根据当前数组长度(oldCap),计算新数组长度(newCap)和新的扩容阈值(newThr):

  • 若 oldCap 不为 0(非初始化扩容),则 newCap = oldCap × 2(翻倍),newThr = oldThr × 2;
  • 若 oldCap 为 0(初始化扩容,即第一次 put 数据时),则 newCap = 默认初始容量 16,newThr = 16×0.75=12;
  • 若 newCap 超过最大容量(2^30),则将 newThr 设为 Integer.MAX_VALUE,不再扩容。

然后,创建一个长度为 newCap 的新 Node 数组(newTab),将 HashMap 的 table 属性指向新数组。

第二步:迁移旧数据(最关键的一步)

将旧数组(oldTab)中的所有 Node,逐个迁移到新数组(newTab)中。这里的核心是:如何确定每个 Node 在新数组中的位置?

由于新数组长度是旧数组的 2 倍(newCap = oldCap × 2),且数组长度始终是 2 的幂次,所以旧数组中某个 Node 的 hash 值,在新数组中的索引有两种可能:

  1. 索引位置不变:若 hash 值的 “第(oldCap 的二进制位数)位” 为 0,则新索引 = 旧索引;
  2. 索引位置 = 旧索引 + oldCap:若 hash 值的 “第(oldCap 的二进制位数)位” 为 1,则新索引 = 旧索引 + oldCap。

举个例子:假设旧数组长度 oldCap=16(二进制 10000),某个 Node 的 hash 值低 5 位为 “01010”(对应旧索引 10)。当扩容到 newCap=32(二进制 100000)时,需要看 hash 值的第 5 位(从 0 开始计数):

  • 若第 5 位为 0(如 hash 低 5 位 “001010”),新索引仍为 10;
  • 若第 5 位为 1(如 hash 低 5 位 “101010”),新索引为 10 + 16 = 26。

这种迁移方式的巧妙之处在于:无需重新计算每个 Node 的 hash 值,只需判断 hash 值的某一位即可确定新索引,极大提升了迁移效率。

源码中对应的逻辑如下(简化后):

for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        oldTab[j] = null; // 释放旧数组引用,避免内存泄漏
        // 情况1:当前节点无后续节点(链表长度为1)
        if (e.next == null)
            newTab[e.hash & (newCap - 1)] = e;
        // 情况2:当前节点是红黑树节点
        else if (e instanceof TreeNode)
            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        // 情况3:当前节点是链表节点,拆分链表
        else {
            Node<K,V> loHead = null, loTail = null; // 新索引=旧索引的链表
            Node<K,V> hiHead = null, hiTail = null; // 新索引=旧索引+oldCap的链表
            Node<K,V> next;
            do {
                next = e.next;
                // 判断hash值的第(oldCap位数)位是否为0
                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;
            }
        }
    }
}

这里需要注意:迁移链表时,会将原链表拆分为两个子链表,分别存入新数组的两个索引位置,且保持原链表的顺序(JDK 1.8 之前是头插法,会导致链表逆序,甚至在并发场景下出现死循环,1.8 改为尾插法解决了这个问题)。

初始化扩容:第一次 put 数据时的特殊处理

如果 HashMap 是默认构造(无参构造),那么在创建对象时,数组(table)并不会立即初始化,而是在第一次调用 put () 方法时才触发初始化扩容 —— 此时会将数组长度设为默认的 16,扩容阈值设为 12,然后再执行数据插入流程。

这种 “延迟初始化” 的设计,是为了避免创建 HashMap 后未使用时的内存浪费。

开发中的避坑指南:基于原理的实践建议

理解了 HashMap 的插入和扩容机制后,我们可以结合这些原理,在实际开发中规避一些常见问题:

1. 避免使用可变对象作为 key

如果 key 是可变对象(如自定义类,且未重写 hashCode () 和 equals (),或对象属性可修改),可能会导致 “key 的 hash 值变化”—— 比如,将一个自定义对象作为 key 存入 HashMap 后,修改了对象的属性,导致 hashCode () 返回值改变。此时再通过原 key 查询,就无法找到对应的 value,因为 hash 计算出的索引已经变了。

建议:使用不可变对象作为 key,如 String、Integer 等包装类;若必须使用自定义对象,务必重写 hashCode () 和 equals (),且确保对象属性修改后,hashCode () 和 equals () 的结果不变(即 key 不可变)。

2. 初始化时指定合适的容量

如果已知 HashMap 要存储的数据量,建议在初始化时指定容量,避免频繁扩容。例如,要存储 1000 个数据,如何计算初始容量?

公式:初始容量 = 预计数据量 / 负载因子 + 1(向上取整)。

以默认负载因子 0.75 为例,1000 / 0.75 ≈ 1333.33,向上取整为 1334,所以初始容量设为 1334 即可。但由于 HashMap 的容量必须是 2 的幂次,实际会自动调整为大于 1334 的最小 2 的幂次(即 2048),这样就能确保存储 1000 个数据时,不会触发扩容。

3. 并发场景下避免使用 HashMap

HashMap 不是线程安全的,在并发场景下可能出现问题:

  • JDK 1.7 及之前:并发扩容时,链表可能出现环,导致死循环;
  • JDK 1.8 及之后:虽然解决了死循环问题,但仍可能出现数据覆盖、查询不到数据等问题。

建议:并发场景下使用 ConcurrentHashMap,它通过 “分段锁”(JDK 1.7)或 “CAS+ synchronized”(JDK 1.8)实现线程安全,同时保证了较高的并发效率。例如,在分布式项目的本地缓存场景中,用 ConcurrentHashMap 存储用户会话信息,既能避免线程安全问题,又能满足多线程下的快速查询需求。

4. 注意红黑树与链表的转换阈值

前文提到,当链表长度超过 8 且数组长度≥64 时,链表会转为红黑树;而当红黑树节点数少于 6 时,会重新转为链表。这两个阈值(8 和 6)的设计,是为了避免 “频繁转换”—— 如果阈值差距过小(如 7 和 6),可能导致链表与红黑树频繁切换,反而消耗更多性能。

开发中无需修改这些阈值(HashMap 源码中为静态常量,不可动态调整),但需注意:若数组长度不足 64,即使链表长度超过 8,也不会转为红黑树,而是会先触发扩容。例如,当数组长度为 32 时,若某链表长度达到 9,HashMap 会先将数组扩容到 64,再判断是否需要将链表转为红黑树。

实战案例:用代码验证 HashMap 的插入与扩容

为了让大家更直观地理解上述原理,我们通过一个简单的代码案例,观察 HashMap 的数据插入和扩容过程(基于 JDK 11):

import java.util.HashMap;

public class HashMapDemo {
    public static void main(String[] args) {
        // 初始化HashMap,指定初始容量为16(默认值),负载因子0.75
        HashMap<String, Integer> hashMap = new HashMap<>(16, 0.75f);
        
        // 1. 插入12个数据(达到扩容阈值16×0.75=12)
        for (int i = 1; i <= 12; i++) {
            hashMap.put("key" + i, i);
        }
        System.out.println("插入12个数据后,数组长度:" + getTableLength(hashMap)); // 输出16(未扩容)
        System.out.println("当前元素个数:" + hashMap.size()); // 输出12
        
        // 2. 插入第13个数据(超过扩容阈值,触发扩容)
        hashMap.put("key13", 13);
        System.out.println("插入13个数据后,数组长度:" + getTableLength(hashMap)); // 输出32(已扩容为原来的2倍)
        System.out.println("当前元素个数:" + hashMap.size()); // 输出13
        
        // 3. 验证哈希冲突场景(故意让key的hash值相同)
        // 自定义一个类,重写hashCode(),让所有对象返回相同的hash值
        class SameHashKey {
            private String value;
            public SameHashKey(String value) { this.value = value; }
            
            @Override
            public int hashCode() { return 1; } // 所有对象hash值相同
            
            @Override
            public boolean equals(Object obj) {
                if (this == obj) return true;
                if (obj == null || getClass() != obj.getClass()) return false;
                SameHashKey that = (SameHashKey) obj;
                return value.equals(that.value);
            }
        }
        
        // 插入8个SameHashKey对象(触发链表转红黑树的阈值)
        HashMap<SameHashKey, String> conflictMap = new HashMap<>(64); // 数组长度设为64,确保满足转树条件
        for (int i = 1; i <= 8; i++) {
            conflictMap.put(new SameHashKey("conflictKey" + i), "value" + i);
        }
        System.out.println("插入8个冲突key后,节点类型(链表/红黑树):" + getNodeType(conflictMap)); // 输出“链表”
        
        // 插入第9个SameHashKey对象(超过链表转红黑树阈值)
        conflictMap.put(new SameHashKey("conflictKey9"), "value9");
        System.out.println("插入9个冲突key后,节点类型(链表/红黑树):" + getNodeType(conflictMap)); // 输出“红黑树”
    }
    
    // 反射获取HashMap的数组长度(table.length)
    private static int getTableLength(HashMap<?, ?> hashMap) {
        try {
            java.lang.reflect.Field tableField = HashMap.class.getDeclaredField("table");
            tableField.setAccessible(true);
            Object[] table = (Object[]) tableField.get(hashMap);
            return table.length;
        } catch (Exception e) {
            e.printStackTrace();
            return -1;
        }
    }
    
    // 反射判断冲突节点的类型(链表Node/红黑树TreeNode)
    private static String getNodeType(HashMap<?, ?> hashMap) {
        try {
            java.lang.reflect.Field tableField = HashMap.class.getDeclaredField("table");
            tableField.setAccessible(true);
            Object[] table = (Object[]) tableField.get(hashMap);
            
            // 找到有数据的桶(此处已知冲突key的hash值为1,索引为1&(64-1)=1)
            Object node = table[1];
            if (node == null) return "空桶";
            
            // 判断节点类型
            if (node.getClass().getName().contains("TreeNode")) {
                return "红黑树";
            } else {
                return "链表";
            }
        } catch (Exception e) {
            e.printStackTrace();
            return "未知类型";
        }
    }
}

代码运行结果解析

  1. 插入 12 个数据时,未超过扩容阈值(16×0.75=12),数组长度保持 16;插入第 13 个数据时,触发扩容,数组长度变为 32,验证了 “扩容阈值触发扩容” 和 “扩容后数组长度翻倍” 的原理。
  2. 插入 8 个哈希冲突的 key 时,节点类型为链表;插入第 9 个时,转为红黑树,验证了 “链表转红黑树的阈值条件”(链表长度≥8 且数组长度≥64)。

总结:HashMap 核心原理的 3 个关键结论

结构设计:JDK 1.8 后采用 “数组 + 链表 / 红黑树” 结构,通过红黑树优化哈希冲突后的查询效率,数组长度始终为 2 的幂次,为高效哈希计算和扩容迁移提供基础。

插入逻辑:数据插入需经历 “二次哈希计算→位运算定位索引→冲突处理(链表 / 红黑树)”,其中 “二次哈希” 和 “位运算定位” 是减少冲突、提升效率的关键。

扩容机制:当元素个数超过 “容量 × 负载因子” 时触发扩容,数组长度翻倍,数据迁移通过 “判断 hash 值某一位” 实现高效定位,避免重新计算哈希值。

掌握这些原理,不仅能帮助我们在开发中规避 HashMap 的使用陷阱,还能在遇到性能瓶颈时,快速定位问题(如频繁扩容导致的性能损耗、哈希冲突过多导致的查询缓慢等),真正做到 “知其然,更知其所以然”。

如果在实际开发中遇到 HashMap 相关的特殊场景(如自定义 key 的哈希设计、大容量 HashMap 的性能优化等),可以结合本文的原理进一步分析,也欢迎在评论区分享你的问题和经验!

Tags:

最近发表
标签列表