“HashMap不是线程安全的”这句话你可能听过无数遍了,但是当别人真的让你讲清楚它在JDK1.7中是如何出现扩容死循环的?JDK1.8中它发生了什么变化?你回答的出来吗?本文将带你深入的看看它,彻底搞懂HashMap在多线程时,为什么是不安全的,会出现什么情况。
一、HashMap不是线程安全的
首先,我们需要明确的知道:无论哪个版本的HashMap,它都是线程不安全的。 在多线程环境中,会导致下面三大主要的问题:
- 死循环(JDK1.7及更早版本):会在扩容的时候导致CPU100%。
- 数据覆盖:两个线程同时put(),可能导致其中一个线程插入的数据被覆盖,从而出现数据丢失。
- size()不准:底层size变量的累加操作非原子性,导致并发更新后大小远小于实际大小。
二、为什么HashMap在多线程下会死循环?
在JDK1.7及更早的版本中,HashMap的扩容机制采用 “头插法” ,在多线程并发扩容的场景下,极容易发生线程调度和引用错乱,导致出现环形链表,之后的get()和put()操作陷入死循环,最终导致出现CPU100%的经典bug。
2.1 JDK1.7 HashMap扩容机制回顾
JDK1.7的HashMap采用数组+单向链表结构,每个数组元素是一个单向链表的头结点,每当发生哈希冲突的时候,新元素通过链表连接;扩容时,会创建一个容量为原数组2倍的新数组,遍历旧数组,对每个节点重新计算其在新数组的位置,使用头插法插入新链表(头插法会导致链表反转)。
JDK1.7与JDK1.8数据结构对比图:
JDK1.7 transfer()核心源码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; // ① 记录下一个节点
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // ② 头插法:新节点指向原头节点
newTable[i] = e; // ③ 新节点成为头节点
e = next; // ④ 继续处理下一个
}
}
}
问题根源:在①和③之间如果发生线程切换,另一个线程完成扩容后链表已反转,原线程恢复后持有的引用就会形成环。
2.2 两个线程如何形成一个环形链表
假设我们有两个线程(T1和T2),同时对一个HashMap进行put操作并触发扩容。初始状态,旧数据某个桶的链表是:A->B->null。
环形链表形成过程图:
详细步骤分析:
| 步骤 | T1线程状态 | T2线程状态 | 链表状态 |
|---|---|---|---|
| 1. 初始 | - | - | A→B→null |
| 2. T1执行transfer | e=A, next=B, 被挂起 | - | A→B→null |
| 3. T2完成扩容 | 挂起中 | 头插A,再头插B | B→A→null |
| 4. T1恢复 | 继续处理e=A | - | T1劈向新数组 |
| 5. T1插入A | A.next=B(老引用) | - | A→B, 但B.next=A! |
| 6. 环形成 | e=B, 插入后死循环 | - | A↔B 无限循环 |
关键理解点:
- T1挂起时保存的
next=B是指向旧链表中的B节点 - T2完成扩容后,同一个B节点的
next已经指向A - T1恢复后继续处理,将A插入新数组,此时A.next指向B
- 而B.next已经指向A,形成 A↔B 环形链表
2.3 数据覆盖情况复现
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class HashMapConcurrentDemo {
public static void main(String[] args) throws InterruptedException {
final Map<Integer, String> unsafeMap = new HashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(100);
// 100个线程同时向map中放入1000个键值对
for (int i = 0; i < 1000; i++) {
final int key = i;
executor.execute(() -> {
unsafeMap.put(key, "value-" + key);
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
// 理论大小应为1000,实际结果远小于1000
System.out.println("实际运行结果: " + unsafeMap.size());
}
}
运行结果如图所示:
2.4 JDK1.8的改进
JDK1.8对HashMap进行了大的优化:
- 数据结构:数组 + 单链表/红黑树(树化之后提升查询效率)
- 插入方式:将头插法改为尾插法,从而在扩容时保持了链表的原有顺序。
| JDK1.7 | JDK1.8 | |
|---|---|---|
| 数据结构 | 数组+链表 | 数组+链表/红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容死循环 | 会发生 | 不会 |
| 线程安全性 | 不安全 | 不安全 |
需要注意的是,尾插法确实解决了JDK1.7的死循环问题,但是并没有让HashMap变得安全! 数据覆盖和大小不准问题还是存在。
2.5 JDK1.8数据覆盖的源码分析
看一下JDK1.8 putVal()方法中可能导致数据覆盖的关键代码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// ...
if ((p = tab[i = (n - 1) & hash]) == null)
// 问题点:两个线程同时判断tab[i]为null,都会执行newNode
tab[i] = newNode(hash, key, value, null);
else {
// ...
}
// 问题点:size++不是原子操作
++modCount;
if (++size > threshold)
resize();
// ...
}
并发场景下的问题:
- 数据覆盖:线程A和B同时判断
tab[i]==null,都执行newNode(),后执行的会覆盖先执行的 - size不准:
++size不是原子操作,会丢失计数
三、多线程下的正确选择
线程安全Map方案对比图:
方案1:ConcurrentHashMap(推荐)
ConcurrentHashMap是JDK提供的线程安全的HashMap实现,是多线程环境下的首选。
ConcurrentHashMap的演进:
| 版本 | 实现机制 | 并发度 |
|---|---|---|
| JDK1.7 | 分段锁(Segment):将数据分成16个段,每段一把锁 | 16 |
| JDK1.8 | CAS + synchronized:锁粒度细化到单个Node | 数组长度 |
JDK1.8 ConcurrentHashMap的核心优化:
- 放弃Segment:不再使用分段锁,而是对每个桶头节点加synchronized锁
- CAS操作:使用CAS进行无锁化的桶初始化和元素插入
- 红黑树:与HashMap一样,链表长度≥8时转为红黑树
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
// 推荐:ConcurrentHashMap
final ConcurrentHashMap<Integer, String> safeMap = new ConcurrentHashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(100);
// 100个线程同时向map中放入1000个键值对
for (int i = 0; i < 1000; i++) {
final int key = i;
executor.execute(() -> safeMap.put(key, "value-" + key));
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
// 结果一定是1000
System.out.println("实际运行结果: " + safeMap.size());
}
}
运行结果:
方案2:Collections.synchronizedMap()(不推荐)
这个方案是使用一个全局的互斥锁包装HashMap,每次只允许一个线程读写这个HashMap,实现简单但性能极差,不推荐在高并发场景使用。
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
synchronizedMap的实现原理:
// Collections.SynchronizedMap内部实现
public V put(K key, V value) {
synchronized (mutex) { // 全局锁,所有操作串行化
return m.put(key, value);
}
}
方案对比
| 方案 | 线程安全 | 性能 | 适用场景 |
|---|---|---|---|
| HashMap | ❌ | ⭐⭐⭐⭐⭐ | 单线程 |
| ConcurrentHashMap | ✅ | ⭐⭐⭐⭐ | 多线程(推荐) |
| synchronizedMap | ✅ | ⭐⭐ | 低并发场景 |
| Hashtable | ✅ | ⭐⭐ | 已过时,不推荐 |
总结
核心结论
HashMap是线程不安全的,主要体现在三个方面:
| 问题 | JDK版本 | 原因 | 后果 |
|---|---|---|---|
| 扩容死循环 | JDK1.7 | 头插法导致链表反转 | CPU 100% |
| 数据覆盖 | 全版本 | 并发put无同步 | 数据丢失 |
| size不准 | 全版本 | ++size非原子操作 | 计数错误 |
JDK1.8改用尾插法解决了死循环问题,但数据覆盖和size不准的问题依然存在,因为HashMap内部实现无任何同步机制。