知其然要知其所以然,探索每一个知识点背后的意义,你知道的越多,你不知道的越多,一起学习,一起进步,如果文章感觉对您有用的话,关注、收藏、点赞,有困惑的地方可以评论,我们一起探讨!
HashMap 深度解析
1. 概述
HashMap 是 Java 集合框架中基于哈希表的 Map 实现类,用于存储键值对(Key-Value)。
- 核心特性:
- 允许
null键和null值。 - 非线程安全(多线程环境下需使用
ConcurrentHashMap)。 - 插入和查询的时间复杂度接近 O(1)(理想情况下)。
- 允许
- 设计目标:高效存储和快速查找数据,通过哈希函数将键映射到存储位置。
2. 底层数据结构
2.1 整体结构
HashMap 的底层由 数组 + 链表/红黑树 组成:
- 数组(桶数组):默认初始长度为 16,每个数组元素称为一个 桶(Bucket)。
- 链表:解决哈希冲突时,同一桶内的元素以链表形式存储。
- 红黑树(Java 8 新增):当链表长度超过阈值(默认 8)时,链表转换为红黑树以提高查询效率(数据小于8个的时候链表的增删改的效果略优于红黑树)。
classDiagram
class HashMap {
<<Node[] table>> # 桶数组
}
class Node {
int hash
K key
V value
Node next
}
class TreeNode {
TreeNode parent
TreeNode left
TreeNode right
boolean red
}
HashMap --> Node : 链表节点
Node --> TreeNode : 转换为红黑树(当链表长度≥8且桶数≥64)
2.2 哈希函数
-
哈希值计算:
- 调用键的
hashCode()方法获取原始哈希值。 - 通过 扰动函数(异或高16位与低16位)减少哈希冲突概率。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } - 调用键的
-
桶索引计算:
index = (table.length - 1) & hash; // 等价于 hash % table.length
3. 解决哈希冲突
3.1 链地址法(Separate Chaining)
- 当多个键的哈希值映射到同一桶时,元素以链表形式存储。
- 链表插入方式:
- JDK 1.7:头插法(可能引发多线程扩容死循环)。
- JDK 1.8:尾插法(解决死循环问题)。
3.2 红黑树优化(Java 8)
- 触发条件:
- 当链表长度 ≥
TREEIFY_THRESHOLD(默认 8) 且 桶数组长度 ≥MIN_TREEIFY_CAPACITY(默认 64)时,链表转换为红黑树。 - 当红黑树节点数 ≤
UNTREEIFY_THRESHOLD(默认 6)时,红黑树退化为链表。
- 当链表长度 ≥
- 优势:将链表查询的 O(n) 时间复杂度优化为红黑树的 O(log n)。
4. 扩容机制
4.1 扩容条件
- 默认负载因子(Load Factor):0.75(平衡时间与空间效率)。
- 扩容阈值(Threshold):
容量 × 负载因子。- 当元素数量超过阈值时,触发扩容。
4.2 扩容流程
- 创建新数组:容量扩大为原来的 2 倍(保证长度始终为 2 的幂)。
- 数据迁移:
- 遍历旧数组,重新计算每个元素的桶索引。
- JDK 1.8 优化:根据哈希值高位判断元素在新数组中的位置,无需重新计算哈希值。
4.3 扩容示例
sequenceDiagram
participant User as 用户
participant HashMap as HashMap
participant OldTable as 旧数组
participant NewTable as 新数组
User->>HashMap: 添加元素
HashMap->>HashMap: 检查元素数量是否超过阈值
alt 超过阈值
HashMap->>NewTable: 创建新数组(容量×2)
HashMap->>OldTable: 遍历旧数组
loop 每个桶
OldTable->>HashMap: 处理链表/红黑树
HashMap->>NewTable: 迁移元素到新数组
end
HashMap->>HashMap: 更新引用为新数组
end
5. 线程安全性
- 非线程安全:多线程环境下,HashMap 可能导致以下问题:
- 数据覆盖:多个线程同时插入导致数据丢失。
- 死循环(JDK 1.7):头插法扩容时可能形成环形链表。
- 不一致状态:并发修改导致遍历时抛出
ConcurrentModificationException。
示例:JDK 1.7 头插法死循环
// 线程 A 和线程 B 同时触发扩容
void transfer(Entry[] newTable) {
for (Entry<K,V> e : table) {
while (e != null) {
Entry<K,V> next = e.next;
e.next = newTable[i]; // 头插法
newTable[i] = e;
e = next;
}
}
}
// 并发操作可能导致链表成环,后续查询时死循环。
6. 性能分析
| 操作 | 时间复杂度(平均) | 时间复杂度(最坏) |
|---|---|---|
| 插入(put) | O(1) | O(n) 或 O(log n) |
| 查询(get) | O(1) | O(n) 或 O(log n) |
| 删除(remove) | O(1) | O(n) 或 O(log n) |
- 最坏情况:所有键哈希冲突,退化为链表(O(n))或红黑树(O(log n))。
7. Java 8 的改进
- 红黑树优化:解决长链表查询性能问题。
- 尾插法:避免多线程扩容死循环。
- 哈希计算优化:高位参与扰动,减少冲突概率。
- 扩容优化:无需重新计算哈希值,提升扩容效率。
8. 与 Hashtable、ConcurrentHashMap 的对比
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是(全局锁) | 是(分段锁/CAS + synchronized) |
| 允许 null | 键/值均可为 null | 不允许 | 不允许 |
| 性能 | 高(无锁) | 低(全局锁) | 高(细粒度锁) |
| 扩容机制 | 2 倍扩容 | 2 倍扩容 | 分段扩容 |
9. 使用建议
- 单线程环境:优先使用
HashMap(性能最优)。 - 高并发读:使用
ConcurrentHashMap。 - 避免频繁扩容:初始化时指定合适的容量和负载因子。
- 键对象设计:重写
hashCode()和equals()方法,确保哈希分布均匀。
总结
HashMap 是 Java 中高效、灵活的键值存储结构,通过 数组 + 链表/红黑树 的设计平衡了性能与空间消耗。理解其底层实现、哈希冲突解决、扩容机制及线程安全问题,有助于在实际开发中合理使用并优化性能。