HashMap 在多线程环境下可能引发哪些问题?
答案:
在多线程同时操作 HashMap 时,可能引发 死循环、数据丢失、脏数据读取 等问题。
根本原因:
HashMap 的设计是非线程安全的,多线程并发修改其结构(如扩容、插入、删除)会导致内部链表或红黑树结构损坏。
具体问题分析:
1. 死循环(JDK 1.7 及之前版本的经典问题)
- 触发场景: 多个线程同时触发扩容(
resize
)。 - 原因:
JDK 1.7 的扩容采用“头插法”迁移链表,多线程并发操作可能导致链表形成环状结构(循环链表)。
举例:
想象两个搬运工(线程)同时把书从一个旧书架搬到新书架,但搬运时不小心把书的顺序弄反了,结果某些书被循环引用,再也找不到正确的顺序了。
2. 数据丢失
- 触发场景: 多个线程同时插入新数据。
- 原因:
两个线程同时计算哈希并定位到同一个桶(数组位置),后插入的线程可能覆盖前一个线程写入的值。
举例:
两个人(线程)同时往同一个抽屉里放文件,后放的人直接把自己的文件盖在别人的文件上,导致别人的文件丢失。
3. 脏数据读取
- 触发场景: 一个线程正在扩容,另一个线程尝试读取数据。
- 原因:
扩容过程中链表可能被临时拆分成两部分,此时读取的数据可能不完整或为旧数据。
类比:
搬家时(扩容),你一边搬箱子一边查字典(读数据),可能查到的词条是已经搬走的旧箱子里的内容。
如何解决?
方案 1:使用线程安全的替代类
-
推荐方法:
ConcurrentHashMap
原理:- JDK 1.7 采用分段锁(每个段独立加锁,提高并发度)。
- JDK 1.8 改为基于
CAS
和synchronized
锁单个桶(更细粒度)。
举例:
把仓库分成多个小房间(分段),每个房间有独立的锁,搬运工可以同时操作不同房间,互不干扰。
-
其他方法(不推荐):
Hashtable
:全表锁,性能差(类似整个仓库只有一把锁,所有人排队操作)。Collections.synchronizedMap(new HashMap())
:包装类,同样全表锁。
方案 2:手动加锁(仅限特殊场景)
- 方法: 使用
synchronized
或ReentrantLock
包裹所有 HashMap 操作。
缺点: 完全串行化,性能极低,不如直接使用ConcurrentHashMap
。
举个栗子 🌰
问题复现(死循环):
// JDK 1.7 环境下运行以下代码
HashMap<Integer, Integer> map = new HashMap<>(2); // 容量2,阈值1.5
// 线程1和线程2同时执行 put 操作触发扩容
map.put(5, 5); // 哈希冲突可能导致链表成环
此时调用 map.get(5)
可能陷入死循环(CPU 100%)。
解决方案:
直接替换为 ConcurrentHashMap
:
ConcurrentHashMap<Integer, Integer> safeMap = new ConcurrentHashMap<>();
// 多线程操作安全
总结
问题 | 触发场景 | 解决方案 |
---|---|---|
死循环 | 多线程并发扩容(JDK1.7) | 使用 ConcurrentHashMap |
数据丢失 | 多线程同时插入同一位置 | 使用线程安全的容器 |
脏数据读取 | 扩容与读操作并发 | 避免并发读写非线程安全容器 |
关键点:
- JDK 1.8 的 HashMap 仍非线程安全(尾插法解决死循环,但其他问题仍存在)。
- 永远不要在多线程中直接使用 HashMap,优先选择
ConcurrentHashMap
。