谈谈HashMap的线程安全问题与解决方案
谈谈HashMap的线程安全问题与解决方案
目录
一、为什么说HashMap是线程不安全的?
1.1 数据覆盖
1.2 size 字段不准确
1.3 脏数据
二、如何解决HashMap的线程安全问题?
2.1. 使用 ConcurrentHashMap(首选方案)
2.2 使用 Collections.synchronizedMap()
2.3 手动显式加锁
三、方案对比与选型建议
四、小小总结
一、为什么说HashMap是线程不安全的?
HashMap 是Java中最常用的数据结构之一,以其高效的查找和插入(平均O(1)时间复杂度)著称。然而,在多线程环境下,HashMap的线程不安全问题会导致数据不一致、程序崩溃甚至死循环。HashMap线程不安全大致有以下的原因:
1.1 数据覆盖
当多个线程同时调用 put() 方法插入键值对时,若发生哈希冲突(两个键被分配到同一个哈希桶),可能导致数据覆盖。
场景示例: 线程A和线程B同时发现某个哈希桶为空(table[i] == null),并各自插入新节点。后插入的节点会覆盖前一个节点的数据。
底层原因:插入操作非原子性(检查桶状态 → 创建节点 → 插入链表/红黑树)。
代码示例:
public class HashMapTest {
public static void main(String[] args) throws InterruptedException {
HashMap<Integer,Integer> hashMap = new HashMap<>();
ExecutorService executorService= Executors.newFixedThreadPool(10);
//启动10个线程,每个线程都往hashMap中添加1000个元素
for (int i = 0; i < 10; i++) {
final int threadId=i;
executorService.execute(()->{
for (int j = 0; j < 1000; j++) {
//这里保证了key是不会重复的,这样保证了覆盖不是因为key名字相同而导致的覆盖
int key=threadId*1000+j;
hashMap.put(key,key);
}
});
}
executorService.shutdown();
while (!executorService.isTerminated()){
Thread.sleep(100);
}
//预期
System.out.println("预期size:"+10*1000);
//实际
System.out.println("实际的hashMap size:"+hashMap.size());
}
}
运行结果:
可以看到每一次的运行出现数据覆盖的情况还不一样;
1.2 size 字段不准确
HashMap 的size 字段记录元素数量,但多线程下size++ 和 size-- 是非原子操作。
场景: 1 线程B读取size=10,2 线程A读取 size=10,3 线程B同插入数据并更新 size=11,4 线程A写入 size=11,覆盖了B的更新(这些是高并发环境下的执行顺序)。 结果:size 与实际元素数量不一致(实际应该是12)。
1.3 脏数据
一个线程正在修改哈希表结构(如扩容、树化),另一线程尝试读取数据时,可能读到中间状态。
示例: 线程A正在迁移哈希桶,线程B读取到未完全迁移的旧桶,导致获取到 null 或部分数据。
根本原因:缺乏内存可见性保证(未使用volatile或锁)。
二、如何解决HashMap的线程安全问题?
2.1. 使用 ConcurrentHashMap(首选方案)
ConcurrentHashMap是专为高并发设计的线程安全哈希表,性能接近HashMap,且无需手动同步。
实现原理: JDK 1.7:分段锁(Segment),每个段独立加锁,降低锁竞争。 JDK 1.8+:CAS + synchronized 锁单个哈希桶(Node),锁粒度更细。
代码例子:
public class HashMapTest {
public static void main(String[] args) throws InterruptedException {
//这里换成了线程安全的ConcurrentHashMap
Map<Integer,Integer> hashMap = new ConcurrentHashMap<>();
ExecutorService executorService= Executors.newFixedThreadPool(10);
//启动10个线程,每个线程都往hashMap中添加1000个元素
for (int i = 0; i < 10; i++) {
final int threadId=i;
executorService.execute(()->{
for (int j = 0; j < 1000; j++) {
//这里保证了key是不会重复的,这样保证了覆盖不是因为key名字相同而导致的覆盖
int key=threadId*1000+j;
hashMap.put(key,key);
}
});
}
executorService.shutdown();
while (!executorService.isTerminated()){
Thread.sleep(100);
}
//预期
System.out.println("预期size:"+10*1000);
//实际
System.out.println("实际的hashMap size:"+hashMap.size());
}
}
结果:
可以看到使用了ConcurrentHashMap类之后就不会出现值被覆盖的问题了,所以最后的数量跟预期的数量一致;
优点:高并发性能优异; 支持高吞吐量的读写操作;
2.2 使用 Collections.synchronizedMap()
通过包装类对所有方法加锁实现线程安全,但性能较低。锁粒度粗:锁住整个Map,高并发下性能差。
Map<Integer, String> safeMap = Collections.synchronizedMap(new HashMap<>());
对比第一种方法可以看出运行时长变长了
(效率虽低但都能实现线程安全)
适用场景:低并发场景或旧代码快速迁移
2.3 手动显式加锁
通过synchronized或ReentrantLock 控制并发,灵活性高但复杂度高。
适用场景: 需要跨多个操作的复杂同步逻辑(因为可以设置在哪里开始上锁,哪里释放锁,,如事务性操作)。
三、方案对比与选型建议
四、小小总结
问题根源:HashMap的线程不安全源于非原子操作、缺乏可见性保证和结构修改的竞争条件(也就是不上锁)。
解决方案:优先选择ConcurrentHashMap,它在高并发下性能优异且易于使用。
设计原则:根据业务场景权衡性能与复杂度,避免不必要的同步开销(见机行事好吧)。
最后祝大家都能成为技术大牛,加油哇!