⭐ 推荐:社区规则条款 V1.0

Java 中 HashMap 线程不安全的原因是什么?

liulian
liulian 初级会员超兽战士
发布于 2026-10-10 22:21 ·1 浏览 ·0 回复

学完这篇你能得到什么:你能说清 HashMap 在并发 put、扩容、size 统计时到底哪里会出错,并知道什么时候该换成 ConcurrentHashMap。

第一步:先记住 HashMap 一次 put 不是一步完成

这一步要做什么:打开 JDK 8 的 HashMap.java,定位 putVal 方法。做完你能看到,一次 put 分成多步。

JDK 8 中 HashMap 是数组 + 链表 + 红黑树。put 主要流程是:

  1. 计算 key.hash;
  2. 如果 table 为空,调用 resize();
  3. 根据 (n - 1) & hash 找数组下标;
  4. 桶为空就执行 tab[i] = newNode(...);
  5. 桶不为空就遍历链表或红黑树,找到相同 key 就覆盖 value,否则尾插;
  6. 最后 ++size,并判断是否再次 resize()。

这些步骤没有加锁。

注意:JDK 7 和 JDK 8 的底层结构不同,但“非同步”这一点相同。

第二步:验证并发 put 会丢数据

这一步要做什么:写一个 demo,让两个线程各 put 10000 个不同 key,最后看 map.size()。做完你会发现结果经常小于 20000。

import java.util.HashMap;
import java.util.Map;

public class HashMapUnsafeDemo {
    public static void main(String[] args) throws InterruptedException {
        Map<Integer, Integer> map = new HashMap<>();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) map.put(i, i);
        });
        Thread t2 = new Thread(() -> {
            for (int i = 10000; i < 20000; i++) map.put(i, i);
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("size=" + map.size());
    }
}

运行命令:

javac -encoding UTF-8 HashMapUnsafeDemo.java
java HashMapUnsafeDemo

原因:两个线程可能同时看到同一个桶为 null,然后各自执行 tab[i] = newNode(...),后执行的覆盖先执行的。

注意:这个结果不是每次必现,多跑几次或把线程数调大更容易看到。

第三步:并发扩容会放大问题

这一步要做什么:理解 resize() 和 JDK 7 的 transfer() 在并发时会发生什么。做完你会知道 JDK 7 可能死循环,JDK 8 会丢数据或 size 不准。

JDK 7 的 transfer 用头插法迁移链表。两个线程同时扩容,链表指针可能互相引用,形成环形链表。之后 get 一个不存在的 key,可能在该桶里无限循环,CPU 飙到 100%。

JDK 8 改成尾插法,循环链表问题基本消失,但 resize 仍不是原子的:线程 A 正在把旧 table 迁移到新 table,线程 B 同时 put 到旧 table,B 的数据可能丢失;两个线程同时扩容,还可能互相覆盖 table。

注意:不要因为“JDK 8 不死循环了”就认为 HashMap 变线程安全了。

第四步:size 和 modCount 也不是原子的

这一步要做什么:看 putVal 末尾的 ++size 和 ++modCount。做完你会明白 size 可能偏小,fail-fast 不可靠。

++size、--size、++modCount 都是普通自增,多线程下可能丢失更新。迭代器用 modCount 做 fail-fast,但它只做“尽力检测”。并发修改时可能抛 ConcurrentModificationException,也可能不抛,不能当同步手段。

注意:putIfAbsent、computeIfAbsent 等默认方法在 HashMap 里也不是整体加锁的复合操作。

第五步:需要并发时换容器

这一步要做什么:根据场景选替代方案。做完你就不该再用裸 HashMap 抗并发。

  • 读多写少、允许全表锁:Map<K,V> map = Collections.synchronizedMap(new HashMap<>()); 遍历时仍要手动 synchronized(map)。
  • 高并发读写:用 ConcurrentHashMap<K,V>,JDK 8 用 CAS + synchronized 锁桶,推荐。注意它不允许 null key 和 null value。
  • 老代码兼容:Hashtable 所有方法加 synchronized,性能差,基本不选。

注意:如果只是单线程内使用,或者对象只在一个线程中发布,HashMap 没问题;线程不安全只发生在多线程共享且至少一个线程写。

小结

  • HashMap 的 put、resize、size++、modCount++ 都没有同步。
  • 并发 put 会覆盖,导致数据丢失、size 不准。
  • JDK 7 并发扩容可能形成循环链表并导致 get 死循环;JDK 8 尾插避免成环,但仍有覆盖和扩容竞态。
  • fail-fast 只用于发现 bug,不是线程安全机制。
  • 多线程共享写场景请用 ConcurrentHashMap;需要全表同步时用 Collections.synchronizedMap,并注意遍历加锁。
版权声明:本文来自 GJ站长论坛《Java 中 HashMap 线程不安全的原因是什么?》
原文链接:https://www.gj0.com/thread-1547.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。

全部回复 0

还没有回复,来抢沙发~