常见集合篇(五)深入解析 HashMap:从原理到源码,全方位解读
常见集合篇(五):深入解析 HashMap,从原理到源码,全方位解读
- 常见集合篇(五):深入解析 HashMap,从原理到源码,全方位解读
- 引言
- 一、HashMap 的实现原理
- 1.1 数据结构基础
- 1.2 存储逻辑示例
- 二、HashMap 的 `put` 方法具体流程
- 2.1 JDK 8 源码分析
- 2.2 执行步骤拆解
- 三、HashMap 常见属性
- 四、HashMap 的扩容机制
- 4.1 触发条件
- 4.2 扩容过程
- 4.3 示例
- 五、HashMap 的寻址算法
- 5.1 哈希计算
- 5.2 桶定位
- 六、HashMap 在 1.7 下的多线程死循环问题
- 6.1 问题根源
- 6.2 示例场景
- 七、HashSet 与 HashMap 的区别
- 八、HashTable 与 HashMap 的区别
- 总结
常见集合篇(五):深入解析 HashMap,从原理到源码,全方位解读
引言
在 Java 编程领域,HashMap 是一种高频使用的数据结构,无论是日常开发中的键值对存储,还是面试中的核心考点,它都占据着重要地位。本文将围绕 HashMap 的实现原理、put
方法流程、常见属性、扩容机制、寻址算法等核心问题展开深度剖析,同时对比 HashSet、HashTable 的差异,帮助读者全面掌握这一关键数据结构。
一、HashMap 的实现原理
1.1 数据结构基础
HashMap 采用 “数组 + 链表 + 红黑树” 的复合结构:
-
数组:作为底层存储,每个数组元素称为一个“桶”(Bucket)。
-
链表:当多个键值对的哈希值映射到同一桶时,通过链表解决冲突。
-
红黑树:当链表长度超过阈值(默认
8
),链表会转为红黑树,提升查询效率(JDK 8 新增优化)。 -
冲突处理:由于不同的键可能具有相同的哈希值,这就会导致冲突。当发生冲突时,HashMap使用链表或红黑树等数据结构来存储具有相同哈希值的键值对。这些数据结构允许在冲突的位置上存储多个键值对,并通过比较键的equals()方法来区分它们
链表:在JDK 8之前,HashMap使用链表来解决冲突。当多个键值对被映射到同一个桶时,它们会形成一个链表。通过遍历链表来查找、插入或删除键值对。但是,当链表长度过长时,会影响HashMap的性能
红黑树:从JDK 8开始,当链表长度超过一个阈值(默认为8)时,链表会被自动转换为红黑树,以提高操作效率。红黑树的查找、插入和删除操作具有较低的时间复杂度,可以在平均情况下保持对数时间复杂度
1.2 存储逻辑示例
假设存储键值对 {“key1”, “value1”}
:
- 计算
key1
的哈希值,确定其在数组中的桶位置。 - 若桶为空,直接将键值对存入桶对应的链表头。
- 若桶已存在元素,遍历链表(或红黑树),对比键是否相等:
- 相等则覆盖值;
- 不相等则新增节点(若链表长度达标,转换为红黑树)。
二、HashMap 的 put
方法具体流程
2.1 JDK 8 源码分析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 初始化数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 定位桶,若桶空则新建节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 桶首节点匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 红黑树处理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5. 遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 找到重复键
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 覆盖值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
++modCount;
// 6. 检查扩容
if (++size > threshold)
resize();
return null;
}
2.2 执行步骤拆解
- 计算哈希:通过
hash(key)
对键进行哈希处理,减少哈希冲突。 - 初始化数组:若数组未初始化,调用
resize()
创建默认容量(16)的数组。 - 定位桶位置:通过
(n - 1) & hash
计算桶索引。 - 处理冲突:
- 桶为空:直接插入新节点。
- 桶非空:检查桶首节点是否匹配,匹配则覆盖值;若为红黑树,调用红黑树插入方法;否则遍历链表,插入新节点(若链表过长,转换为红黑树)。
- 检查扩容:若元素数量超过阈值,触发扩容。
三、HashMap 常见属性
属性名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
DEFAULT_INITIAL_CAPACITY | int | 16 | 默认初始容量,必须是 2 的幂次。 |
DEFAULT_LOAD_FACTOR | float | 0.75f | 默认负载因子,用于计算扩容阈值。 |
threshold | int | - | 扩容阈值,值为 容量 × 负载因子 。 |
loadFactor | float | - | 负载因子,控制扩容时机,影响空间和时间效率。 |
modCount | int | - | 记录集合结构修改次数,用于 ConcurrentModificationException 快速失败机制。 |
四、HashMap 的扩容机制
4.1 触发条件
当 size
(当前元素数量)超过 threshold
(扩容阈值,即 容量 × 负载因子
)时,触发扩容。
4.2 扩容过程
- 创建新数组:新数组容量为旧数组的 2 倍,例如旧容量 16,新容量 32。
- 迁移元素:遍历旧数组每个桶,重新计算元素在新数组的位置并复制。由于容量是 2 的幂次,元素新位置要么在原位置,要么在原位置 + 旧容量。
4.3 示例
假设初始容量为 16,负载因子 0.75,阈值为 12。当添加第 13 个元素时:
- 触发扩容,创建容量为 32 的新数组。
- 遍历旧数组,每个元素重新计算哈希:
- 若元素原桶索引为
i
,新索引可能是i
或i + 16
。 - 例如,旧数组中某元素哈希值与 15(16-1)按位与得 5,扩容后与 31(32-1)按位与,若高位变化,新索引为 5 + 16 = 21。
- 若元素原桶索引为
五、HashMap 的寻址算法
5.1 哈希计算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 目的:将哈希码的高 16 位与低 16 位异或,减少哈希冲突。
- 示例:若
key.hashCode()
为0b10101100
,右移 16 位后与原值异或,混合高低位信息。
5.2 桶定位
通过 (n - 1) & hash
计算桶索引,其中 n
是数组长度(且为 2 的幂次)。例如,数组长度 16(二进制 10000
),n - 1
为 15
(01111
),与哈希值按位与,确保结果在数组范围内,且分布均匀。
六、HashMap 在 1.7 下的多线程死循环问题
6.1 问题根源
JDK 1.7 中,HashMap 扩容采用头插法。多线程环境下,若线程 A 和线程 B 同时扩容,可能导致链表节点顺序混乱,形成循环链表。
6.2 示例场景
- 初始链表:节点
A
→B
,存储在旧数组桶中。 - 线程 A 扩容:复制
B
到新数组,再复制A
,新链表为A
→B
。 - 线程 B 同时扩容:同样复制节点,但因并发操作,链表可能变为
A
→B
→A
,形成循环。 - 查询触发死循环:遍历链表时,因循环结构导致无限循环。
七、HashSet 与 HashMap 的区别
对比维度 | HashSet | HashMap |
---|---|---|
存储内容 | 仅存储键,值为固定对象 PRESENT | 存储键值对 |
实现原理 | 基于 HashMap,复用 HashMap 的键存储逻辑 | 独立实现键值对存储 |
核心方法 | add() 、remove() 等键操作 | put() 、get() 等键值对操作 |
空值支持 | 允许存储一个 null 键 | 允许 null 键和 null 值(键唯一) |
示例:
HashSet<String> set = new HashSet<>();
set.add("test"); // 内部调用 HashMap 的 put(key, PRESENT)
HashMap<String, Integer> map = new HashMap<>();
map.put("key", 1); // 存储键值对
八、HashTable 与 HashMap 的区别
对比维度 | HashTable | HashMap |
---|---|---|
线程安全 | 方法加 synchronized ,线程安全 | 非线程安全 |
空值支持 | 键和值均不能为 null | 允许 null 键和 null 值(键唯一) |
性能 | 同步开销大,性能较低 | 无同步开销,性能更优 |
继承体系 | 继承 Dictionary 类 | 实现 Map 接口 |
扩容机制 | 扩容为原容量 2 倍 + 1 | 扩容为原容量 2 倍 |
示例:
HashTable<String, Integer> table = new HashTable<>();
// table.put(null, 1); // 编译报错,不允许 null 键
// table.get(null); // 编译报错,不允许 null 键
HashMap<String, Integer> map = new HashMap<>();
map.put(null, 1); // 允许 null 键
map.put("key", null); // 允许 null 值
总结
通过对 HashMap 实现原理、put
流程、属性、扩容机制、寻址算法的深入分析,以及与 HashSet、HashTable 的对比,我们全面掌握了这一数据结构的核心要点。在实际开发中,需根据场景选择合适的集合:单线程环境优先用 HashMap;需要线程安全时,可选择同步包装类 Collections.synchronizedMap
或 ConcurrentHashMap
;而 HashTable 因性能问题已逐渐被替代。理解这些细节,不仅能写出更高效的代码,也能在面试中从容应对相关问题。