线上压测时,若发现 CPU 飙高或吞吐量遭遇瓶颈,共享数据结构的线程安全问题往往是罪魁祸首。HashMap 在并发扩容时极易引发死循环或丢失数据,而传统的 Hashtable 与 Collections.synchronizedMap 仅凭一把大锁将所有读写操作串行化,导致吞吐量断崖式下跌。JUC(java.util.concurrent)并发容器正是为了突破这一瓶颈而生,它们通过更细的锁粒度或无锁 CAS 机制,在确保线程安全的前提下大幅提升了并发吞吐量。
这一篇聚焦两类最常用的并发容器:以 ConcurrentHashMap 为代表的并发映射,和以 BlockingQueue 为代表的阻塞队列(它们是构建线程池、生产者-消费者模型与异步削峰架构的地基)。其底层机制,正是前几篇深入探讨过的 CAS、synchronized 锁升级与 AQS。
本文是 并发编程 / JUC 系列的第 5 篇(并发容器)。 全系列 6 篇:
一句话定位:摒弃全局大锁,通过细粒度锁、CAS 无锁化与写时复制技术,在保障线程安全的同时极限压榨多核并发吞吐量。
TL;DR
- HashMap 的并发灾难:JDK 7 并发扩容易形成环形链表导致 CPU 打满,JDK 8 虽修复成环问题,但仍存在丢失更新与读到中间状态的风险,多线程环境下严禁共享。
- 摒弃全局大锁:
Hashtable与synchronizedMap依赖单一大锁将所有读写操作串行化,高并发场景下极易成为吞吐量瓶颈。 - ConcurrentHashMap 的演进:JDK 7 采用分段锁(Segment),默认 16 段各持一把
ReentrantLock,并发度受限于段数;JDK 8 抛弃分段锁,改为Node[]数组结合 CAS 与 synchronized,将锁粒度细化至单个桶,并在链表过长(≥ 8 且表容量 ≥ 64)时转化为红黑树以保障查询性能。 - 读操作的无锁化:
ConcurrentHashMap的get操作全程无锁,依赖volatile修饰的节点属性保障内存可见性;而size()采用分段计数,返回的是弱一致的近似值。 - CopyOnWrite 的适用边界:适用于读极多写极少的场景(如监听器列表),读操作完全无锁,但写操作需复制整个数组,伴随较高的内存开销与弱一致性延迟。
- BlockingQueue 家族:作为生产者-消费者模型与线程池的核心抽象,
ArrayBlockingQueue采用单锁,LinkedBlockingQueue采用双锁(putLock与takeLock)分离生产与消费,SynchronousQueue实现无缓冲的线程间直接交付,需根据业务场景权衡选型。
Table of contents
Open Table of contents
1. 为什么不能用 HashMap / Hashtable
1.1 HashMap 的并发灾难
HashMap 从设计之初就未考虑线程安全。在 JDK 7 中,多线程同时触发扩容(resize)时,头插法迁移链表极易形成环形链表。一旦后续的 get 操作落入该桶,便会陷入死循环,导致线上 CPU 瞬间打满——这是极为经典的线上事故。
尽管 JDK 8 将扩容改为尾插法并优化了迁移逻辑,彻底消除了环形链表隐患,但并发写入时依然会面临丢失更新、读取到中间状态以及 size 错乱等致命问题。因此结论十分明确:HashMap 仅限于单线程环境使用,或作为只读集合在安全发布后不再修改。
1.2 Hashtable / synchronizedMap 的性能瓶颈
虽然 Hashtable 和 Collections.synchronizedMap 能够保证线程安全,但其实现方式过于简单粗暴——每个方法都通过 synchronized 竞争同一把全局大锁:
// synchronizedMap 内部:所有操作竞争同一个 mutex
public V get(Object key) { synchronized (mutex) { return m.get(key); } }
public V put(K k, V v) { synchronized (mutex) { return m.put(k, v); } }
这意味着任意两个线程的任意操作(哪怕都是读操作,或者操作完全不同的 key)都必须互斥等待。在高并发场景下,这种全局串行化会导致吞吐量断崖式下跌。如果业务需要高并发映射表,必须使用 ConcurrentHashMap。
2. ConcurrentHashMap 的演进
为了突破全局大锁的瓶颈,JDK 7 引入了分段锁(Segment)机制:将一个庞大的 Map 切分为若干个独立的段,每个段自带一把锁,从而将锁粒度从「整个 Map」缩小到「单个段」。 然而,随着并发需求的提升,JDK 8 推倒重来,直接将锁粒度极致细化到了单个哈希桶。
ConcurrentHashMap 从 JDK 7 的分段锁架构到 JDK 8 的细粒度桶锁与红黑树架构演进。
2.1 JDK 7:分段锁的局限
- 核心类
Segment继承自ReentrantLock,写操作仅需获取所属段的锁。 - 并发度约等于段数(由
concurrencyLevel决定,默认 16)。 - 定位元素需要两次哈希:首先定位到具体的段,然后在段内定位到具体的桶。
- 局限性在于:并发度在初始化时即被段数写死,且
Segment这种嵌套结构带来了额外的内存开销与寻址成本。
3. ConcurrentHashMap:JDK 8 的 CAS + synchronized
正因如此,JDK 8 彻底抛弃了 Segment 架构,回归了类似 HashMap 的 Node[] table 单层数组结构,并将锁粒度细化到单个哈希桶。我们可以从简化版的 putVal 写入逻辑中一窥究竟:
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int i;
if (tab == null) tab = initTable(); // 懒初始化(CAS 抢占 sizeCtl 标记位)
else if ((f = tabAt(tab, i = (n-1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
break; // 空桶:CAS 比较并交换直接放入,全程无锁
}
else if (f.hash == MOVED) tab = helpTransfer(tab, f); // 遇到 ForwardingNode,协助并发扩容
else {
synchronized (f) { // 非空桶:仅获取该桶头节点的同步状态
// 遍历链表或红黑树,执行插入或更新操作
}
}
}
addCount(1L, ...); // 分段计数,必要时触发扩容
这条加锁路径展现了三个关键的架构设计:
- 空桶采用 CAS 放入首节点,全程无锁化——在哈希散列良好的情况下,大多数写入操作会落在不同的空桶中,几乎不会发生锁竞争。
- 非空桶仅通过
synchronized锁定该桶的头节点——锁粒度缩小至单一桶,并发度理论上等同于桶的数量,远超 JDK 7 的 16 段限制。这里正是上一篇提到的 synchronized 锁升级大显身手之处:在低碰撞场景下,偏向锁与轻量级锁的开销极低。 - 链表转红黑树机制:当某个桶的链表长度 ≥ 8 且 整体表容量 ≥ 64 时,链表将转化为红黑树。这一机制将极度哈希碰撞下的查找时间复杂度从 O(n) 降至 O(log n),有效抵御了哈希碰撞攻击。(需要强调的是,若表容量 < 64,系统会优先选择扩容而非转树)。
3.1 get 操作为何能实现无锁化
ConcurrentHashMap 的 get 操作全程无锁,其核心在于依靠 volatile 保障内存可见性。Node 节点的 val 和 next 属性均被声明为 volatile:
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
// 带 acquire 语义的数组元素读(JDK 9+ VarHandle;更早版本为 getObjectVolatile)
return (Node<K,V>) U.getReferenceAcquire(tab, ...);
}
根据 JMM 的 happens-before 规则,写线程对哈希桶的修改(无论是 CAS 成功还是 synchronized 释放锁)必然对后续读线程的 volatile 读可见。因此,读操作能够实时获取最新状态而无需加锁,这是其在读多写少场景下维持极高吞吐量的底层基石。
3.2 size() 提供的弱一致性
在高并发场景下,精确维护一个全局计数器本身就会成为性能瓶颈(所有写线程都需要 CAS 竞争同一个计数器)。为此,ConcurrentHashMap 借鉴了 LongAdder 的设计思想,采用 baseCount + CounterCell[] 进行分段累加:在低竞争时直接 CAS 更新 baseCount,竞争激烈时则将压力分散到多个 CounterCell 中,调用 size() 时再进行汇总求和。
这种设计的代价是:size() 与 mappingCount() 返回的仅仅是弱一致的近似值,在并发修改期间无法保证绝对精确。切忌将其用于强一致性的业务判断(例如「判断 size 恰好满 100 就触发某项操作」)。
3.3 严禁存入 null 键值
与 HashMap 不同,ConcurrentHashMap 严格禁止存入 null 键或 null 值。这是因为在并发环境下,get 返回 null 会产生致命的二义性——调用方无法分辨究竟是「key 不存在」还是「key 存在但 value 被设为了 null」。单线程下尚能通过 containsKey 再次确认,但在并发环境中,两次调用之间状态可能已被其他线程修改。若需表达空语义,请使用 Optional 或业务定义的哨兵对象替代。
4. CopyOnWrite:读极多写极少的利器
如果业务场景的读操作远多于写操作,CopyOnWriteArrayList 与 CopyOnWriteArraySet 提供了另一种极致的无锁化思路:读操作完全无锁,直接读取当前不变的底层数组;写操作则互斥加锁并复制整个数组,修改完成后再将引用原子性地替换过去:
public boolean add(E e) {
synchronized (lock) { // 获取锁,串行化写操作
Object[] es = getArray();
Object[] newElements = Arrays.copyOf(es, es.length + 1); // 复制整个数组
newElements[es.length] = e;
setArray(newElements); // volatile 写,原子替换引用
return true;
}
}
public E get(int index) { // 读操作无锁、无等待
return elementAt(getArray(), index);
}
适用场景:读操作频率呈压倒性优势、且集合规模较小的场景。最典型的应用包括监听器/回调列表、白名单缓存、路由表等「启动时或偶尔修改、运行时高频读取」的业务。
核心代价:
- 每次写操作都必须复制整个数组,若写入频繁或数组过大,将带来巨大的内存开销与 GC 压力。
- 弱一致性延迟——迭代器遍历时获取的是「某一时刻的状态快照」,遍历期间发生的写入操作对其不可见(这也解释了为何在遍历时绝不会抛出
ConcurrentModificationException)。
5. 阻塞队列家族
BlockingQueue 接口是构建生产者-消费者模型、线程池任务调度以及异步削峰架构的核心抽象。当队列满时,put 操作会使线程挂起等待;当队列空时,take 操作同样会阻塞等待。其底层通常依赖 AQS 的 Condition 机制(详见该文 §6 的有界缓冲示例)。
BlockingQueue 家族选型决策树与核心特性对比。
| 队列实现 | 容量边界 | 锁结构 | 核心特点与适用场景 |
|---|---|---|---|
ArrayBlockingQueue | 有界 | 单锁(put/take 共用) | 基于数组,容量固定;通用性强,支持公平锁策略 |
LinkedBlockingQueue | 可界/默认无界 | 双锁(putLock + takeLock) | 生产与消费分离锁,吞吐量极高;线上务必显式设置容量 |
SynchronousQueue | 容量 0 | 无缓冲 | 线程间无缓冲直接交付;常用于 CachedThreadPool |
PriorityBlockingQueue | 无界 | 单锁 | 支持按优先级出队;需警惕无界导致的 OOM 风险 |
DelayQueue | 无界 | 单锁 | 元素达到指定延迟时间方可出队;适用于定时或延迟任务 |
LinkedTransferQueue | 无界 | CAS 无锁 | transfer 机制确保生产者阻塞等待消费者取走;极致性能 |
对比前一种方案,LinkedBlockingQueue 的双锁设计正是其吞吐量碾压 ArrayBlockingQueue 的秘诀——生产与消费操作分别持有独立的 putLock 和 takeLock,两者能够真正并行执行,仅依靠一个 AtomicInteger 类型的 count 协调状态。
选型建议:若系统需要严格的内存容量上界且读写模式均衡,首选 ArrayBlockingQueue;若追求极致的生产与消费吞吐量,使用 LinkedBlockingQueue(切记显式设置容量以防 OOM);若需要工作线程直接接管任务而不做缓冲,则选择 SynchronousQueue。
典型的生产者-消费者骨架如下:
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000); // 必须指定有界容量!
// 生产者:队列满时阻塞等待(天然背压),或使用 offer(timeout) 实施超时降级
queue.put(task);
// 消费者:队列空时阻塞等待,直到被唤醒
Task t = queue.take();
需要强调的是,put 与 take 的阻塞机制本身就是一种天然的背压(Backpressure):当队列满载时,生产者线程被阻塞挂起,上游流量自然随之减速。这与网络连接与 I/O 模型以及线程池中的背压治理是同源的架构思路。
6. 非阻塞队列 ConcurrentLinkedQueue
当业务场景不需要阻塞语义(即不需要「队列空了就挂起等待」),而仅仅需要一个极致性能的线程安全队列时,ConcurrentLinkedQueue 是最佳选择。它基于 CAS 的 Michael-Scott 无锁算法实现,offer 与 poll 操作全程无锁化,非常适合高并发环境下的临时数据缓冲与无界任务收集。但需注意它是一个无界队列,且调用 size() 需要遍历整个链表(时间复杂度 O(n) 且为近似值)。
7. 选型速查
| 业务需求 | 推荐容器 |
|---|---|
| 高并发键值映射 | ConcurrentHashMap |
| 读极多写极少的列表或集合 | CopyOnWriteArrayList / CopyOnWriteArraySet |
| 生产者-消费者模型、线程池缓冲 | ArrayBlockingQueue / LinkedBlockingQueue(务必有界) |
| 线程间直接接力交付任务 | SynchronousQueue |
| 延迟调度或定时任务 | DelayQueue |
| 高性能无锁缓冲(无需阻塞语义) | ConcurrentLinkedQueue |
| 高并发数值累加器 | LongAdder(性能远超 AtomicLong) |
8. 常见陷阱
- 误将 CHM 的复合操作视作原子:
if (!map.containsKey(k)) map.put(k, v)是典型的「检查再执行」两步操作,中间极易被其他线程插足。必须使用putIfAbsent、computeIfAbsent或merge等原子方法。 - 在
computeIfAbsent的计算逻辑中递归操作同一个 CHM:在 JDK 8 早期版本中这会引发死锁或抛出异常,且业务逻辑极易出错,严禁在 Lambda 表达式中对同一个 Map 进行递归修改。 - 依赖
size()执行精确判断:由于其返回的是弱一致的近似值,用于强一致性业务逻辑必然导致状态错乱。 - 在写频繁场景滥用 CopyOnWrite:每次写入都会触发全量数组复制,高频写入必然拖垮系统性能并引发频繁 GC。
- 无界队列引发 OOM:默认的
LinkedBlockingQueue或PriorityBlockingQueue均为无界队列,一旦消费端降级,任务堆积将迅速耗尽内存。线上环境务必显式设置容量,并配合背压策略。 - 误以为并发容器能保障整段业务的线程安全:并发容器仅仅保障单次操作的原子性与可见性;跨越多次操作的业务不变式,依然需要通过显式加锁或原子方法组合来维持。
并发容器解决了单次操作的线程安全问题;然而,跨越多次操作的业务不变式该如何维持,多线程之间如何高效协同,以及复杂的异步调用链路该如何编排,这将是下一篇探讨的核心主题。
延伸阅读
- 并发协作与异步编排:Latch / Barrier / Semaphore 与 CompletableFuture。
- 线程池原理与调优:有界队列与背压。
- AQS 与 Lock 家族:Condition 与有界缓冲。
- OpenJDK 源码:
ConcurrentHashMap、CopyOnWriteArrayList、LinkedBlockingQueue。 - Brian Goetz et al. Java Concurrency in Practice, Ch. 5。
- Maged M. Michael, Michael L. Scott. Simple, Fast, and Practical Non-Blocking Concurrent Queue Algorithms。