HashMap 作为 Java 集合框架中最核心、最常用的键值对存储容器,几乎是后端开发中无处不在 的工具类。无论是业务数据缓存、对象映射、快速查找,还是框架底层实现,HashMap 都承担着关键角色。同时,它也是 Java 面试中必问、必考、必深挖的知识点,从基础结构、哈希算法、冲突解决,到扩容机制、线程安全问题,再到 JDK 1.8 的红黑树优化,都是考察开发者基本功的核心内容。
本文将从基础定义、底层数据结构、核心源码流程、扩容机制、JDK 版本差异、高频面试题六个维度,带你彻底吃透 HashMap。
一、HashMap 基础认知
1.1 什么是 HashMap?
HashMap 是 Java 中基于哈希表 实现的键值对(key-value)存储集合,继承自 AbstractMap,实现了 Map 接口。它的核心设计目标是极高的存取效率 ,理想情况下,put(存)和 get(取)操作的时间复杂度都能达到 O(1)。
1.2 HashMap 的核心特性
- key 唯一,value 可重复 :key 依靠
hashCode()和equals()保证唯一性,value 可以重复存储。 - 允许 null 值:key 最多允许 1 个 null,value 允许多个 null。
- 无序性:存储顺序和插入顺序无关,且随着扩容,元素位置会发生变化。
- 非线程安全 :多线程环境下使用会出现数据覆盖、死循环等问题,并发场景推荐使用
ConcurrentHashMap。 - 底层动态扩容:通过扩容机制避免哈希冲突过多,保证存取性能。
二、HashMap 底层数据结构详解
HashMap 的底层结构,在 JDK 1.7 和 JDK 1.8 中发生了重大优化,这也是理解 HashMap 的关键。
2.1 JDK 1.7:数组 + 链表
JDK 1.7 的 HashMap 采用数组 + 单向链表的结构:
- 数组(哈希桶):是 HashMap 的主体,用于快速定位元素,每个下标位置称为一个 "桶"。
- 链表 :用于解决哈希冲突,当多个 key 计算出相同数组下标时,以链表形式挂载在对应下标下。
缺陷:当哈希冲突严重时,链表会变得很长,查找效率会从 O (1) 退化到 O (n),严重影响性能。
2.2 JDK 1.8:数组 + 链表 + 红黑树
JDK 1.8 对底层结构做了里程碑式优化 ,引入红黑树:
- 当链表长度达到 8 ,且数组长度达到 64 时,链表会树化转为红黑树,将查找效率从 O (n) 提升到 O (log n)。
- 当红黑树节点数量减少到 6 时,红黑树会退化为链表,节省内存空间。
最终结构:数组是主干,链表处理普通冲突,红黑树处理极端冲突。
三、HashMap 核心参数与默认值
HashMap 的性能和行为,由以下几个核心参数控制:
| 参数 | 含义 | 默认值 | 作用 |
|---|---|---|---|
| 初始容量 | 数组的初始长度 | 16 | 必须是 2 的 n 次方,保证哈希下标计算均匀 |
| 加载因子 | 数组的填充阈值 | 0.75 | 平衡空间占用和哈希冲突概率的核心参数 |
| 扩容阈值 | 触发扩容的元素数量 | 容量 × 加载因子 | 元素数量超过阈值则自动扩容 |
| 树化阈值 | 链表转红黑树的长度 | 8 | 链表长度≥8 且数组长度≥64 时树化 |
| 退化阈值 | 红黑树退链表的节点数 | 6 | 避免频繁在链表和红黑树之间切换 |
为什么加载因子默认是 0.75?
- 加载因子太小:会导致频繁扩容,浪费内存空间。
- 加载因子太大 :会导致哈希冲突急剧增加,链表变长,查询变慢。0.75 是官方经过大量测试得出的空间与效率的最佳平衡点。
为什么数组长度必须是 2 的 n 次方?
- 下标计算高效 :
hash & (length - 1)等价于取模运算,但位运算比取模快得多。 - 哈希分布均匀:2 的 n 次方的二进制只有一位是 1,减 1 后低位全是 1,能让哈希值均匀分布在数组下标中,减少冲突。
- 扩容优化:JDK 1.8 扩容时,只需判断哈希值的高位,就能快速确定元素在新数组中的位置,无需重新全量计算。
四、HashMap 核心流程:put 方法源码级解析
put 方法是 HashMap 最核心的逻辑,涵盖哈希计算、下标定位、冲突处理、树化、扩容全流程。
4.1 第一步:计算哈希值
HashMap 不会直接使用 key.hashCode(),而是做了扰动处理:
java
static final int hash(Object key) {
int h;
// key 为 null 时哈希值为 0
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 高 16 位与低 16 位异或 :让高位参与下标计算,大幅降低哈希冲突概率。
4.2 第二步:计算数组下标
java
index = hash & (table.length - 1);
利用位运算快速定位数组位置。
4.3 第三步:执行插入逻辑
完整流程:
- 判断底层数组是否为空,为空则通过
resize()初始化数组。 - 根据哈希值计算数组下标,判断当前桶是否为空:
- 空:直接新建 Node 节点放入桶中。
- 不为空:发生哈希冲突,进入冲突处理。
- 冲突处理:
- 若头节点的 key 和待插入 key 完全一致:直接覆盖 value。
- 若当前桶是红黑树:执行红黑树的插入逻辑。
- 若当前桶是链表:遍历链表插入,若遍历过程中 key 重复则覆盖,插入后判断链表长度是否≥8,达到则树化。
- 插入完成后,判断元素数量是否超过扩容阈值,超过则执行扩容。
五、HashMap 核心流程:get 方法解析
get 方法是 put 方法的逆流程 ,核心是快速定位 + 精准查找:
- 根据 key 计算哈希值和数组下标,定位到对应桶。
- 先判断头节点:
- 哈希值相同,且 key 用
==或equals()判断相等:直接返回头节点的 value。
- 哈希值相同,且 key 用
- 若不是头节点:
- 是红黑树:执行红黑树查找,效率 O (log n)。
- 是链表:遍历链表查找,效率 O (n)。
- 查找不到则返回 null。
六、HashMap 扩容机制(resize)
扩容是 HashMap 保证性能的核心机制,也是面试高频考点。
6.1 什么时候扩容?
当元素数量 size > 扩容阈值 threshold 时触发。
6.2 扩容做了什么?
- 新容量 = 旧容量 × 2:始终保持 2 的 n 次方。
- 新阈值 = 新容量 × 加载因子。
- 重新分配元素:将旧数组中的元素重新计算下标,放入新数组。
6.3 JDK 1.8 扩容优化
JDK 1.8 扩容时,元素在新数组中的位置只有两种:
- 原下标位置
- 原下标 + 旧容量
原因:容量是 2 倍扩容,哈希值与新数组长度做与运算时,只多了一位高位判断:
- 高位是 0:下标不变。
- 高位是 1:下标 = 原下标 + 旧容量。
优势:无需重新计算哈希 ,效率极高,且链表不会倒置 ,避免了 JDK 1.7 中多线程扩容导致的死循环问题。
七、JDK 1.7 与 JDK 1.8 HashMap 全面对比
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 底层结构 | 数组 + 单向链表 | 数组 + 链表 + 红黑树 |
| 链表插入方式 | 头插法 | 尾插法 |
| 扩容后链表顺序 | 倒置 | 保持原顺序 |
| 哈希算法 | 多次扰动,复杂 | 一次异或扰动,简单高效 |
| 并发风险 | 扩容易出现死循环 | 不会死循环,但仍非线程安全 |
| 冲突严重时性能 | O(n) | O(log n) |
八、HashMap 高频面试题(含答案)
8.1 HashMap 为什么线程不安全?
- 多线程 put 时,会出现数据覆盖问题。
- JDK 1.7 扩容时采用头插法,会导致链表环形死链,引发 CPU 100%。
- 无锁机制,不保证内存可见性。
8.2 为什么链表长度达到 8 才转红黑树?
根据泊松分布 ,链表长度达到 8 的概率极低,几乎是极端场景。同时,红黑树的节点占用内存是链表节点的2 倍左右,只有在冲突极端严重时才使用,避免空间浪费。
8.3 HashMap、HashTable、ConcurrentHashMap 的区别?
- HashTable :线程安全(方法加
synchronized全锁),效率极低,已废弃。 - HashMap:非线程安全,效率高,适合单线程。
- ConcurrentHashMap:线程安全,JDK 1.7 分段锁,JDK 1.8 CAS + 轻量级锁,性能优秀,推荐并发场景使用。
8.4 如何让 HashMap 线程安全?
- 使用
Collections.synchronizedMap()包装。 - 直接使用
ConcurrentHashMap(推荐)。
九、总结
HashMap 是 Java 中设计极其精妙的集合类,核心逻辑可以浓缩为三句话:
- 底层结构:数组保证快速定位,链表处理普通冲突,红黑树解决极端冲突。
- 核心原理:通过哈希算法、位运算、扩容机制,实现 O (1) 级别的高效存取。
- 使用注意:非线程安全,高并发场景禁止使用,JDK 1.8 的优化大幅提升了极端场景下的性能。
吃透 HashMap,不仅能让你在日常开发中写出更高效的代码,也能轻松应对各类 Java 面试,是后端开发者必须掌握的底层核心知识。