Skip to content
Charles Shao
Go back

并发容器:ConcurrentHashMap 与阻塞队列

views

高并发下,共享数据结构是正确性的重灾区:HashMap 在并发扩容时可能形成死循环或丢数据,Hashtable/Collections.synchronizedMap 用一把大锁把所有操作串行化、吞吐低下。JUC 的并发容器就是为此而生——它们用更细的锁粒度无锁 CAS 在保证线程安全的同时把并发吞吐拉起来。

这一篇聚焦两类最常用的:以 ConcurrentHashMap 为代表的并发映射,和以 BlockingQueue 为代表的阻塞队列(线程池、生产者-消费者、异步削峰 的地基)。底层正是前几篇的 CAS、synchronized 锁升级AQS

TL;DR

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 的性能问题

HashtableCollections.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 推倒重来,锁粒度到桶:

ConcurrentHashMap JDK7 与 JDK8 对比。左面板 JDK7 Segment:默认多段各持一把锁;右面板 JDK8 Node[]:空桶 CAS 放首节点、非空桶 synchronized 锁头节点、链表过长转红黑树。底部说明并发度从段数提升到约等于桶数,get 靠 volatile 无锁,size 为近似值。

2.1 JDK 7:分段锁

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

三个关键设计:

  1. 空桶用 CAS 放入首节点,全程无锁——大多数写落在不同桶,几乎不碰撞。
  2. 非空桶只 synchronized 锁住那个头节点——锁粒度是”一个桶”,并发度 ≈ 桶数,远高于 JDK 7 的 16 段。这里正是 synchronized 锁升级 派上用场的地方:低碰撞时是轻量级锁,很便宜。
  3. 链表转红黑树:当某桶链表长度 ≥ 8 表容量 ≥ 64 时,转成红黑树,查找从 O(n) 降到 O(log n),防止哈希碰撞攻击退化。(表容量 < 64 时优先扩容而非转树。)

3.1 get 为什么不用锁

get 全程无锁,靠 volatile 保证可见性——Nodevalnext 都是 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 阻塞——底层多用 AQSCondition(该文 §6 有界缓冲例子)。

BlockingQueue 选型图。四个芯片:ArrayBlockingQueue(有界单锁)、LinkedBlockingQueue(须设容量、双锁)、SynchronousQueue(容量0手递手)、Priority/Delay(无界慎用)。底部说明 put/take 天然背压,Linked 默认无界危险。

队列有界锁结构特点 / 适用
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. 选型速查

需求选它
并发 MapConcurrentHashMap
读极多写极少的 List/SetCopyOnWriteArrayList / Set
生产者-消费者、线程池队列ArrayBlockingQueue / LinkedBlockingQueue(有界)
线程直接接力任务SynchronousQueue
延迟/定时任务DelayQueue
高性能无锁队列(无需阻塞)ConcurrentLinkedQueue
计数器(高并发累加)LongAdder(优于 AtomicLong

8. 常见陷阱

容器保证的是单次操作的线程安全;跨多次操作的业务不变式,以及多线程如何协同、异步如何编排,是下一篇的主题。

延伸阅读


views
Share this post on:

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