作为 Java 后端开发者,HashMap 几乎是我们每天都在使用的集合类,也是面试中 100% 会被深挖的核心考点。很多人能背出 "数组 + 链表 + 红黑树",却讲不清为什么要这么设计;知道 JDK1.8 做了优化,却说不出具体优化了什么、解决了什么问题。
从 JDK1.7 到 JDK1.8,HashMap 经历了一次近乎重构的升级,背后藏着大量的性能优化思考和工程权衡:
- 为什么要从单纯的链表改成链表 + 红黑树?
- 头插法为什么改成了尾插法?
- 为什么数组长度必须是 2 的幂?加载因子为什么是 0.75?
- JDK1.7 的死循环问题是怎么产生的?1.8 真的完全解决了吗?
这篇文章,我们就从数据结构→核心原理→版本差异→面试深度问题→最佳实践五个维度,彻底搞懂 HashMap 的底层设计。

一、HashMap 核心认知
HashMap 是 Java 集合框架中基于哈希表实现的键值对存储结构,核心目标是实现 O (1) 平均时间复杂度的插入、查询和删除操作。
核心特性
- 键唯一:同一个 key 只会保留一份 value,新值会覆盖旧值
- 允许 null:支持 1 个 null 键和多个 null 值
- 线程不安全:多线程环境下会出现数据异常,并发场景应使用 ConcurrentHashMap
- 无序:不保证元素的存储顺序,也不保证顺序随时间不变
核心设计思想
HashMap 的本质是用空间换时间,通过哈希函数将 key 映射到数组下标,实现快速定位。当多个 key 映射到同一个下标时(哈希冲突),再用链表或红黑树存储冲突元素,在时间和空间之间找到最优平衡。
二、底层数据结构的演进
1. JDK 1.7:数组 + 单向链表
JDK1.7 及之前的 HashMap 底层只有数组 + 单向链表两种结构,数组是主体,链表用于解决哈希冲突。
- 数组 :名为
table,类型是Entry<K,V>[],每个元素是链表的头节点 - 链表:每个 Entry 节点包含 hash、key、value、next 四个属性
- 冲突解决 :采用链地址法,相同下标的元素串成一条链表
这种结构在数据量小、哈希分布均匀时性能很好,但一旦出现大量哈希冲突,链表会越变越长,查询时间复杂度会从 O (1) 退化为 O (n),极端情况下甚至会导致 CPU 飙升。
2. JDK 1.8:数组 + 链表 + 红黑树
JDK1.8 对 HashMap 做了重大重构,引入了红黑树作为兜底方案,解决了链表过长导致的性能退化问题。
- 数组 :名为
table,类型改为Node<K,V>[],功能与 1.7 一致 - 链表:当冲突元素较少时,仍然使用单向链表存储
- 红黑树:当冲突元素达到阈值时,链表转换为红黑树,查询复杂度从 O (n) 降为 O (logn)
树化与退化规则
| 行为 | 触发条件 | 说明 |
|---|---|---|
| 链表转红黑树 | 链表长度 ≥ 8 且 数组容量 ≥ 64 | 若数组容量小于 64,优先扩容而不是树化 |
| 红黑树转链表 | 树中节点数 ≤ 6 | 扩容拆分时也可能触发退化 |
为什么不全程使用红黑树?
红黑树虽然查询更快,但并非全场景最优:
- 内存开销大:红黑树节点需要维护父节点、子节点、颜色等信息,内存占用约是普通链表节点的 2 倍
- 插入成本高:插入和删除节点需要通过旋转、变色维持树的平衡,节点数量少时开销大于收益
- 概率极低:在加载因子 0.75 的情况下,链表长度达到 8 的概率仅约千万分之六,属于极端兜底方案
三、核心原理解析
1. 哈希计算与下标定位
HashMap 通过哈希函数将 key 转换为数组下标,这是整个哈希表的核心。
下标计算方式
数组下标 = hash(key) & (length - 1)
这行代码等价于hash(key) % length,但位运算的执行效率远高于取模运算。
为什么数组长度必须是 2 的幂?
这是 HashMap 最经典的设计之一:
- 当 length 是 2 的幂时,
length - 1的二进制表示全部是 1 - 此时
hash & (length - 1)的结果等价于取模,且能保证下标均匀分布在 0 到 length-1 之间 - 如果不是 2 的幂,二进制会出现 0 位,导致部分下标永远无法命中,哈希冲突概率大幅上升
扰动函数:让高位也参与运算
直接使用 key 的 hashCode 计算下标会有一个问题:如果 hashCode 的高位差异大、低位差异小,很容易出现哈希冲突。
因此 HashMap 加入了扰动函数,让高位也参与到下标计算中:
- JDK1.7:进行 4 次右移异或操作,充分混合高低位
- JDK1.8 :简化为一次
hash ^ (hash >>> 16),高 16 位与低 16 位异或,在性能和散列效果之间做了权衡
2. put 方法完整执行流程(JDK1.8)
这是面试最常考的流程,一共分为 8 个核心步骤:
- 数组初始化 :如果数组为空,调用
resize()初始化数组,默认容量 16 - 计算下标 :对 key 做哈希扰动,通过
hash & (n - 1)计算数组下标 - 空位直接插入:如果下标位置没有元素,直接创建新 Node 放入数组
- key 相同则覆盖:如果位置上的元素 key 与当前 key 相同,直接覆盖 value
- 红黑树插入:如果位置上是红黑树节点,调用红黑树的插入方法
- 链表尾插:如果是链表,从头遍历链表;找到相同 key 则覆盖,否则插到链表尾部
- 检查是否树化 :插入后如果链表长度 ≥ 8,调用
treeifyBin()检查数组容量,小于 64 则扩容,否则转红黑树 - 检查是否扩容:元素数 size 自增,如果超过阈值(容量 × 加载因子),执行扩容

3. 扩容机制(resize)
当 HashMap 中的元素数量超过阈值时,会触发扩容,容量变为原来的 2 倍。
扩容触发条件
size > capacity * loadFactor
默认情况下,容量 16,加载因子 0.75,阈值就是 12,放入第 13 个元素时触发扩容。
JDK1.7 扩容逻辑
- 头插法迁移元素,新元素插到链表头部
- 每个元素都要重新计算 hash 和数组下标
- 迁移后链表顺序会反转
- 并发扩容时可能形成环形链表,导致死循环
JDK1.8 扩容优化
JDK1.8 对扩容做了重大优化,大幅提升了扩容效率:
- 尾插法迁移:保持原链表顺序,不会反转
- 无需重新计算 hash :通过
hash & oldCap判断元素位置- 结果为 0:元素留在原下标位置
- 结果为 1:元素移动到「原下标 + 旧容量」的位置
- 避免死循环:尾插法不会反转链表,从根源上解决了 1.7 的环形链表问题
4. get 方法执行流程
- 计算 key 的 hash 值和数组下标
- 数组位置的第一个元素直接匹配,命中则返回
- 如果是红黑树,调用树的查找方法
- 如果是链表,遍历链表逐个比对 key
- 找不到则返回 null
四、JDK1.7 与 JDK1.8 核心区别
| 对比维度 | JDK 1.7 | JDK 1.8 | 优化收益 |
|---|---|---|---|
| 数据结构 | 数组 + 单向链表 | 数组 + 链表 + 红黑树 | 极端冲突下查询从 O (n) 优化到 O (logn) |
| 插入方式 | 头插法 | 尾插法 | 避免扩容时链表反转,解决并发死循环 |
| 哈希扰动 | 4 次右移异共 9 次扰动 | 1 次高低位异或 | 简化计算,提升性能 |
| 扩容逻辑 | 全部重新计算 hash | 按高位判断位置,无需重算 hash | 大幅提升扩容速度 |
| 节点类名 | Entry | Node / TreeNode | 语义更清晰 |
| 初始化时机 | 构造方法创建空数组 | 第一次 put 时才初始化数组 | 懒加载,节省内存 |
| 并发问题 | 死循环 + 数据覆盖 | 数据覆盖、size 计数不准 | 修复了死循环,但仍非线程安全 |
五、面试高频深度问题
1. 为什么加载因子默认是 0.75?
加载因子是时间与空间成本的权衡结果:
- 加载因子过小(如 0.5):冲突概率低、查询快,但空间利用率低,扩容频繁
- 加载因子过大(如 1.0):空间利用率高,但冲突概率大幅上升,链表变长,查询变慢
- 0.75 的数学依据:基于泊松分布计算,当加载因子为 0.75 时,桶中元素长度超过 8 的概率仅约 0.00000006,属于极小概率事件,在空间和时间之间达到了最优平衡
2. 为什么树化阈值是 8,退化阈值是 6?
- 阈值设为 8:正常哈希分布下,链表长度达到 8 的概率极低,作为兜底防止恶意哈希攻击和极端分布
- 退化设为 6:8 和 6 之间留 2 个差值,是为了避免频繁在链表和树之间来回转换(抖动)。如果阈值都是 7,插入删除一个元素就可能反复树化、退化,造成性能损耗
3. JDK1.7 扩容死循环是怎么产生的?
死循环的根源是头插法 + 并发扩容:
- 两个线程同时触发扩容,都记录了当前节点和下一个节点
- 线程 2 先完成扩容,头插法导致链表顺序反转,B.next 指向了 A
- 线程 1 恢复执行,继续用头插法迁移,将 A 插到 B 前面,此时 A.next=B,B.next=A,形成环形链表
- 后续调用 get 遍历该位置时,会在环形链表中无限循环,CPU 占用率直接拉满
注意:JDK1.8 只是修复了死循环问题,但 HashMap 仍然不是线程安全的,并发场景下仍会出现数据覆盖、size 计数错误等问题,生产环境必须使用 ConcurrentHashMap。
4. 自定义对象作为 key 为什么必须重写 hashCode 和 equals?
HashMap 判断两个 key 是否相同的逻辑是:先比 hash 值,hash 相同再用 equals 比对。
- 如果只重写 equals 不重写 hashCode:两个逻辑相等的对象会算出不同的 hash,被存到不同位置,导致 key 重复
- 如果只重写 hashCode 不重写 equals:hash 相同但 equals 不同,会被当成不同的 key,查询时找不到对应的值
约定:两个对象 equals 相等,hashCode 必须相等;hashCode 相等,equals 不一定相等。
5. HashMap 的 key 可以为 null 吗?
可以,HashMap 允许一个 null 键 和多个 null 值。
- null 键的 hash 值固定为 0,始终存放在数组第 0 位
- 因为只有第 0 位存 null 键,所以最多只能有一个 null 键
六、常见坑点与最佳实践
常见坑点
- 不指定初始容量:默认容量 16,数据量大时会频繁扩容,严重影响性能
- 初始容量设置错误:直接把预期元素数设为容量,导致提前触发扩容
- 自定义 key 不重写 hashCode/equals:导致元素重复或查询不到
- 并发场景使用 HashMap:导致数据异常、死循环等问题
最佳实践
- 合理设置初始容量:建议初始容量 = 预期元素数 / 0.75 + 1,避免频繁扩容
- 自定义 key 必须同时重写 hashCode 和 equals,遵循相等约定
- 并发场景统一使用 ConcurrentHashMap,不要用 Collections.synchronizedMap
- 遍历使用 entrySet:比 keySet 二次取值性能更好
- 避免在循环中反复增删元素:减少扩容和树化的性能损耗
七、总结
HashMap 从 JDK1.7 到 1.8 的演进,本质上是不断在时间、空间、实现复杂度之间做工程权衡的过程:
- 引入红黑树,解决了极端冲突下的性能退化问题
- 改为尾插法,修复了并发扩容的死循环问题
- 优化扩容逻辑,通过高位判断大幅提升扩容效率
- 简化扰动函数,在散列效果和性能之间找到平衡点
理解 HashMap 的底层原理,不仅能帮你轻松通过面试,更能让你在实际开发中合理使用、避开坑点,写出性能更优的代码。