hashmap并发使用会出现哪些具体问题?能否提供实际案例?
很多开发者对HashMap的并发风险的认知只停留在“它不是线程安全的”这个模糊结论上,实际生产中触发的问题远不止表面的抽象描述,具体可分为以下几类:
1. 并发写导致的键值对覆盖、数据丢失
这是最常见、触发概率最高的并发问题,根源是HashMap的put操作完全没有原子性保证:从计算哈希定位桶位、判断桶位是否为空、写入节点到更新size计数,整个操作链路每一步都没有加锁。
当两个线程同时往同一个空桶位写入不同的key时,会出现两个线程都判断当前桶位没有节点,各自构造Node写入桶位的情况——后执行写入的线程会直接覆盖前者写入的节点,最终两个key只保留了一个,实际存储的数据量和预期严重不符。
可复现的最小示例:
Map<String, Integer> unsafeMap = new HashMap<>(); // 线程A写入1000个键值对 new Thread(() -> { for (int i = 0; i < 1000; i++) { unsafeMap.put("key_" + i, i); } }).start(); // 线程B写入另外1000个无重复的键值对 new Thread(() -> { for (int i = 1000; i < 2000; i++) { unsafeMap.put("key_" + i, i); } }).start(); // 等待两个线程执行完成后打印unsafeMap.size() // 实际运行结果几乎永远小于2000,运行次数越多越容易触发大量键值丢失的情况
2. 内存可见性缺失导致的读值不一致、异常
HashMap内部存储节点的数组、链表上的节点值都没有用volatile修饰,不同CPU核心上的线程对HashMap的修改没有强制同步到主内存的要求,会出现典型的工作内存缓存不一致问题:
- 线程A已经在HashMap中写入了某个key,但线程B因为本地缓存的原因,持续读取到key不存在的旧状态,迟迟无法拿到最新写入的值
- 当HashMap触发扩容时,一个线程正在将旧数组的节点迁移到新数组,另一个线程刚好持有旧数组的引用做遍历,会读到已经被从旧数组移除、正在搬运的节点片段,极端场景下会触发空指针异常。
3. JDK7及更早版本特有的环形链表死循环(CPU100%故障)
JDK7版本的HashMap在扩容时,采用头插法的方式将旧链表的节点迁移到新数组的桶位中。当多个线程同时触发扩容操作时,链表节点的前后引用很容易因为头插法的指针翻转形成闭环。
一旦形成环形链表,后续任意一个访问到这个桶位的get操作,都会沿着环形链表无限循环遍历,对应的线程CPU使用率会直接拉满到100%,如果是核心业务线程触发,会直接导致整个服务失去响应。这类故障在早期高并发互联网业务中是非常典型的生产事故来源。
需要注意的是:JDK8版本将头插法改成了尾插法,从机制上避免了环形链表死循环的问题,但上述的覆盖丢数据、读值不一致、计数不准的问题依然完全存在,并不代表JDK8的HashMap可以直接在并发场景下使用。
4. size计数不准确
HashMap内部用来记录键值对总数的size变量是普通的整型数值,没有原子性保证。多线程并发执行put/remove操作时,对size的读-改-写操作会被并发打断,最终记录的size值和HashMap内实际存储的节点数存在偏差——比如实际存储了2000个键值对,size可能只记录到1600,或者出现和实际逻辑完全不符的异常计数。
内容的提问来源于stack exchange,提问作者voipp

