作为 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 | 扩容拆分时也可能触发退化 |
为什么不全程使用红黑树?
红黑树虽然查询更快,但并非全场景最优:
三、核心原理解析
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 个核心步骤:

3. 扩容机制(resize)
当 HashMap 中的元素数量超过阈值时,会触发扩容,容量变为原来的 2 倍。
扩容触发条件
size > capacity * loadFactor
默认情况下,容量 16,加载因子 0.75,阈值就是 12,放入第 13 个元素时触发扩容。
JDK1.7 扩容逻辑
- 头插法迁移元素,新元素插到链表头部
- 每个元素都要重新计算 hash 和数组下标
- 迁移后链表顺序会反转
- 并发扩容时可能形成环形链表,导致死循环
JDK1.8 扩容优化
JDK1.8 对扩容做了重大优化,大幅提升了扩容效率:
- 结果为 0:元素留在原下标位置
- 结果为 1:元素移动到「原下标 + 旧容量」的位置
4. get 方法执行流程
四、JDK1.7 与 JDK1.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 扩容死循环是怎么产生的?
死循环的根源是头插法 + 并发扩容:
注意: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 键
六、常见坑点与最佳实践
常见坑点
最佳实践
七、总结
HashMap 从 JDK1.7 到 1.8 的演进,本质上是不断在时间、空间、实现复杂度之间做工程权衡的过程:
- 引入红黑树,解决了极端冲突下的性能退化问题
- 改为尾插法,修复了并发扩容的死循环问题
- 优化扩容逻辑,通过高位判断大幅提升扩容效率
- 简化扰动函数,在散列效果和性能之间找到平衡点
理解 HashMap 的底层原理,不仅能帮你轻松通过面试,更能让你在实际开发中合理使用、避开坑点,写出性能更优的代码。

