Java 中 HashMap 线程不安全的原因是什么?
学完这篇你能得到什么:你能说清 HashMap 在并发 put、扩容、size 统计时到底哪里会出错,并知道什么时候该换成 ConcurrentHashMap。
第一步:先记住 HashMap 一次 put 不是一步完成
这一步要做什么:打开 JDK 8 的 HashMap.java,定位 putVal 方法。做完你能看到,一次 put 分成多步。
JDK 8 中 HashMap 是数组 + 链表 + 红黑树。put 主要流程是:
- 计算
key.hash; - 如果 table 为空,调用
resize(); - 根据
(n - 1) & hash找数组下标; - 桶为空就执行
tab[i] = newNode(...); - 桶不为空就遍历链表或红黑树,找到相同 key 就覆盖 value,否则尾插;
- 最后
++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,并注意遍历加锁。
原文链接:https://www.gj0.com/thread-1547.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。