HashMap非线程安全问题解析与应对
大家好,我是欧阳方超,微信公众号同名。
1 概述
HashMap是一种非常常用的数据结构,用于存储键值对。它利用哈希表的原理实现,通过特定的哈希函数计算键的哈希值,然后就能快速在内存中找到对应的存储位置,进而进行值的存储及获取。在并发编程系列文章中,我们已经看到过自定义类的非线程安全性,本篇就介绍下JDK中这个常用却又非线程安全的类——HashMap。
2 HashMap非线程安全问题
2.1 并发扩容导致死循环或数据丢失
HashMap在多线程环境下会暴漏出一些问题,因为它不是线程安全的。这意味着当多个线程同时对一个HashMap进行操作时,可能会导致数据不一致或其他错误情况。这是由于HashMap的各种操作,如put、get、remove等,都没有进行同步处理。当HashMap执行put操作时,尤其是当元素个数达到一定阈值,需要进行扩容的时候,内部会进行复杂的计算,包括重新计算哈希值、复制元素等操作。假设这样一个场景,有两个线程thread1和thread2同时对HashMap进行操作,如果thread1执行put操作时触发了扩容操作,而此时thread2也在执行put或get操作,那么就可能出现问题,thread2可能会读取到不一致的数据。
为了更直观地展示HashMap的非线程安全性,下面看一个示例:
public class HashMapTest {
public static void main(String[] args) {
Map<Integer, Object> unsafeMap = new HashMap<>(2, 0.75f);
//创建CountDownLatch,用于等待两个线程都完成操作
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
unsafeMap.put(i, i);
}
countDownLatch.countDown();
});
Thread thread2 = new Thread(() -> {
for (int i = 10000; i < 20000; i++) {
unsafeMap.put(i, i);
}
countDownLatch.countDown();
});
thread1.start();
thread2.start();
try {
//等待两个线程都完成操作
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("最终HashMap的大小为:" + unsafeMap.size());
}
}
上面的示例中,创建了两个线程thread1、thread2,这两个线程都向同一个HashMap中放入10000个键值对。理论上HashMap的大小应该是20000,但由于HashMap不是线程安全的,在put操作过程中可能会出现数据覆盖、丢失等情况,程序运行结果为:
最终HashMap的大小为:19423
2.2 非线程安全的迭代器
当使用HashMap的迭代器变量集合时,如果遍历过程中有其他线程修改了HashMap的结构,此时会发生ConcurrentModificationException异常。因为创建迭代器时,会记录下modCount值,当HashMap结构发生变化时,该变量也会变化,从而导致抛出异常。
下面的示例展示了这个问题:
public class HashMapTest {
public static void main(String[] args) {
Map<Integer, Object> unsafeMap = new HashMap<>(2, 0.75f);
//创建CountDownLatch,用于等待两个线程都完成操作
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
unsafeMap.put(i, i);
}
countDownLatch.countDown();
});
Thread thread2 = new Thread(() -> {
Iterator<Integer> iterator = unsafeMap.keySet().iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
}
countDownLatch.countDown();
});
thread1.start();
thread2.start();
try {
//等待两个线程都完成操作
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("最终HashMap的大小为:" + unsafeMap.size());
}
}
上面的示例中,thread1线程往HashMap放入键值对,同时thread2线程使用迭代器遍历HashMap,可能回抛出异常,程序运行结果:
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1445)
at java.util.HashMap$KeyIterator.next(HashMap.java:1469)
3 多线程环境下的替代方案
在多线程环境下,如果要使用类似HashMap的功能,该怎么办呢?Java为开发者提供了ConcurrentHashMap,还可以使用Collections工具类提供的synchronized系列的方法,如synchronizedMap(),可以将HashMap封装成线程安全的集合。
比如在上面两个线程往HashMap放入键值对的示例中,如果使用Collections.synchronizedMap()方法来包装HashMap,就能一定程度上保证同步操作,
Map<Integer, Object> unsafeMap = new HashMap<>(2, 0.75f);
Map<Integer, Object> threadSafeMap= Collections.synchronizedMap(unsafeMap);
调整后,程序运行结果为:
最终HashMap的大小为:20000
需要注意的是,虽然Collections.synchronizedMap() 来包装 HashMap,可以提供一定的同步机制,但这并不保证复合操作(例如迭代和修改)是安全的。特别是,当一个线程在修改映射时,另一个线程在迭代它,就可能导致并发修改,从而引发异常。所以,多线程环境下推荐使用ConcurrentHashMap,它是为并发访问设计的。
5 总结
通过示例展示了HashMap的非线程安全性,为确保线程安全,介绍了ConcurrentHashMap和Collections工具类两种同步机制,更推荐使用 ConcurrentHashMap,因为它通常是处理 Java 中并发操作的最佳实践,因为它设计用于高并发且不会造成锁的开销。
我是欧阳方超,把事情做好了自然就有兴趣了,如果你喜欢我的文章,欢迎点赞、转发、评论加关注。我们下次见。