上一篇用真实的丢失更新证明了 HashMap 天生不是给并发场景用的。这一篇往前一步:先真实让普通 HashMap 在并发写入下真的丢数据,再验证 ConcurrentHashMap 到底靠什么机制,做到"线程安全"却不用把整张表锁死。
上一篇:ArrayList 扩容:5,000,000 次 add,不预设容量真实多搬运了 1230 多万个元素
16 个线程,每个线程写自己独占的一段 key,理论上互不冲突,结果却不是这样。
16 个线程,每个线程负责写入一段完全不重叠的 key(线程 0 写 0~49999,线程 1 写 50000~99999,以此类推),逻辑上没有任何两个线程会写同一个 key,唯一的共享状态是底层数组和扩容逻辑:
期望 key 总数: 800000 plain HashMap 并发 put 后 size(): 656061 ConcurrentHashMap 并发 put 后 size(): 800000普通 HashMap 真实丢了 143939 个 key(约 18%),整个过程没有抛出任何异常,程序表面上跑得好好的 ConcurrentHashMap 在完全一样的并发写入下,size() 精确等于期望值
丢失的原因不是"两个线程同时写同一个 key"(这次实验里从设计上就不存在这种冲突),是扩容过程本身不是线程安全的:一个线程正在把旧数组的节点搬到新数组时,另一个线程如果也在同时读写链表结构,会打乱正在进行中的搬迁逻辑,导致某些节点在搬迁过程中"丢失"——数组本身没有加锁保护结构性修改,并发场景下这种破坏是真实会发生的,而不是理论上的担忧。
让一个线程在某个 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 实例,也不是固定数量的分段——桶的数量会随容量扩大而增加,理论上并发写入能用到的锁粒度只会越来越细。
每一行都是真实运行这段代码得到的结果(用CountDownLatch制造真实的最大并发竞争)。