Skip to content
Charles Shao
Go back

并发容器:ConcurrentHashMap、CopyOnWrite 与阻塞队列

–views

线上压测时,若发现 CPU 飙高或吞吐量遭遇瓶颈,共享数据结构的线程安全问题往往是罪魁祸首。HashMap 在并发扩容时极易引发死循环或丢失数据,而传统的 Hashtable 与 Collections.synchronizedMap 仅凭一把大锁将所有读写操作串行化,导致吞吐量断崖式下跌。JUC(java.util.concurrent)并发容器正是为了突破这一瓶颈而生,它们通过更细的锁粒度或无锁 CAS 机制,在确保线程安全的前提下大幅提升了并发吞吐量。

这一篇聚焦两类最常用的并发容器:以 ConcurrentHashMap 为代表的并发映射,和以 BlockingQueue 为代表的阻塞队列(它们是构建线程池、生产者-消费者模型与异步削峰架构的地基)。其底层机制,正是前几篇深入探讨过的 CAS、synchronized 锁升级与 AQS。

本文是 并发编程 / JUC 系列的第 5 篇(并发容器)。 全系列 6 篇:

  1. JMM 与可见性:volatile、happens-before 与重排序
  2. synchronized 与锁升级:对象头、Monitor 与偏向 / 轻量 / 重量级锁
  3. AQS 与 Lock 家族:ReentrantLock、读写锁与 Condition
  4. 线程池原理与调优:ThreadPoolExecutor 七参数、执行流程与拒绝策略
  5. 并发容器:ConcurrentHashMap、CopyOnWrite 与阻塞队列
  6. 并发协作与异步编排:闭锁、信号量与 CompletableFuture

一句话定位:摒弃全局大锁,通过细粒度锁、CAS 无锁化与写时复制技术,在保障线程安全的同时极限压榨多核并发吞吐量。

TL;DR

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 JDK7 与 JDK8 对比。左面板 JDK7 Segment:默认多段各持一把锁;右面板 JDK8 Node[]:空桶 CAS 放首节点、非空桶 synchronized 锁头节点、链表过长转红黑树。底部说明并发度从段数提升到约等于桶数,get 靠 volatile 无锁,size 为近似值。 ConcurrentHashMap 从 JDK 7 的分段锁架构到 JDK 8 的细粒度桶锁与红黑树架构演进。

2.1 JDK 7:分段锁的局限

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, ...);                                     // 分段计数,必要时触发扩容

这条加锁路径展现了三个关键的架构设计:

  1. 空桶采用 CAS 放入首节点,全程无锁化——在哈希散列良好的情况下,大多数写入操作会落在不同的空桶中,几乎不会发生锁竞争。
  2. 非空桶仅通过 synchronized 锁定该桶的头节点——锁粒度缩小至单一桶,并发度理论上等同于桶的数量,远超 JDK 7 的 16 段限制。这里正是上一篇提到的 synchronized 锁升级大显身手之处:在低碰撞场景下,偏向锁与轻量级锁的开销极低。
  3. 链表转红黑树机制:当某个桶的链表长度 ≥ 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);
}

适用场景:读操作频率呈压倒性优势、且集合规模较小的场景。最典型的应用包括监听器/回调列表、白名单缓存、路由表等「启动时或偶尔修改、运行时高频读取」的业务。

核心代价:

  1. 每次写操作都必须复制整个数组,若写入频繁或数组过大,将带来巨大的内存开销与 GC 压力。
  2. 弱一致性延迟——迭代器遍历时获取的是「某一时刻的状态快照」,遍历期间发生的写入操作对其不可见(这也解释了为何在遍历时绝不会抛出 ConcurrentModificationException)。

5. 阻塞队列家族

BlockingQueue 接口是构建生产者-消费者模型、线程池任务调度以及异步削峰架构的核心抽象。当队列满时,put 操作会使线程挂起等待;当队列空时,take 操作同样会阻塞等待。其底层通常依赖 AQS 的 Condition 机制(详见该文 §6 的有界缓冲示例)。

BlockingQueue 选型图。四个芯片:ArrayBlockingQueue(有界单锁)、LinkedBlockingQueue(须设容量、双锁)、SynchronousQueue(容量0手递手)、Priority/Delay(无界慎用)。底部说明 put/take 天然背压,Linked 默认无界危险。 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. 常见陷阱

并发容器解决了单次操作的线程安全问题;然而,跨越多次操作的业务不变式该如何维持,多线程之间如何高效协同,以及复杂的异步调用链路该如何编排,这将是下一篇探讨的核心主题。

延伸阅读


–views
Share this post on:

Previous Post
并发协作与异步编排:闭锁、信号量与 CompletableFuture
Next Post
线程池原理与调优:ThreadPoolExecutor 七大参数与执行流程