bigywater.tech
Java 内部原理 · 06

ConcurrentHashMap:多线程 put 为什么不会锁住整张表

上一篇用真实的丢失更新证明了 HashMap 天生不是给并发场景用的。这一篇往前一步:先真实让普通 HashMap 在并发写入下真的丢数据,再验证 ConcurrentHashMap 到底靠什么机制,做到"线程安全"却不用把整张表锁死。

800000 → 656061
16 线程并发 put 80 万个不重复 key,普通 HashMap 真实丢了近 18%,不抛任何异常
697ms → 0ms
同样的"一个 key 被慢操作占着",synchronizedMap 真实被卡住,ConcurrentHashMap 真实秒返回

上一篇:ArrayList 扩容:5,000,000 次 add,不预设容量真实多搬运了 1230 多万个元素

真实验证:普通 HashMap 在并发写入下真的会丢数据

16 个线程,每个线程写自己独占的一段 key,理论上互不冲突,结果却不是这样。

16 个线程,每个线程负责写入一段完全不重叠的 key(线程 0 写 0~49999,线程 1 写 50000~99999,以此类推),逻辑上没有任何两个线程会写同一个 key,唯一的共享状态是底层数组和扩容逻辑:

真实实测 · 16 线程,每线程 5 万个 key
期望 key 总数: 800000
plain HashMap 并发 put 后 size(): 656061
ConcurrentHashMap 并发 put 后 size(): 800000
普通 HashMap 真实丢了 143939 个 key(约 18%),整个过程没有抛出任何异常,程序表面上跑得好好的 ConcurrentHashMap 在完全一样的并发写入下,size() 精确等于期望值

丢失的原因不是"两个线程同时写同一个 key"(这次实验里从设计上就不存在这种冲突),是扩容过程本身不是线程安全的:一个线程正在把旧数组的节点搬到新数组时,另一个线程如果也在同时读写链表结构,会打乱正在进行中的搬迁逻辑,导致某些节点在搬迁过程中"丢失"——数组本身没有加锁保护结构性修改,并发场景下这种破坏是真实会发生的,而不是理论上的担忧。

真实验证:ConcurrentHashMap 锁的到底是不是整张表

让一个线程在某个 key 上"慢慢操作",同时另一个线程写完全不相关的 key,看后者要不要等。

先看一个用整张表加锁实现线程安全的对照组——Collections.synchronizedMap:

对照组:synchronizedMap(整表加锁) Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>()); // 线程 A:先拿到锁,慢慢操作 800ms synchronized (map) { sleep(800); map.put("keyA", 1); } // 线程 B(100ms 后启动):写一个完全无关的 key synchronized (map) { map.put("keyB", 2); }
真实实测
synchronizedMap: 写入 keyB 等待了 697ms(keyA 那边的操作还在慢慢跑)
线程 B 老老实实等了 697ms 才写进去——整张表只有一把锁,谁都得排队

换成 ConcurrentHashMap,用它自己的 compute 方法模拟同样的"慢操作占着 keyA":

ConcurrentHashMap ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); // 线程 A:对 keyA 做一次耗时 800ms 的 compute map.compute("keyA", (k, v) -> { sleep(800); return 1; }); // 线程 B(100ms 后启动):put 完全无关的 keyB map.put("keyB", 2);
真实实测
ConcurrentHashMap: 写入 keyB 等待了 0ms(keyA 那边的 compute 还在慢慢跑)
线程 B 几乎瞬间返回——完全没有等 keyA 那边的操作跑完

两组实验唯一的变量是用的 Map 实现,结果天差地别:synchronizedMap 用一把锁保护整个 HashMap,任何写操作都要抢这一把锁;ConcurrentHashMap 的锁粒度精细到单个桶(bin)——keyA 和 keyB 大概率落在不同的桶里,各自锁各自的桶,互不阻塞。

桶级别的锁具体是怎么加的

Java 8 开始,ConcurrentHashMap 不再是老版本(Java 7 及之前)那种"分段锁"(Segment 数组,每段一把锁、固定分成 16 段)的实现——这是个经常被过时资料带偏的细节。现在的实现是:

锁的对象是"某个桶的头节点"这个具体的 Node 对象,不是整个 ConcurrentHashMap 实例,也不是固定数量的分段——桶的数量会随容量扩大而增加,理论上并发写入能用到的锁粒度只会越来越细。

分步演示:ConcurrentHashMap 真实靠什么保证并发安全

每一行都是真实运行这段代码得到的结果(用CountDownLatch制造真实的最大并发竞争)。

ConcurrentHashMap真实彻底禁止null;computeIfAbsent真实是原子的,8个线程抢着算同一个key也只会真实算一次;迭代真实是弱一致性的,不怕并发修改
这一步新验证的行为

    参考与说明

    • 本文全部 size() 结果和等待时间均在本机真实运行 Java 17、16 线程并发得到;plain HashMap 的丢失比例在不同次运行中会有波动(结构性破坏跟具体的扩容时机、线程调度相关),但真实丢数据这件事本身是稳定可复现的。
    • 单线程读-改-写复合操作为什么不安全、volatile 和原子性的区别,参见上一篇volatile。
    • 没有涉及:ConcurrentHashMap 的 size() 在扩容过程中的估算机制(不是简单加锁统计)、ConcurrentModificationException 和弱一致性迭代器的具体行为——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号