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 |
| 下单后发通知、写日志 | 生产快、消费慢时,不能无限堆积 | 有界阻塞队列 |
②没有它会怎样:HashMap 丢数据,整表加锁又太挤
- HashMap 三次分别丢了 3.7 万、16 万、12.9 万个用户,没有任何异常。两个线程同时往同一个空桶放节点,后放的盖掉先放的;同时扩容,各自搬出一张新表,最后只留下其中一张(HashMap 页)。
Collections.synchronizedMap每个方法都锁整张表:不丢了,但所有线程的读和写排成一队,读也不能并行。- 缺的那一块:让不同的桶各管各的。改的是不同的桶,就互不等待;读不加锁也能读到完整的数据。
不懂这一点时的典型误用:换成 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 一样的桶数组,多了三种特殊节点
spread 时把最高位清掉了),所以看到 −1 就知道是 ForwardingNode,−2 是 TreeBin。实测 9 个 hashCode 相同的 key 挤进同一个桶,桶头从 Node 变成 TreeBin。④-2put 走哪条路:点一个场景看看
- 1空桶不加锁:用
casTabAt把新节点 CAS 进数组。两个线程抢同一个空桶,只有一个成功,失败的那个重新循环,这时桶已经不空了,就走加锁的路。 - 2非空桶锁桶头:
synchronized (f),f 是桶里第一个节点。锁到以后再确认一次桶头没被换掉(期间可能被扩容搬走),然后在链表或树里找 key。只有同一个桶的写才互相等,表越大、哈希越散,冲突越少。 - 3get 完全不加锁:
tabAt是一次带获取语义的读,节点的val、next是 volatile。写的一方先把节点构造好再发布,读的一方读到节点就看得到完整内容。 - 4扩容大家一起搬:一个线程发现要扩容,就建一张两倍长的新表,从后往前每次领 16 个桶(
MIN_TRANSFER_STRIDE)去搬;别的线程 put 时撞上 ForwardingNode,先去领一段帮忙(helpTransfer),搬完再回来 put。
④-3size 和“只保证单个方法原子”
- 1size 是估算:
size()把 baseCount 和所有 CounterCell 加起来。加的过程中别的线程还在改,所以并发时读到的是“某个时刻附近”的值。实测 两个线程各 put 30 万个以后:baseCount 40954,counterCells 2 格合计 559046,加起来 600000。 - 2单个方法是原子的,两个方法之间不是:
get和put各自安全,但“get 完再 put”中间能插进别人。要“读出来改一改再写回”,用merge、compute、computeIfAbsent,它们在桶锁里一次做完。 - 3不允许 null:key、value 都不能是 null。并发下
get返回 null,必须能确定是“没有这个 key”;如果允许 null 值,就分不清,而又没法像单线程那样再调一次 containsKey 确认(两次调用之间可能变了)。
④-4阻塞队列:满了就等,空了就等
| 操作 | 抛异常 | 返回特殊值 | 一直等 | 等一会儿 |
|---|---|---|---|---|
| 放 | add | offer → false | put | offer(e, 时长) |
| 取 | remove | poll → null | take | poll(时长) |
| 实现 | 容量 | 内部 | 用在哪 |
|---|---|---|---|
| ArrayBlockingQueue | 有界,必须给 | 数组 + 一把锁 + 两个 Condition(notEmpty、notFull) | 固定大小的缓冲、背压 |
| LinkedBlockingQueue | 不给就是 Integer.MAX_VALUE | 链表 + 两把锁(putLock、takeLock),放和取可以同时进行 | Executors.newFixedThreadPool 的任务队列(无界,⑥ 坑 4) |
| PriorityBlockingQueue | 无界 | 数组堆 + 一把锁 | 按优先级取 |
| DelayQueue | 无界 | PriorityQueue + 一把锁 + leader 线程 | 超时取消、定时重试(全景第 9 站) |
| SynchronousQueue | 0:手递手 | 双重栈 / 队列,CAS | newCachedThreadPool(专门那页) |
| LinkedTransferQueue | 无界 | 双重队列,CAS | 生产者要等消费者接走(同上) |
- 1“等”都是 AQS 的 Condition 加 park(AQS 页机制 5):取的线程在 notEmpty 上等,放了东西就 signal;所以全景第 9 站的 order-canceller 平时是 WAITING,不占 CPU。
- 2DelayQueue 的 leader:只让一个线程按“堆顶还剩多久”定时睡(TIMED_WAITING),其余取的线程不限时等;堆顶到期,leader 取走,再叫醒下一个当 leader。避免一堆线程同时醒来抢同一个元素。
④-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 更省。
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
- 1HashMap 三次都丢、每次丢得不一样、从不报错:这就是 J01 全景第 8 站预言的事。ConcurrentHashMap 不丢,三次都比整表加锁的 synchronizedMap 快(第 1 次 235 对 241 ms,只快一点)。
- 2ConcurrentHashMap 在数量达到门槛(12)时就扩容,HashMap 是超过门槛(第 13 个)才扩。
new ConcurrentHashMap(64)在 JDK 17 里按 64 ÷ 0.75 + 1 = 86 再向上取 2 的幂,建出 128 长的表;new HashMap(64)是 64(⑥ 坑 5)。 - 3两个线程同时 put,大部分计数落进了 counterCells,只有约 7% 加在 baseCount 上:一冲突就分格,和 LongAdder 一模一样。
- 4这一轮 ArrayBlockingQueue 比 LinkedBlockingQueue 快。按设计 LinkedBlockingQueue 放和取用两把锁、能并行,但每放一个都要 new 一个链表节点;2 核、元素很小时,分配和 GC 的开销盖过了两把锁的好处。普通 main 程序,只看方向。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 计数偏少、同一个用户被建了两个订单列表、后建的把先建的盖掉。⑤:200 万次 +1 少了 3~4.6 万
- 根源
- 两次调用之间没有任何锁,别的线程随时能插进来。
- 做法
merge、compute、computeIfAbsent、putIfAbsent;值本身是可变对象(List)时,值也要是线程安全的。
- 现象
- 同一个桶上的其他线程全卡住;或者直接抛
IllegalStateException: Recursive update。⑤:AaAa 和 BBBB 同桶,实测抛出 - 根源
- 传进去的函数在 synchronized(桶头) 里执行;函数里又往同一个桶放东西,JDK 9 起检测到就抛异常。JDK 8 里同样的写法可能直接死循环(JDK-8062841,历史原因)。
- 做法
- 函数只做“new 一个对象”这样的快事;查库、调接口放在外面算好再 put;不要在函数里改同一个 map。
- 现象
- “当前在线订单数”时多时少;遍历时没抛 ConcurrentModificationException,却漏了刚放进去的订单。
- 根源
- size 是边加边算的;迭代器是弱一致的:反映迭代开始以后某些时刻的状态,不保证看到所有并发修改,也不会报错。
- 做法
- 要精确就在业务上加锁或者用数据库统计;监控类数字接受它是估算;用
mappingCount()拿 long。
- 现象
- 消费变慢后内存一路涨,最后 OOM;线程池的最大线程数配了也没用。
- 根源
new LinkedBlockingQueue()容量是 21 亿;Executors.newFixedThreadPool正是这么建的(JDK 17 源码)。队列永远不满,就永远不会触发拒绝策略,也不会加线程(线程池页)。- 做法
- 队列一律给容量;放的时候用带超时的 offer,满了就降级或拒绝(阿里巴巴开发手册禁止用 Executors 建线程池也是这个原因)。
- 现象
- 想“预分配 64 个桶”,结果建了 128 个;按 HashMap 的经验算的扩容次数对不上。⑤ 实测 128
- 根源
- ConcurrentHashMap 把参数当成“要放多少个元素”,按 n ÷ 0.75 + 1 向上取 2 的幂;HashMap 把它当“桶数”向上取 2 的幂。历史原因,JDK 8 的算法又和 17 略有不同。
- 做法
- 按“预计放多少个”传参数,别按桶数传;真在意就像 ⑤ 那样用反射看一眼。
使用规范
Spring 单例里的缓存、注册表、计数,只要有可能被两个请求线程同时碰。
HashMap 静默丢数据。
不在业务代码里写 get 再 put。
坑 1。
不做 IO,不改同一个 map,不抛受检异常。
坑 2:卡住整个桶、Recursive update。
满了要有明确的处理:降级、拒绝、或者记日志丢弃。
坑 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~7 | Segment 分段锁,默认 16 段 |
|---|---|
| JDK 7 的 HashMap | 头插法,并发扩容可能把链表弄成环,get 死循环;JDK 8 改尾插,不成环但照样丢数据(⑤ 实测) |
| JDK 8 | CAS + synchronized 桶头、TreeBin、协作扩容、CounterCell;新增 compute / merge / forEach / reduce 等并行批量方法;computeIfAbsent 递归可能死循环 |
| JDK 9+ | 递归更新抛 IllegalStateException: Recursive update(本页 JDK 17 实测) |
| JDK 21~23 | compute 的函数在 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)。
- ConcurrentHashMap 的
transfer源码逐行:sizeCtl 的高低位、resizeStamp、最后一个线程的复查 - 批量方法
forEach(parallelismThreshold, …)、reduce、search用 ForkJoinPool 并行 - ConcurrentSkipListMap:没有锁的有序 Map,跳表的 CAS 插入(和 Redis ZSet 对照)
- Caffeine 本地缓存:在 ConcurrentHashMap 之上做淘汰、过期,读写缓冲为什么用环形队列(J07)
- Disruptor 环形队列:为什么比 ArrayBlockingQueue 快一个数量级
↺回到⑤:改造你的案例
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 或者加锁的列表。