HashMap底层原理与线程安全问题剖析
HashMap是Java开发的核心数据结构,但很多人只知其然不知其所以然。本文用ASCII图示详解HashMap的底层存储结构、哈希冲突处理、扩容机制,深入分析为什么不是线程安全的,以及会引发什么问题。
前言
HashMap 面试必问,但很多同学答得磕磕巴巴。
什么数组 + 链表 + 红黑树、什么负载因子 0.75、什么线程不安全,这些概念背得很熟,但底层到底怎么实现的,为什么这么设计,说不清楚。
今天我们用图示把 HashMap 给拆解透彻。
HashMap 基本结构
整体架构
HashMap 的核心就是一个 Node 数组,每个数组元素可能是:
HashMap内部结构:
table数组:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ <- 数组索引
├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│null │Node │null │Node │List │null │Tree │null │ <- 存储内容
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
│ │ │ │
▼ ▼ ▼ ▼
单个节点 单个节点 链表 红黑树
情况1 - 单个节点:
┌──────────────┐
│ key: "name" │
│ value: "米虫" │
│ hash: 12345 │
│ next: null │
└──────────────┘
情况2 - 链表(哈希冲突):
┌────────────┐ ┌────────────┐ ┌────────────┐
│key: "age" │───▶│key: "city" │───▶│key: "job" │
│value: 18 │ │value:"上海" │ │value:"码农" │
│hash: 998 │ │hash: 1892 │ │hash: 3721 │
│next: ──────┼────┼────────────┤ │next: null │
└────────────┘ └────────────┘ └────────────┘
哈希函数设计
哈希过程:
1. 计算key的hashCode:
"mebugs".hashCode() = 123456789
2. HashMap的hash函数处理:
原始hash: 123456789
高16位与低16位异或
3. 计算数组索引:
hash & (length-1) = hash & 15 = 9
所以存储在 table[9] 位置
核心操作原理
put 操作流程
put("key", "value") 执行流程:
1. 计算hash值和数组索引
2. 检查table[index]位置
- 位置为空:直接插入
- key相同:替换value
- key不同:链表尾部插入
3. 检查是否需要树化
4. 检查是否需要扩容
扩容机制
扩容的关键:容量翻倍后,元素要么在原位置,要么在"原位置+oldCap"位置
判断方法:hash & oldCap
- 结果为0:位置不变
- 结果非0:新位置 = 原位置 + oldCap
链表会被拆分成两个链表:
- 低位链表:留在原位置
- 高位链表:移动到新位置
线程安全问题
问题根源
HashMap 不是线程安全的,根本原因是操作不是原子的:
// put操作的关键步骤
if (table[i] == null) // 步骤1:检查
table[i] = newNode(...); // 步骤2:设置
多线程执行时的时序问题:
时间轴分析:
线程T1 线程T2
────────────────────────────────────────
hash("key1") → index=5
检查 table[5] == null ✓
hash("key2") → index=5
检查 table[5] == null ✓
table[5] = Node2
table[5] = Node1
结果:Node2被覆盖,数据丢失!
主要问题类型
1. 数据丢失
put操作覆盖问题:
初始状态:table[0] = null
T1: put("a", 1) → 准备写入table[0]
T2: put("b", 2) → 也准备写入table[0]
结果:只有一个数据存活,另一个丢失
2. 扩容时的问题
扩容过程中的数据可见性问题:
T1开始扩容:创建新数组,迁移数据...
T2执行操作:可能看到旧table、新table或null
导致:
- get返回null(明明刚put的)
- 数据不一致
- 数组越界异常
3. 无限循环(JDK 1.7)
JDK 1.7头插法导致的环形链表:
扩容前:A → B → null
多线程扩容可能形成:A → B → A (环形)
get操作遍历链表时死循环:
A.next → B → A.next → B ...
CPU 100%,程序卡死!
解决方案对比
┌─────────────────┬─────────────┬─────────────┬─────────────┐
│ 方案 │ 线程安全性 │ 性能 │ 复杂度 │
├─────────────────┼─────────────┼─────────────┼─────────────┤
│ HashMap │ ❌ │ ⭐⭐⭐ │ 简单 │
│ Hashtable │ ✅ │ ⭐ │ 简单 │
│ SynchronizedMap │ ⭐ │ ⭐ │ 简单 │
│ ConcurrentHashMap│ ✅ │ ⭐⭐ │ 复杂 │
│ 手动加锁 │ ✅ │ ⭐ │ 中等 │
└─────────────────┴─────────────┴─────────────┴─────────────┘
ConcurrentHashMap 的优势
HashMap vs ConcurrentHashMap 并发表现:
HashMap (不安全):
┌───────────────┐
│ 全局table │ ← 多线程操作同一数组,相互覆盖
│ [0][1][2][3] │
└───────────────┘
ConcurrentHashMap (JDK 1.8):
┌─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ ← 数组头节点分别加锁
│ │ 🔒 │ │ 🔒 │ 更细粒度的控制
└─────┴─────┴─────┴─────┘
小结
HashMap 的核心是数组 + 链表 + 红黑树的组合结构。
线程不安全的根本原因:
- put 操作不是原子的
- 扩容过程复杂且耗时
- 没有任何同步机制
主要问题:
- 数据丢失(最常见)
- 无限循环(JDK 1.7)
- 扩容时的各种异常
解决思路:
- 单线程场景继续用 HashMap
- 多线程场景选 ConcurrentHashMap
- 特殊需求才考虑手动加锁
掌握这些原理,HashMap 相关的面试题基本就没问题了。
