HashMap 扩容机制:从源码细节到工程实践

HashMap 是 Java 后端开发中最常用的集合之一。它看起来只是 putget,但它背后包含了哈希算法、数组寻址、链表冲突处理、红黑树、扩容迁移、负载因子和并发风险等多个设计点。

如果只把 HashMap 当成语法工具,很多问题会被忽略:容量设置不合理会导致频繁扩容;对象 key 设计不当会导致数据"放进去却取不出来";并发场景误用 HashMap 可能造成数据丢失;大对象 Map 还可能带来明显的 GC 压力。

本文主要基于 JDK 8 的 HashMap 实现进行分析,同时在必要位置说明 JDK 7 与 JDK 8 的差异。

一、HashMap 解决的核心问题是什么

1.1 HashMap 的目标不是"有序",而是快速定位

HashMap 的核心目标是通过 key 快速找到 value。它不保证遍历顺序,也不适合依赖顺序语义的业务场景。如果业务需要保持插入顺序,应该考虑 LinkedHashMap;如果需要按 key 排序,应该考虑 TreeMap

HashMap 更适合这些场景:

  • 根据用户 ID 查找用户上下文;
  • 根据配置项名称查找配置值;
  • 在一次请求处理中缓存中间结果;
  • 在批处理任务中做 ID 到对象的映射;
  • 在本地内存中做小规模索引。

这些场景的共同点是:读写操作主要围绕 key 进行,业务不依赖元素顺序。

1.2 HashMap 的底层结构

JDK 8 中,HashMap 的核心结构可以概括为:

text 复制代码
数组 table + 链表 + 红黑树

数组负责快速定位桶,链表负责处理少量冲突,红黑树负责处理极端冲突场景。

简化后的结构如下:

java 复制代码
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

table 是一个 Node<K,V>[] 数组。每个数组位置通常称为一个 bucket,也就是桶。不同 key 经过 hash 计算后,会被映射到某个桶中。

二、put 和 get 的基本流程

2.1 put 流程

一次 put(key, value) 大致经历以下步骤:

  1. 计算 key 的 hash 值;
  2. 根据 hash 和数组长度计算桶下标;
  3. 如果桶为空,直接放入新节点;
  4. 如果桶不为空,判断是否是同一个 key;
  5. 如果 key 已存在,覆盖旧值;
  6. 如果 key 不存在,将节点追加到链表或插入红黑树;
  7. 如果桶内节点过多,尝试树化;
  8. 如果元素数量超过阈值,触发扩容。

可以用简化代码理解这个过程:

java 复制代码
Map<String, Integer> map = new HashMap<>();
map.put("order", 1);
map.put("user", 2);
map.put("order", 3);

System.out.println(map.get("order")); // 3,旧值被覆盖

HashMap 判断 key 是否相同,不是只看 hashCode(),还会继续比较 equals()。因此,自定义对象作为 key 时,equals()hashCode() 必须满足一致性约束。

2.2 get 流程

一次 get(key) 大致经历以下步骤:

  1. 计算 key 的 hash 值;
  2. 根据 hash 找到桶下标;
  3. 如果桶为空,返回 null
  4. 如果桶第一个节点匹配,直接返回;
  5. 如果桶是链表,逐个节点比较;
  6. 如果桶是红黑树,按树结构查找。

理想情况下,HashMap 的 get 接近 O(1)。但这个结论有前提:hash 分布足够均匀,冲突数量较少。如果大量 key 落入同一个桶,链表查找会退化为 O(n);JDK 8 引入红黑树后,极端冲突下可以将查找成本降低到接近 O(log n)

三、Hash 冲突到底是什么

3.1 冲突不是只由相同 hashCode 导致

在 HashMap 中,冲突指多个 key 被映射到同一个桶。造成冲突的原因有两类:

  1. 不同 key 的 hash 值相同;
  2. 不同 key 的 hash 值不同,但经过桶下标计算后落到同一个数组位置。

JDK 8 的桶下标计算可以简化为:

java 复制代码
int index = (table.length - 1) & hash;

如果数组长度是 16,那么参与下标计算的主要是 hash 的低 4 位。即使两个 key 的完整 hash 值不同,只要低位计算结果相同,也会落到同一个桶。

3.2 为什么需要 hash 扰动函数

JDK 8 中 HashMap 的扰动函数可以简化理解为:

java 复制代码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

它的目的不是重新发明一套 hash 算法,而是让 hashCode 的高 16 位也参与低位计算。因为 HashMap 通过 (n - 1) & hash 计算桶下标,而数组长度通常不大时,低位对结果影响更明显。如果某些 key 的 hashCode 低位分布不好,就容易产生冲突。

扰动函数可以缓解这类问题,但不能替代一个质量良好的 hashCode() 实现。

四、为什么 HashMap 的容量是 2 的幂

4.1 取模可以转为按位与

当数组长度是 2 的幂时,下面两个表达式等价:

text 复制代码
k % 2^n == k & (2^n - 1)

因此,当 HashMap 的容量为 16、32、64 这类 2 的幂时,可以用按位与计算桶下标:

java 复制代码
int index = hash & (capacity - 1);

相比普通取模,按位与更简单,也更适合 HashMap 的扩容迁移逻辑。不过,不建议脱离具体 JVM、CPU 和基准环境给出固定性能倍数。

4.2 容量为 2 的幂还能优化扩容迁移

JDK 8 中,HashMap 扩容时容量通常翻倍。假设旧容量是 oldCap,新容量是 2 * oldCap。某个节点扩容后只有两种去向:

  1. 仍然留在原下标;
  2. 移动到 原下标 + oldCap

判断依据是:

java 复制代码
if ((node.hash & oldCap) == 0) {
    // 留在原下标
} else {
    // 移动到原下标 + oldCap
}

这避免了对每个节点重新完整计算下标,也降低了扩容迁移成本。

4.3 如果容量不是 2 的幂会怎样

如果容量不是 2 的幂,hash & (length - 1) 就不再等价于取模,桶下标分布可能出现明显问题。HashMap 内部会把传入的初始容量调整为大于等于该值的最小 2 的幂。

例如:

java 复制代码
Map<String, String> map = new HashMap<>(10);

这并不意味着底层数组长度一定是 10。HashMap 会在初始化 table 时把容量调整到 16。

五、扩容、负载因子与容量规划

5.1 负载因子是什么

负载因子表示 HashMap 对空间利用率和冲突概率之间的权衡。默认负载因子是 0.75

扩容阈值大致可以理解为:

text 复制代码
threshold = capacity * loadFactor

当容量为 16,负载因子为 0.75 时,阈值是 12。也就是说,当元素数量超过 12 时,HashMap 会触发扩容。

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

负载因子越小,数组越稀疏,冲突概率越低,但空间浪费越多。负载因子越大,空间利用率越高,但冲突概率和查找成本会上升。

0.75 是 JDK 默认选择的折中:

负载因子 优点 缺点 适用场景
较小,例如 0.5 冲突更少 占用更多内存 对查询延迟更敏感,内存充足
默认 0.75 时间和空间折中 不是所有场景最优 大多数业务代码
较大,例如 1.0 空间利用率更高 冲突概率增加 内存敏感且数据规模可控

在多数 Java 后端业务中,默认负载因子已经足够。真正需要关注的是初始容量是否合理。

5.3 为什么要设置初始容量

如果明确知道 Map 大概要存多少元素,应该设置合适的初始容量,避免多次扩容。

例如,一个批处理任务需要临时缓存 10,000 个用户对象:

java 复制代码
int expectedSize = 10_000;
int initialCapacity = (int) (expectedSize / 0.75f) + 1;
Map<Long, User> userMap = new HashMap<>(initialCapacity);

这段代码的目的不是追求"微优化",而是减少扩容带来的数组分配、节点迁移和短时间内的 GC 压力。

在高吞吐接口、本地缓存预热、批量导入、报表计算等场景中,容量规划比事后分析扩容成本更可靠。

六、链表为什么会转红黑树

6.1 树化触发条件

JDK 8 中,HashMap 的树化涉及几个关键阈值:

常量 默认值 含义
TREEIFY_THRESHOLD 8 桶内节点数量达到该阈值后,可能树化
UNTREEIFY_THRESHOLD 6 扩容拆分树节点时,节点较少可能退化为链表
MIN_TREEIFY_CAPACITY 64 table 容量至少达到该值才会树化

需要注意:桶内节点达到 8 并不一定立刻变成红黑树。如果 table 容量小于 64,HashMap 会优先扩容,而不是树化。

6.2 为什么不一开始就使用红黑树

红黑树能改善极端冲突下的查找复杂度,但它不是免费的。

和链表节点相比,树节点需要维护更多指针和颜色信息,内存成本更高,插入和旋转逻辑也更复杂。在冲突节点很少时,链表的顺序查找成本并不高,使用红黑树反而可能增加维护成本。

因此,HashMap 的策略是:

  1. 冲突少时使用链表;
  2. 容量还小时优先扩容;
  3. 容量足够大且单桶冲突仍然严重时,再树化。

这是一种典型的工程权衡:先用简单结构解决常见情况,再用复杂结构兜底极端情况。

6.3 红黑树不是常态优化路径

正常业务中,如果 key 的 hashCode() 实现合理,HashMap 很少走到树化。树化更像是一种防御机制,用来避免极端 hash 冲突导致链表过长。

如果一个系统中 HashMap 频繁树化,应该优先怀疑:

  • key 的 hashCode() 分布是否太差;
  • 是否使用了大量相同或相似特征的复合 key;
  • 是否存在恶意输入导致 hash 碰撞;
  • 初始容量是否设置过小;
  • 是否把 HashMap 用成了不合适的数据结构。

七、对象作为 key 的常见坑

7.1 equals 与 hashCode 必须保持一致

自定义对象作为 key 时,必须同时正确实现 equals()hashCode()

错误示例:

java 复制代码
class UserKey {
    private final Long tenantId;
    private final Long userId;

    UserKey(Long tenantId, Long userId) {
        this.tenantId = tenantId;
        this.userId = userId;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof UserKey)) {
            return false;
        }
        UserKey other = (UserKey) obj;
        return Objects.equals(tenantId, other.tenantId)
                && Objects.equals(userId, other.userId);
    }

    // 如果没有重写 hashCode,HashMap 中的查找可能失败
}

正确做法:

java 复制代码
class UserKey {
    private final Long tenantId;
    private final Long userId;

    UserKey(Long tenantId, Long userId) {
        this.tenantId = tenantId;
        this.userId = userId;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof UserKey)) {
            return false;
        }
        UserKey other = (UserKey) obj;
        return Objects.equals(tenantId, other.tenantId)
                && Objects.equals(userId, other.userId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(tenantId, userId);
    }
}

工程建议:如果 key 是值对象,应尽量设计成不可变对象。Java 16 之后也可以用 record 简化这类代码:

java 复制代码
record UserKey(Long tenantId, Long userId) {
}

7.2 不要修改已经放入 HashMap 的 key

如果 key 放入 HashMap 后,参与 hashCode() 计算的字段发生变化,后续可能无法再通过这个 key 取到 value。

示例:

java 复制代码
class OrderKey {
    private String orderNo;

    OrderKey(String orderNo) {
        this.orderNo = orderNo;
    }

    void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof OrderKey)) {
            return false;
        }
        OrderKey other = (OrderKey) obj;
        return Objects.equals(orderNo, other.orderNo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderNo);
    }
}

Map<OrderKey, String> map = new HashMap<>();
OrderKey key = new OrderKey("A001");
map.put(key, "created");

key.setOrderNo("A002");

System.out.println(map.get(key)); // 可能返回 null

这类问题在线上并不容易定位,因为数据确实存在于 Map 中,但已经不在新 hash 对应的桶里。

7.3 null key 与 null value

HashMap 允许一个 null key,也允许多个 null value。null key 的 hash 按 0 处理,通常会落在 0 号桶。

但在工程代码中,不建议滥用 null key。它会降低代码表达力,也容易掩盖上游数据异常。对于业务字段缺失,通常应在入参校验、DTO 转换或领域模型层明确处理。

八、HashMap 的并发风险

8.1 HashMap 不是线程安全容器

HashMap 没有对 putresizeremove 等操作做并发控制。多个线程同时读写同一个 HashMap,可能出现:

  • 数据覆盖;
  • 更新丢失;
  • 读取到不一致状态;
  • 扩容期间结构异常;
  • 迭代时抛出 ConcurrentModificationException

ConcurrentModificationException 只能用于快速失败,并不能作为线程安全保证。

8.2 JDK 7 的扩容死链与 JDK 8 的差异

JDK 7 中,HashMap 扩容迁移链表节点时使用头插法,在并发 resize 场景下可能形成环形链表,导致 CPU 飙高甚至线程长时间卡住。

JDK 8 改变了扩容迁移方式,避免了 JDK 7 中典型的头插法死链问题。但这不意味着 JDK 8 的 HashMap 可以用于并发写。它仍然可能出现数据丢失、覆盖和读取不一致。

工程结论很简单:

只要存在并发写,就不要使用普通 HashMap 承载共享状态。

8.3 并发场景应该怎么选

场景 推荐选择 原因
单线程、方法内临时映射 HashMap 简单,性能足够
多线程共享读写 ConcurrentHashMap 针对并发访问设计
需要保持插入顺序 LinkedHashMap 维护双向链表
需要按 key 排序 TreeMap 基于红黑树,支持排序
只读固定映射 Map.of(...) 或不可变 Map 减少误修改风险

在 Spring 应用中,尤其要注意单例 Bean 的成员变量。如果把 HashMap 放在单例 Bean 中,并在多个请求线程中读写,就会引入并发风险。

错误示例:

java 复制代码
@Component
class LocalStateHolder {
    private final Map<Long, String> state = new HashMap<>();

    public void update(Long userId, String value) {
        state.put(userId, value);
    }
}

如果这是多线程共享状态,应改用 ConcurrentHashMap,或者重新设计状态边界,避免在应用实例内维护可变共享状态。

九、线上问题如何排查 HashMap 相关风险

9.1 CPU 异常时看线程栈

如果线上出现 CPU 飙高,可以先通过线程 dump 观察热点线程是否长期停留在 HashMap 的 getputresize 或相关业务代码中。

需要注意:看到 HashMap 出现在栈里,不代表问题一定是 HashMap 本身。更常见的原因是业务代码构造了过大的 Map、key 的 hash 分布差、或者并发使用方式错误。

9.2 GC 压力大时看容量和生命周期

HashMap 本身会持有数组和节点对象。如果在短时间内创建大量大 Map,或者频繁扩容,会增加对象分配和 GC 压力。

排查时可以关注:

  • 是否在循环中反复创建大 Map;
  • 是否可以设置初始容量;
  • Map 生命周期是否过长;
  • 是否存在本地缓存无限增长;
  • 是否缺少淘汰策略。

如果业务本质上是缓存,不应只用 HashMap 裸写。至少要考虑容量上限、过期策略、并发安全和监控指标。成熟场景可以使用 Caffeine 这类本地缓存组件,而不是自己用 HashMap 拼缓存。

9.3 代码审查时重点看四类问题

  1. Map 是否被多个线程共享写入
  2. 初始容量是否与数据规模匹配
  3. key 是否为可变对象
  4. equals 与 hashCode 是否成对实现

十、HashMap 与 Hashtable 的区别

10.1 null 支持不同

HashMap 允许一个 null key,也允许多个 null value。Hashtable 不允许 null key,也不允许 null value。

10.2 线程安全方式不同

Hashtable 的很多方法使用 synchronized 修饰,因此单个方法调用具备同步保护。但这种设计粒度较粗,在现代 Java 并发程序中通常不作为首选。

HashMap 不保证线程安全。如果需要并发读写,通常应使用 ConcurrentHashMap

10.3 为什么不推荐继续使用 Hashtable

Hashtable 是早期集合类。现代 Java 代码中,如果不需要线程安全,使用 HashMap;如果需要并发访问,使用 ConcurrentHashMap。除非维护历史代码,一般不建议新代码继续使用 Hashtable。

十一、String.hashCode 为什么常提到 31

String.hashCode() 的计算方式常被简化为:

java 复制代码
s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-1]

选择 31 有几个常见原因:

  1. 31 是奇素数,有助于减少某些分布模式下的冲突;
  2. 31 参与乘法时可以被 JVM 优化为 (i << 5) - i
  3. 这是 Java 早期设计中在散列效果和计算成本之间做出的选择。

不要把原因简单理解为"因为能做位移优化"。散列效果、计算成本、历史兼容性都影响了这个设计。

十二、总结:把 HashMap 当成工程组件,而不是语法工具

HashMap 的实现并不复杂,但它背后的设计权衡值得认真理解。

从实现角度看,HashMap 通过数组实现快速定位,通过链表处理普通冲突,通过红黑树兜底极端冲突,通过容量为 2 的幂优化索引计算和扩容迁移,通过负载因子在时间和空间之间折中。

从工程角度看,使用 HashMap 时更应该关注这些问题:

  1. 是否需要设置合理初始容量;
  2. key 的 equals()hashCode() 是否正确;
  3. key 是否不可变;
  4. 是否存在并发写;
  5. 是否把 HashMap 用成了无限增长的本地缓存;
  6. 是否应该选择 ConcurrentHashMapLinkedHashMapTreeMap 或缓存组件。

真正成熟的 HashMap 理解,不是记住"默认容量 16、负载因子 0.75、树化阈值 8",而是知道这些设计为什么存在、在哪些场景会失效,以及如何在业务代码中规避风险。

相关推荐
yueping21 小时前
如何用idea打开jar包
java
IT_Octopus1 小时前
从一个应用开发者的角度,搞懂大数据查询的完整链路
java·大数据·数据库
许彰午1 小时前
51-BpmnDesigner集成
java·低代码·架构
蜗牛互联网1 小时前
Claude放宽生命科学限制:代价是验证、分级和30天留存
java·人工智能·后端
泡海椒1 小时前
评分系统最佳实践:JQuick-Java实现权重、阈值动态配置评分
java·人工智能·python
三克的油1 小时前
java-学习1
java·开发语言·学习
mldong2 小时前
弃用 ZCode,转 DeepSeek Harness:我用 GitHub Actions 自建 Windows 打包的全实录
java·架构
Wang's Blog3 小时前
Java 接入Redis: Redis下载与源码编译安装
java·服务器·redis
农民工老王3 小时前
故障排查:反向代理转发 GET 请求时 JDK 自动追加 Content-Length:0 致后端 500
java·httpclient·content-length