高并发下,共享数据结构是正确性的重灾区:HashMap 在并发扩容时可能形成死循环或丢数据,Hashtable/Collections.synchronizedMap 用一把大锁把所有操作串行化、吞吐低下。JUC 的并发容器就是为此而生——它们用更细的锁粒度或无锁 CAS 在保证线程安全的同时把并发吞吐拉起来。
这一篇聚焦两类最常用的:以 ConcurrentHashMap 为代表的并发映射,和以 BlockingQueue 为代表的阻塞队列(线程池、生产者-消费者、异步削峰 的地基)。底层正是前几篇的 CAS、synchronized 锁升级 与 AQS。
TL;DR
- HashMap 非线程安全:JDK 7 并发扩容会形成环形链表导致 CPU 100% 死循环;JDK 8 改了扩容不再成环,但仍会丢数据/读到 null,多线程绝不能共享。
- 别用 Hashtable / synchronizedMap:它们用单一大锁串行化所有读写,高并发下是瓶颈。
- ConcurrentHashMap JDK 7 用分段锁(Segment):默认 16 段,每段一把
ReentrantLock,不同段可并发写,并发度 = 段数。 - JDK 8 抛弃分段锁:改为
Node[]数组 + 对单个桶头节点 CAS/synchronized,锁粒度细到”桶”,并发度大幅提升;桶内链表过长(≥8 且表容量≥64)转红黑树。 - get 全程无锁:靠
volatile的 Node.val/next 保证可见性;size()用 baseCount + CounterCells 分段计数,是弱一致的近似值。 - CopyOnWrite 适合读极多写极少(如监听器列表):读无锁,写时复制整个数组,写代价高、有内存与一致性延迟。
- BlockingQueue 家族支撑生产者-消费者与线程池:ArrayBlockingQueue(单锁)、LinkedBlockingQueue(putLock/takeLock 双锁)、SynchronousQueue(手递手)、PriorityBlockingQueue、DelayQueue 各有取舍。
Table of contents
Open Table of contents
1. 为什么不能用 HashMap / Hashtable
1.1 HashMap 的并发灾难
HashMap 从设计上就非线程安全。JDK 7 中,多线程同时触发扩容(resize)时,头插法迁移链表可能形成环形链表,之后 get 落到该桶就死循环,表现为 CPU 飙到 100%——这是经典线上事故。
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 的思路:把一个大 Map 切成若干独立的段(Segment),每段自带一把锁,锁粒度从「整个 Map」缩小到「一段」。 JDK 8 推倒重来,锁粒度到桶:
2.1 JDK 7:分段锁
Segment extends ReentrantLock,写操作只锁所属段。- 并发度 ≈ 段数(
concurrencyLevel,默认 16)。 - 定位一个 key 要两次哈希:先定段,再定段内桶。
- 局限:并发度被段数写死;Segment 结构带来额外开销。
3. ConcurrentHashMap:JDK 8 的 CAS + synchronized
JDK 8 彻底抛弃 Segment,回归 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 为什么不用锁
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, ...);
}
写线程对桶的修改(CAS 或 synchronized 释放)与读线程的 volatile 读之间存在 happens-before(见 JMM 与可见性),所以读能看到最新值,且无需加锁——这是读多写少场景吞吐高的核心。
3.2 size() 是近似的
高并发下精确维护一个全局计数本身就是瓶颈(所有写都要 CAS 同一个计数器)。CHM 借鉴 LongAdder 的思路,用 baseCount + CounterCell[] 分段累加:低竞争时 CAS baseCount,竞争高时分散到多个 CounterCell,size() 时求和。
代价:size() / mappingCount() 返回的是弱一致的近似值,并发修改时不保证精确。别拿它做强一致的判断(如”恰好满 100 就触发”)。
3.3 不支持 null 键值
ConcurrentHashMap 不允许 null 键或 null 值。因为并发下 get 返回 null 有二义性——分不清是”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 有界缓冲例子)。
| 队列 | 有界 | 锁结构 | 特点 / 适用 |
|---|---|---|---|
ArrayBlockingQueue | 有界 | 单锁(put/take 共用) | 数组实现,容量固定;通用、可选公平 |
LinkedBlockingQueue | 可界/默认无界 | 双锁(putLock + takeLock) | 生产消费用不同锁,吞吐高;务必设容量 |
SynchronousQueue | 容量 0 | 无缓冲 | 手递手直传,无存储;配合 CachedThreadPool |
PriorityBlockingQueue | 无界 | 单锁 | 按优先级出队;注意无界会 OOM |
DelayQueue | 无界 | 单锁 | 元素到期才能取;定时/延迟任务 |
LinkedTransferQueue | 无界 | CAS 无锁 | transfer 让生产者等消费者取走;高性能 |
LinkedBlockingQueue 的双锁是它比 ArrayBlockingQueue 吞吐高的原因——生产和消费用两把独立的锁(putLock/takeLock),可以真正并行,靠一个 AtomicInteger count 协调。选型时:需要严格容量上界且访问模式均衡用 ArrayBlockingQueue;生产消费吞吐都高用 LinkedBlockingQueue(记得设容量);线程直接接管任务用 SynchronousQueue。
生产者-消费者骨架:
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000); // 有界!
// 生产者:队满则阻塞(背压),或用 offer(timeout) 做超时降级
queue.put(task);
// 消费者:队空则阻塞等待
Task t = queue.take();
put/take 的阻塞本身就是天然背压:队满时生产者被挡住,上游自然减速——这与 连接与 I/O 模型、线程池 的背压是同一套思路。
6. 非阻塞队列 ConcurrentLinkedQueue
当你不需要阻塞语义(不需要”空了就等”),只要一个高性能线程安全队列,用 ConcurrentLinkedQueue——它基于 CAS 的 Michael-Scott 无锁算法,offer/poll 全程无锁,适合高并发下的临时缓冲、无界任务收集。注意它无界,且 size() 需遍历(O(n)、近似)。
7. 选型速查
| 需求 | 选它 |
|---|---|
| 并发 Map | ConcurrentHashMap |
| 读极多写极少的 List/Set | CopyOnWriteArrayList / Set |
| 生产者-消费者、线程池队列 | ArrayBlockingQueue / LinkedBlockingQueue(有界) |
| 线程直接接力任务 | SynchronousQueue |
| 延迟/定时任务 | DelayQueue |
| 高性能无锁队列(无需阻塞) | ConcurrentLinkedQueue |
| 计数器(高并发累加) | LongAdder(优于 AtomicLong) |
8. 常见陷阱
- 把 CHM 的复合操作当原子:
if (!map.containsKey(k)) map.put(k, v)是两步,中间可能被插入。用putIfAbsent/computeIfAbsent/merge这些原子方法。 - 在
computeIfAbsent的 lambda 里再操作同一个 CHM:JDK 8 早期会死锁/抛异常,且逻辑易出错,别在计算函数里递归改同一个 map。 - 依赖
size()做精确判断:它是近似值,用于强一致逻辑会出错。 - CopyOnWrite 用在写频繁场景:每次写复制全数组,写多必然拖垮性能和 GC。
- 无界队列(默认 LinkedBlockingQueue / PriorityBlockingQueue)堆积 OOM:务必设容量,配合背压。
- 误以为并发容器让整段业务线程安全:容器只保证单次操作原子;跨多次操作的业务不变式仍需锁或原子方法组合。
容器保证的是单次操作的线程安全;跨多次操作的业务不变式,以及多线程如何协同、异步如何编排,是下一篇的主题。
延伸阅读
- 并发协作与异步编排: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