ConcurrentHashMap · 桶 · CAS 放空桶 · synchronized 锁桶头 · TreeBin · ForwardingNode · 多线程协助扩容 · baseCount + CounterCell · merge / compute · Recursive update · 阻塞队列 · put / offer / take / poll · ArrayBlockingQueue · LinkedBlockingQueue · DelayQueue · 背压

ConcurrentHashMap 与阻塞队列:锁一个桶,而不是锁整张表

J01 的全景预言过:两个线程同时往 HashMap 里放新用户,会丢。本机实测,100 万个用户丢了 4%~16%。ConcurrentHashMap 的办法是把锁的粒度缩到一个桶:空桶用一次 CAS 放进去,有东西的桶只锁桶头那个节点,读完全不加锁,扩容时大家一起帮忙搬,计数也像 LongAdder 那样分格记。它只保证“单个方法是原子的”,先 get 再 put 照样丢更新。阻塞队列是另一种并发容器:放不进去就等、取不到就等,线程之间靠它交接任务。全景第 9 站的订单表和超时取消,用的就是这两样。

全景:一个下单请求在线程池里 · 第 9 站地图:J02 · ConcurrentHashMap 与阻塞队列(第 5 个知识点)前置:HashMap:哈希、扩容、树化实验:JDK 17.0.20,2 核 ARM 实测

①为什么需要:全景第 9 站有两样东西被几十个线程同时碰

在全景第 9 站,这一步要解决的是:每个抢到的人,订单号要放进按用户分组的 userOrders;30 分钟不付款要自动取消,取消任务交给一个后台线程。前者是几十个 exec 线程同时写一张表,后者是 exec 线程放、后台线程取,两边速度不一样。

靶场里的事需要什么本页用什么
userOrders:用户 → 他的订单列表很多线程同时读写,既不能丢数据,也不能为了安全把所有线程排成一队ConcurrentHashMap
商品销量计数“读出来 +1 再写回”要是一步merge / compute
30 分钟未付款就取消到期前取不到,到期了马上取到,取的线程平时睡着DelayQueue
下单后发通知、写日志生产快、消费慢时,不能无限堆积有界阻塞队列
需求地图上这一项的深度是“懂机制”:说得清 ConcurrentHashMap 的 put 走了哪几条路、为什么读不加锁、size 为什么不是精确值;知道它保证什么、不保证什么;阻塞队列会按“有界还是无界、要不要排序、要不要延迟”选,并知道线程池为什么离不开它。

②没有它会怎样:HashMap 丢数据,整表加锁又太挤

实测 JDK 17,2 核。两个线程各 put 50 万个不同的用户,应该有 1000000 个。

HashMap 第 1 次962723
HashMap 第 2 次839013
HashMap 第 3 次871315
synchronizedMap / CHM1000000

不懂这一点时的典型误用:换成 ConcurrentHashMap 就觉得“线程安全了”,接着写 Integer v = map.get(k); map.put(k, v + 1);。实测 两个线程各 +1 共 200 万次,结果 1970799 和 1954211;换成 merge 才是 2000000(⑥ 坑 1)。

③相邻方案

容器怎么保证安全读要不要锁适合
Hashtable / synchronizedMap每个方法锁整张表要老代码;并发很低
JDK 7 的 ConcurrentHashMap分 16 段(Segment),每段一把 ReentrantLock不要历史,JDK 8 起改掉了
JDK 8+ 的 ConcurrentHashMap空桶 CAS,非空桶锁桶头不要通用的并发 Map,日常首选
ConcurrentSkipListMap跳表 + CAS,没有锁不要要按 key 排序、范围查询
CopyOnWriteArrayList写时复制整个数组不要读极多、写极少的小列表(监听器列表)

选择规则:要 Map 就用 ConcurrentHashMap,要有序再换跳表;要在线程之间交接任务、让“放的”和“取的”速度自动对齐,用阻塞队列。本页以 ConcurrentHashMap 为主,阻塞队列讲家族和选法,SynchronousQueue 和 LinkedTransferQueue 的内部见专门那一页。

④原理:一个桶一把锁,空桶连锁都不要

先知道这些
  • HashMap 的桶、扩容、树化:数组下标 = 扰动后的 hash & (长度 − 1),链表长到 8 且数组不小于 64 时变红黑树(HashMap 页)。
  • CAS、synchronized、LongAdder:见synchronized 与 CAS。
  • volatile 读和发布:先把节点的字段写好,再用一次 volatile 写(或 CAS)放进数组,别的线程 volatile 读到它,就一定看到写好的字段(JMM 下一圈第三圈)。

④-1长什么样:和 HashMap 一样的桶数组,多了三种特殊节点

transient volatile Node[] table 0 空 1 2 空 88 … Node 用户 1024 Node 用户 3072 链表:val、next 都是 volatile TreeBin(hash = −2) 桶里超过 8 个:换成红黑树,自带读写小锁 ForwardingNode(hash = −1) 扩容时:已搬到新表,读的跟过去,写的先帮忙搬 计数(size 从这里算) volatile long baseCount 没冲突:CAS 加到 baseCount CounterCell[] counterCells 冲突了:各加各的格子(和 LongAdder 一样) sizeCtl 平时 = 下次扩容的门槛(长度 × 0.75) 负数 = 正在初始化或扩容
用负的 hash 值标出特殊节点:普通节点的 hash 一定 ≥ 0(spread 时把最高位清掉了),所以看到 −1 就知道是 ForwardingNode,−2 是 TreeBin。实测 9 个 hashCode 相同的 key 挤进同一个桶,桶头从 Node 变成 TreeBin。

④-2put 走哪条路:点一个场景看看

④-3size 和“只保证单个方法原子”

④-4阻塞队列:满了就等,空了就等

操作抛异常返回特殊值一直等等一会儿
放addoffer → falseputoffer(e, 时长)
取removepoll → nulltakepoll(时长)
实现容量内部用在哪
ArrayBlockingQueue有界,必须给数组 + 一把锁 + 两个 Condition(notEmpty、notFull)固定大小的缓冲、背压
LinkedBlockingQueue不给就是 Integer.MAX_VALUE链表 + 两把锁(putLock、takeLock),放和取可以同时进行Executors.newFixedThreadPool 的任务队列(无界,⑥ 坑 4)
PriorityBlockingQueue无界数组堆 + 一把锁按优先级取
DelayQueue无界PriorityQueue + 一把锁 + leader 线程超时取消、定时重试(全景第 9 站)
SynchronousQueue0:手递手双重栈 / 队列,CASnewCachedThreadPool(专门那页)
LinkedTransferQueue无界双重队列,CAS生产者要等消费者接走(同上)

④-5为什么这样设计:锁的粒度跟着数据走

JDK 8:锁一个桶CAS + synchronized(桶头)

  • 并发度随表长增长:表长 1024,就是 1024 把“锁”,不用事先猜开几段
  • 不用额外的锁对象,桶头节点自己就是锁;没人抢时 synchronized 只是一两次 CAS(synchronized 页)
  • 扩容多线程协作,计数分格,没有全局热点
  • 代码复杂得多:ForwardingNode、ReservationNode、TreeBin 的读写小锁

JDK 7:分段锁16 个 Segment,每段一个小 HashMap

  • 思路简单:每段就是一个加了 ReentrantLock 的小哈希表
  • 并发度在创建时就定死(默认 16),后来机器核越来越多,16 不够用
  • 每个 Segment 都是一个 ReentrantLock 对象,空 map 也要占内存
  • size 要先不加锁算几次,不一致再把 16 段全锁上

决定胜负的约束:真正会冲突的是“同一个桶”,不是“同一段”。哈希够散时,两个线程同时写同一个桶的概率很小,所以锁桶头几乎总是无竞争的;再配上 synchronized 在 JDK 6 以后的轻量级锁,比 Segment 里的 ReentrantLock 更省。

API最少要会的几行
ConcurrentHashMap<Long, List<Long>> userOrders = new ConcurrentHashMap<>();
userOrders.computeIfAbsent(1024L, k -> new CopyOnWriteArrayList<>()).add(10017L);  // 没有就建,一步完成

ConcurrentHashMap<Long, Long> sold = new ConcurrentHashMap<>();
sold.merge(88L, 1L, Long::sum);                               // 销量 +1,原子
sold.compute(88L, (k, v) -> v == null ? 1 : v + 1);           // 同上,写法更灵活
long n = sold.mappingCount();                                 // 比 size() 好:返回 long

BlockingQueue<Long> notify = new ArrayBlockingQueue<>(10_000);  // 有界
if (!notify.offer(orderId, 50, TimeUnit.MILLISECONDS)) degrade();  // 满了:降级,不要无限等
DelayQueue<CancelTask> cancel = new DelayQueue<>();
CancelTask t = cancel.take();                                 // 到期才返回

初始容量的算法、computeIfAbsent 里的递归、弱一致的迭代器、队列选型的坑,这些放到第⑥步。

⑤亲眼看到:丢数据、桶里的节点、计数器和三种队列

三个程序,--release 8 编译,JDK 17 运行;ChmPeek 用反射读内部字段,要加 --add-opens java.base/java.util.concurrent=ALL-UNNAMED。完整代码:study-labs/java/juc/chm-blocking-queue →

$ java -cp … MapRace
== 1. 两个线程各 put 50 万个不同的 key,应有 1000000 ==
第 1 次  HashMap 962723,218 ms   synchronizedMap 1000000,241 ms   ConcurrentHashMap 1000000,235 ms
第 2 次  HashMap 839013,147 ms   synchronizedMap 1000000,228 ms   ConcurrentHashMap 1000000,87 ms
第 3 次  HashMap 871315,172 ms   synchronizedMap 1000000,234 ms   ConcurrentHashMap 1000000,144 ms
== 2. 销量计数:两个线程各 +1 共 100 万次,应有 2000000 ==
第 1 次  get 再 put:1970799    merge:2000000
第 2 次  get 再 put:1954211    merge:2000000
$ java --add-opens java.base/java.util.concurrent=ALL-UNNAMED -cp … ChmPeek
== 1. 扩容:table 长度和 sizeCtl ==
刚 new:        table 还没建,sizeCtl=0
放了   1 个:  table 长 16,sizeCtl=12
放了  12 个:  table 长 32,sizeCtl=24        ← 第 12 个就扩了(HashMap 是第 13 个)
放了  24 个:  table 长 64,sizeCtl=48
放了  48 个:  table 长 128,sizeCtl=96
放了 100 个:  table 长 256,sizeCtl=192
== 2. 一个桶里挤满:链表 → 树 ==
同一个桶里 8 个:表长 128,桶 88 的头节点是 Node     ← new ConcurrentHashMap(64) 建出来是 128
同一个桶里 9 个:表长 128,桶 88 的头节点是 TreeBin
== 3. 计数器:两个线程同时 put ==
size()=600000,baseCount=40954,counterCells 2 格,格子里合计 559046
== 4. computeIfAbsent 里再改同一个 map ==
抛 IllegalStateException: Recursive update
$ java -cp … QueueDemo
== 1. ArrayBlockingQueue(3) ==
队列满了,offer(10004, 200ms) = false,等了 200 ms;add 会抛异常,put 会一直等
== 2. DelayQueue:到期才能取 ==
刚放进去,poll() = null(没到期,取不到)
第 1 个 take:订单 10018,在第 100 ms
第 2 个 take:订单 10019,在第 200 ms
第 3 个 take:订单 10017,在第 300 ms
== 3. 2 生产 2 消费,传 400 万个订单号 ==
第 1 轮  ArrayBlockingQueue 304 ms   LinkedBlockingQueue 650 ms
第 2 轮  ArrayBlockingQueue 385 ms   LinkedBlockingQueue 576 ms
到这里已经能交差了:全景第 9 站的 userOrders 用 ConcurrentHashMap + computeIfAbsent,销量用 merge,超时取消用 DelayQueue,通知用有界队列 + offer 超时降级;你能讲清 put 的三条路、get 为什么不加锁、size 为什么是估算。
已知还没处理的细节:复合操作(⑥ 坑 1)、compute 里干重活或递归(⑥ 坑 2)、迭代和 size 的一致性(⑥ 坑 3)、无界队列(⑥ 坑 4)、初始容量(⑥ 坑 5)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1先 get 再 put、先 containsKey 再 put← ④-3 只保证单个方法原子
现象
计数偏少、同一个用户被建了两个订单列表、后建的把先建的盖掉。⑤:200 万次 +1 少了 3~4.6 万
根源
两次调用之间没有任何锁,别的线程随时能插进来。
做法
merge、compute、computeIfAbsent、putIfAbsent;值本身是可变对象(List)时,值也要是线程安全的。
坑 2computeIfAbsent 里做慢事,或者再改同一个 map← ④-2 compute 拿着桶锁执行
现象
同一个桶上的其他线程全卡住;或者直接抛 IllegalStateException: Recursive update。⑤:AaAa 和 BBBB 同桶,实测抛出
根源
传进去的函数在 synchronized(桶头) 里执行;函数里又往同一个桶放东西,JDK 9 起检测到就抛异常。JDK 8 里同样的写法可能直接死循环(JDK-8062841,历史原因)。
做法
函数只做“new 一个对象”这样的快事;查库、调接口放在外面算好再 put;不要在函数里改同一个 map。
坑 3把 size()、迭代结果当成精确快照← ④-3 size 是估算
现象
“当前在线订单数”时多时少;遍历时没抛 ConcurrentModificationException,却漏了刚放进去的订单。
根源
size 是边加边算的;迭代器是弱一致的:反映迭代开始以后某些时刻的状态,不保证看到所有并发修改,也不会报错。
做法
要精确就在业务上加锁或者用数据库统计;监控类数字接受它是估算;用 mappingCount() 拿 long。
坑 4用了无界队列← ④-4 LinkedBlockingQueue 默认 Integer.MAX_VALUE
现象
消费变慢后内存一路涨,最后 OOM;线程池的最大线程数配了也没用。
根源
new LinkedBlockingQueue() 容量是 21 亿;Executors.newFixedThreadPool 正是这么建的(JDK 17 源码)。队列永远不满,就永远不会触发拒绝策略,也不会加线程(线程池页)。
做法
队列一律给容量;放的时候用带超时的 offer,满了就降级或拒绝(阿里巴巴开发手册禁止用 Executors 建线程池也是这个原因)。
坑 5以为 new ConcurrentHashMap(n) 和 new HashMap(n) 一样← ④-1 sizeCtl 和表长
现象
想“预分配 64 个桶”,结果建了 128 个;按 HashMap 的经验算的扩容次数对不上。⑤ 实测 128
根源
ConcurrentHashMap 把参数当成“要放多少个元素”,按 n ÷ 0.75 + 1 向上取 2 的幂;HashMap 把它当“桶数”向上取 2 的幂。历史原因,JDK 8 的算法又和 17 略有不同。
做法
按“预计放多少个”传参数,别按桶数传;真在意就像 ⑤ 那样用反射看一眼。
第二圈

使用规范

共享的 Map 一律 ConcurrentHashMap

Spring 单例里的缓存、注册表、计数,只要有可能被两个请求线程同时碰。

HashMap 静默丢数据。

读改写用 merge / compute

不在业务代码里写 get 再 put。

坑 1。

compute 的函数要快、要纯

不做 IO,不改同一个 map,不抛受检异常。

坑 2:卡住整个桶、Recursive update。

阻塞队列一律有界,放用 offer 带超时

满了要有明确的处理:降级、拒绝、或者记日志丢弃。

坑 4:OOM。

第三圈

深处:扩容怎么协作、版本差异

多线程一起扩容

触发扩容的线程把 sizeCtl 改成负数(高位记“这一轮扩容的标记”,低位记“有几个线程在帮忙”),建一张两倍长的 nextTable,然后从数组末尾开始,每次用 CAS 领 16 个桶(transferIndex 往前移)。每搬完一个桶,就把旧表那一格换成 ForwardingNode。这期间 get 碰到 ForwardingNode 就去新表查,put 碰到就先调 helpTransfer 领一段帮忙。最后一个搬完的线程把 table 指向新表,sizeCtl 设成新门槛。搬一个桶时,和 HashMap 一样按 hash & 旧长度 拆成低位、高位两条链(HashMap 页的 lo / hi)。(按 JDK 17 源码 transfer)

TreeBin 的读写小锁

树化以后,桶头是 TreeBin,写的时候照样 synchronized(TreeBin)。但红黑树旋转时结构会变,不加锁的 get 可能走丢,所以 TreeBin 自己还有一个 lockState:有写在旋转时,读线程改为沿着 TreeNode 之间保留的 next 链表线性查找,不等锁。

版本差异

JDK 5~7Segment 分段锁,默认 16 段
JDK 7 的 HashMap头插法,并发扩容可能把链表弄成环,get 死循环;JDK 8 改尾插,不成环但照样丢数据(⑤ 实测)
JDK 8CAS + synchronized 桶头、TreeBin、协作扩容、CounterCell;新增 compute / merge / forEach / reduce 等并行批量方法;computeIfAbsent 递归可能死循环
JDK 9+递归更新抛 IllegalStateException: Recursive update(本页 JDK 17 实测)
JDK 21~23compute 的函数在 synchronized 里执行,虚拟线程在里面阻塞会钉住载体线程(JDK 24 起 JEP 491 解决,见 synchronized 页坑 5)

面试官会怎么问

ConcurrentHashMap 在 JDK 7 和 8 有什么区别?

7 是 Segment 分段锁;8 去掉分段,空桶 CAS、非空桶 synchronized 锁桶头,链表长了转红黑树,多线程协作扩容,计数分格(④-5)。

get 为什么不用加锁?

table 是 volatile,读桶用 getReferenceAcquire;Node 的 val、next 是 volatile;写入先构造好节点再 CAS 或在锁里发布,读到节点就看到完整内容(④-2)。

为什么 key 和 value 不能是 null?

并发下 get 返回 null 必须只有“不存在”一种含义,否则没法用两次调用区分(④-3)。

线程池为什么不建议用 Executors 创建?

newFixedThreadPool 和 newSingleThreadExecutor 用无界 LinkedBlockingQueue,任务堆积会 OOM;newCachedThreadPool 用 SynchronousQueue、最大线程数无上限,线程会无限多(坑 4)。

下一圈

↺回到⑤:改造你的案例

1. 修好销量计数,再比一比

在 MapRace 第 2 段加两种写法:compute 和“值换成 LongAdder、用 computeIfAbsent 取出来再 increment”。四种写法对比结果和耗时。

用到:坑 1、④-3

2. 看扩容时的 ForwardingNode

在 ChmPeek 里,一个线程不停 put 到触发扩容,另一个线程不停反射扫旧表,数一数看到 hash = −1 的节点几次。

用到:④-1、⑥ 第三圈

3. 给通知队列加背压

在 QueueDemo 里让消费者每个元素睡 1 ms,生产者用 offer(e, 10ms),统计被降级的个数;再换成无界 LinkedBlockingQueue,看堆内存怎么涨(jcmd GC.heap_info)。

用到:坑 4

自测

1. 用一句话说出这页的精华。

ConcurrentHashMap 把锁缩到一个桶:空桶 CAS、非空桶锁桶头、读不加锁、扩容大家一起搬、计数分格;它只保证单个方法原子,读改写要用 merge / compute。阻塞队列让放和取的线程在满和空时自动等,选型先看有界无界。

2. HashMap 和整表加锁各疼在哪?

HashMap 并发写静默丢数据(实测丢 4%~16%);整表加锁不丢,但所有读写排成一队(②)。

3. 换了 ConcurrentHashMap 计数还是偏少,根源是 ④ 的哪一条?

④-3:只保证单个方法原子,get 和 put 之间能插队。

4. 预测:所有 key 的 hashCode 都返回同一个值,16 个线程同时 put,ConcurrentHashMap 的吞吐会怎样?

所有写都落在同一个桶,都要 synchronized 同一个桶头,退化成一把全局锁,比 synchronizedMap 好不了多少;桶里超过 8 个会树化,查找还算 O(log n),但锁竞争避免不了(④-2、④-5)。

5. 预测:DelayQueue 里放了 10 万个 30 分钟后到期的取消任务,有 4 个线程在 take,这 30 分钟里 CPU 和线程状态是什么样?

几乎不占 CPU:一个 leader 线程按堆顶剩余时间 TIMED_WAITING,另外 3 个 WAITING;到期时 leader 醒来取走堆顶,再叫醒一个接任 leader(④-4)。

6. 放回全景:第 9 站如果还用 HashMap,第 4~8 站全都正确,第 10、11 站正常返回,用户会看到什么?

用户下单成功、库存也扣了,但“我的订单”里偶尔少一单:userOrders 在并发 put 时静默丢了条目,没有异常也没有日志。超时取消如果也依赖这张表,那一单还可能永远不会被取消。换 ConcurrentHashMap + computeIfAbsent,值用 CopyOnWriteArrayList 或者加锁的列表。