《Java 并发编程·JUC 源码与容器》7.1 ArrayBlockingQueue 与 LinkedBlockingQueue

从 JDK 25 源码对比 ArrayBlockingQueue 的单锁双条件与 LinkedBlockingQueue 的双锁双条件,解释 count 为何必须是 AtomicInteger、signalNotEmpty/signalNotFull 的精确唤醒判据,并用同构探针统计每次 put 的唤醒次数与 JMH 吞吐,给出 libraryd-conc 的有界事件队列落地与容量取舍。

承接的问题

6.3 的选型矩阵给过一句话:ArrayBlockingQueue 是单锁,LinkedBlockingQueue 是双锁,后者生产者和消费者不互斥。这句话是对的,但它没有回答三个真正影响选型的问题:单锁为什么配两个条件?双锁为什么反而要一个 AtomicInteger?既然双锁不互斥,为什么实测吞吐没有稳定胜出?

这一节只拆这两个类。它们是最常见的“有界缓冲区”,也是理解 Condition 如何把“等空”和“等满”分开的最小样本。AQS 与 Condition 的排队细节第 1、2 章已讲,这里只看队列自己的状态机。

先给结论:

维度ArrayBlockingQueueLinkedBlockingQueue
锁一把 ReentrantLockputLock + takeLock
条件notEmpty / notFull,同属一把锁各自绑定自己的锁
元素存储预分配 Object[],环形下标每元素一个 Node,链表
计数普通 int,锁保护AtomicInteger
默认容量无默认,必须给且大于 0Integer.MAX_VALUE
公平模式构造参数可选没有公平构造参数

一把锁、两个条件:ArrayBlockingQueue

先看字段。所有可变状态都是普通字段,没有 volatile,因为它们只在 lock 保护下读写:

// JDK 25 源码摘录,已删减
final Object[] items;
int takeIndex;
int putIndex;
int count;
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
transient Itrs itrs;

items 是构造时一次性分配的定长数组,takeIndex 与 putIndex 做环形推进,count 是当前元素数。两个 Condition 由同一把锁创建,所以它们共享同一个 AQS 同步队列,但各自维护一条条件等待队列:等“非空”的消费者进 notEmpty,等“非满”的生产者进 notFull。这就是“一把锁、两个等待集”。

入队与出队的方法体很短:

// JDK 25 源码摘录,已删减
private void enqueue(E e) {
    final Object[] items = this.items;
    items[putIndex] = e;
    if (++putIndex == items.length) putIndex = 0;
    count++;
    notEmpty.signal();
}

private E dequeue() {
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E e = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length) takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal();
    return e;
}

注意这里是无条件 signal():每次入队都唤醒一个等非空的消费者,每次出队都唤醒一个等非满的生产者。它没有先判断“有没有人在等”,因为 Condition.signal() 自己就会检查条件队列是否为空;没有等待者时只是一次廉价判断。更重要的原因是单锁把所有状态变更串行化了:入队时 count 从 0 变 1,此前任何等待中的消费者必然是在 count == 0 时挂起的,所以这一枪不会打空到“语义错误”,最多是“没有等待者”的空枪。

为什么不用 signalAll()?每次变更只让一个等待者具备继续执行的条件:一次入队只增加一个元素,只能满足一个消费者。signalAll() 会把所有等待者搬到同步队列,醒来后只有一个能拿到元素,其余的重新挂起,白白制造一次“惊群”。只有在清空队列、批量删除这类一次改变很多状态的路径上,signalAll() 才是必要的。

公平模式在构造器里直接传给 ReentrantLock:

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

fair = true 时,锁的获取按 FIFO,等待的消费者与生产者不会无限期被后来者插队;代价是每次 lock 都可能要检查队列前驱,吞吐下降。这个参数只影响“谁先拿到锁”,不改变队列的 FIFO 元素顺序。

两把锁、各一个条件:LinkedBlockingQueue

LinkedBlockingQueue 把“改队尾”和“改队头”拆成两条互斥路径:

// JDK 25 源码摘录,已删减
static class Node<E> {
    E item;
    Node<E> next;
    Node(E x) { item = x; }
}
private final int capacity;
private final AtomicInteger count = new AtomicInteger();
transient Node<E> head;
private transient Node<E> last;
private final ReentrantLock takeLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notFull = putLock.newCondition();

head 是一个 item == null 的哨兵节点,last 指向尾节点;生产者只改 last.next 与 count,消费者只改 head 与 count。两条路径唯一的共享可变状态就是 count。这就是它必须是 AtomicInteger 的原因,而不是“作者顺手用了个原子类”。

第一,count 在一个锁下写、在另一个锁下读。生产者在 putLock 里 getAndIncrement(),消费者在 takeLock 里的等待条件 while (count.get() == 0) 读它。普通 int 没有跨线程可见性保证,AtomicInteger 的读写自带 volatile 语义,把这个跨锁的握手补上。

第二,count 的“读旧值再改”必须原子。生产与消费可以真正并行:生产者执行 count++ 的同时消费者执行 count--,如果用 volatile int,两个读改写会丢更新。更关键的是旧值参与了决策:c == 0 意味着“这次 put 之前队列是空的”,只有这个瞬间才可能有消费者在等;c == capacity 意味着“这次 take 之前队列是满的”,只有这个瞬间才可能有生产者在等。getAndIncrement() 返回的旧值让这个判断没有竞态窗口。

第三,size() 可以无锁:直接 count.get()。这也是为什么不用 LongAdder——LongAdder 擅长“只关心总和”的计数,但它不返回自增前的精确旧值,sum() 也不是一个原子快照,无法支撑上面两个边界判断。

put 的完整路径把这三件事串起来:

// JDK 25 源码摘录,已删减
public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    final int c;
    final Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();
    try {
        while (count.get() == capacity) {
            notFull.await();
        }
        enqueue(node);
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
}

三个动作的顺序值得逐行看:先在 putLock 下等到有空位,再把节点挂到尾部,然后原子自增拿到旧值 c。如果 c + 1 < capacity,说明入队后还有空位,可以再唤醒一个等满的生产者;如果入队后正好满了,唤醒也没用,因为被唤醒者会重新检查 count == capacity 再挂起。最后,只有 c == 0(入队前为空)才去唤醒消费者,而且是在释放 putLock 之后通过 signalNotEmpty() 去拿 takeLock。

这个“释放后再拿另一把锁”的顺序不是风格问题。若生产者在持有 putLock 时去拿 takeLock,消费者又在持有 takeLock 时去拿 putLock,就会形成锁顺序反转的死锁。JDK 的写法保证任何线程最多持有一把队列锁;跨锁唤醒一定是“先放自己的,再拿对方的”。

take 是对称的:

// JDK 25 源码摘录,已删减
public E take() throws InterruptedException {
    final E x;
    final int c;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

c > 1 表示取走一个后还有元素,可以再唤醒一个消费者;c == capacity 表示取走前是满的,取走后腾出了一个位置,必须唤醒一个生产者。两个判断都基于“等待者只可能出现在边界上”这一不变式。

只在必要时唤醒:signal 的判据

把两个类的唤醒条件并排放,差异就清楚了:

操作ArrayBlockingQueueLinkedBlockingQueue
入队后无条件 notEmpty.signal()仅 c == 0 时 signalNotEmpty()
入队后仍有空位不需要额外动作c + 1 < capacity 时 notFull.signal()
出队后无条件 notFull.signal()仅 c == capacity 时 signalNotFull()
出队后仍有元素不需要额外动作c > 1 时 notEmpty.signal()

ArrayBlockingQueue 的“无条件”并不表示它更笨。单锁下,入队与出队本身已经互斥,signal() 的成本只是检查条件队列头指针;它省掉的是“判断有没有等待者”的分支,而不是唤醒。真正的唤醒只发生在确实有等待者时。

LinkedBlockingQueue 则不能无条件:它的 notEmpty 属于 takeLock,生产者要唤醒消费者必须去拿另一把锁。如果每次 put 都拿一次 takeLock,双锁就退化成了“每次操作两把锁”,拆分锁的收益会被这笔额外获取吃掉。于是它用 count 的旧值做精确判定,只在队列“从空变非空”或“从满变非满”的瞬间跨锁。这是双锁设计里最容易被忽略的一笔账:锁拆开之后,跨锁通信必须变成稀有事件,否则拆分没有意义。

容量语义与这个设计的代价

ArrayBlockingQueue 的容量是构造时固定的数组长度,创建即分配 capacity 个引用槽;LinkedBlockingQueue 的容量是上界,节点按需分配,默认 Integer.MAX_VALUE。这带来两个完全不同的失败模式:

场景ArrayBlockingQueueLinkedBlockingQueue
容量给大构造时立刻占用内存空队列几乎不占内存,涨到容量才封顶
容量给成默认不存在默认,必须显式给等于无界,生产快于消费时涨到 OOM
满了之后put 阻塞,offer 返回 false同样,但有界必须显式设置
公平性可选 fair不支持

三个 API 的语义也要分清:put 满时阻塞且响应中断;offer(e, timeout, unit) 超时返回 false;offer(e) 立即返回 false;add(e) 满时抛 IllegalStateException。remainingCapacity() 只是一个瞬时估计,返回值在下一纳秒就可能过期,不能拿它做“一定能放进去”的判断——唯一权威的判断是 offer 的返回值。

这个设计的代价也很明确:

  • ArrayBlockingQueue 的入队与出队共用一把锁。即使队列里既有空位又有元素,生产者和消费者也不能同时修改结构;临界区里还包含 signal() 和迭代器维护,临界区越长,锁队列越长。
  • LinkedBlockingQueue 虽然拆了锁,但 count 是两边每操作一次就要碰的共享原子变量,head、last 与它在对象里相邻,缓存行仍然会在核心之间来回迁移;拆锁消掉的是互斥,不是全部竞争。7.3 会用 JOL 的字段偏移展开这一点。
  • LinkedBlockingQueue 没有公平构造参数,两个 ReentrantLock 都是非公平的。如果业务真的需要“等待者按到达顺序被服务”,只能选 ArrayBlockingQueue(capacity, true),或者自己在外面加一层公平调度。

什么时候不要用它们:需要零容量直接交接时用 SynchronousQueue;需要按优先级或到期时间出队时用 7.2 的两个类;需要“生产者和消费者都不阻塞、只是传递引用”时用 ConcurrentLinkedQueue。这两个类是“有界缓冲 + 背压”的工具,不是通用消息总线。

实测:唤醒次数与吞吐

先看唤醒次数。为了不依赖对 JDK 私有字段的反射,我写了一个同构最小实现,只保留两个类的锁、条件与 signal 路径,并在每次 signal 后计数;生产/消费总量固定为 100 万,用 CountDownLatch 对齐起跑线。本机输出(负载均值 110.5,10 核):

capacity=1024, loadavg=110.5458984375
ABQ  P=1 C=1 total=1,000,000  time=0.113s  ops/s=17,645,833  notEmpty.signal=1,000,000 (1.000/put)  notFull.signal=1,000,000 (1.000/take)
LBQ  P=1 C=1 total=1,000,000  time=0.157s  ops/s=12,698,745  notEmpty.signal=969 (0.001/put)  notFull.signal=46 (0.000/take)
ABQ  P=4 C=4 total=1,000,000  time=0.063s  ops/s=31,677,356  notEmpty.signal=1,000,000 (1.000/put)  notFull.signal=1,000,000 (1.000/take)
LBQ  P=4 C=4 total=1,000,000  time=0.082s  ops/s=24,515,662  notEmpty.signal=945 (0.001/put)  notFull.signal=152 (0.000/take)
ABQ  P=8 C=8 total=1,000,000  time=0.108s  ops/s=18,442,614  notEmpty.signal=1,000,000 (1.000/put)  notFull.signal=1,000,000 (1.000/take)
LBQ  P=8 C=8 total=1,000,000  time=0.088s  ops/s=22,726,885  notEmpty.signal=731 (0.001/put)  notFull.signal=418 (0.000/take)

这组数字要分两半看。唤醒次数是可信的:ABQ 每次 put 都调用一次 notEmpty.signal(),每次 take 都调用一次 notFull.signal();LBQ 在消费者跟得上的工作负载里,队列几乎不回到空,生产者很少看到 c == 0,所以 100 万次 put 只触发了不到 1000 次跨锁唤醒。这正是“只在必要时唤醒”的直观效果。

吞吐列不可信,因为探针在每次 signal 里做了一次 AtomicLong.incrementAndGet(),ABQ 被计了 200 万次,LBQ 只有约 2000 次,这个开销把 ABQ 的 ops/s 压低了。它只能说明“同构实现能跑起来”,不能拿来比较两个类的吞吐。

吞吐用 JMH 1.37 单独测,@Group 一组生产者一组消费者,容量 1024,2 次 1 秒预热、3 次 1 秒测量、1 个 fork。运行前的负载均值是 65.48 / 85.44 / 96.38(1/5/15 分钟),结果如下:

# 本机 JMH 输出摘录:四个 Result 段各自保留原始数值与格式
# Parameters: (kind = abq)
Result "jmh.QueueBench1.four":
  19760078.719 ±(99.9%) 33319033.370 ops/s [Average]

# Parameters: (kind = lbq)
Result "jmh.QueueBench1.four":
  29000335.252 ±(99.9%) 19947152.296 ops/s [Average]

# Parameters: (kind = abq)
Result "jmh.QueueBench1.one":
  18833118.686 ±(99.9%) 125420927.951 ops/s [Average]

# Parameters: (kind = lbq)
Result "jmh.QueueBench1.one":
  11399168.976 ±(99.9%) 41186667.335 ops/s [Average]

单生产者单消费者时 ABQ 的均值更高,4 对 4 时 LBQ 更高,方向随线程数翻转;更重要的是误差项大得离谱,one/abq 的 99.9% 置信区间下界已经是 0,four/lbq 的区间也和 four/abq 大幅重叠。在这台负载常年 60–110 的机器上,两组数字的差异不构成结论,只能确认它们都在 10^7 ops/s 量级。复现命令:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
JH="$HOME/.m2/repository/org/openjdk/jmh"
javac --release 25 -d out -cp "$JH/jmh-core/1.37/jmh-core-1.37.jar" \
  -processorpath "$JH/jmh-generator-annprocess/1.37/jmh-generator-annprocess-1.37.jar:$JH/jmh-core/1.37/jmh-core-1.37.jar" \
  bench/QueueBench1.java
java -cp "out:$JH/jmh-core/1.37/jmh-core-1.37.jar:\
$HOME/.m2/repository/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar:\
$HOME/.m2/repository/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar" \
  jmh.QueueBench1 -wi 2 -i 3 -w 1s -r 1s -f 1

在 libraryd-conc 里落地

libraryd-conc 的借还事件要送给通知扇出(mail、sms、push)。这是一个典型的有界缓冲:峰值每秒约 2000 个事件,下游最慢时允许积压 2 秒,于是容量取 4096(2000 × 2 向上取 2 的幂)。这里选 ArrayBlockingQueue 而不是 LBQ,理由不是吞吐,而是三件事:容量固定、内存可预测、需要公平模式避免还书事件被借书事件长期插队。

package com.plume.conc.core;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

/** 借还事件的有界缓冲:满了就让调用方拿到 false,自己做降级。 */
public final class LoanEventBus {

    public record LoanEvent(String isbn, String memberId, Type type) {
        public enum Type { BORROW, RETURN, RESERVE }
    }

    private final BlockingQueue<LoanEvent> queue;

    public LoanEventBus(int capacity) {
        this.queue = new ArrayBlockingQueue<>(capacity, true);
    }

    /** 返回 false 表示缓冲已满,调用方应记录并降级,而不是无限等待。 */
    public boolean publish(LoanEvent event, long timeoutMillis) throws InterruptedException {
        return queue.offer(event, timeoutMillis, TimeUnit.MILLISECONDS);
    }

    public LoanEvent take() throws InterruptedException {
        return queue.take();
    }

    /** 批量取,减少下游每次只处理一条的调度开销。 */
    public List<LoanEvent> drain(int max) {
        List<LoanEvent> batch = new ArrayList<>(max);
        queue.drainTo(batch, max);
        return batch;
    }

    public int backlog() {
        return queue.size();
    }
}

调用方必须处理 publish 的 false:这正是有界队列的价值——把“下游扛不住”变成调用点上一个可观测、可决策的返回值,而不是几小时后堆里堆满 Node 的 OOM。如果事件处理是纯内存、且生产与消费都很猛、临界区里还要做序列化,那么 LBQ 的双锁更合适;但容量仍要显式给,不能吃默认值。

package com.plume.conc.core;

public final class LoanEventBusDemo {
    public static void main(String[] args) throws Exception {
        LoanEventBus bus = new LoanEventBus(2);
        Thread consumer = Thread.ofPlatform().start(() -> {
            try {
                System.out.println("consumed " + bus.take());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        System.out.println(bus.publish(
            new LoanEventBus.LoanEvent("978-7-111-1", "m-1",
                LoanEventBus.LoanEvent.Type.BORROW), 100));
        System.out.println(bus.publish(
            new LoanEventBus.LoanEvent("978-7-111-2", "m-2",
                LoanEventBus.LoanEvent.Type.RETURN), 100));
        System.out.println("backlog=" + bus.backlog());
        consumer.join();
    }
}

常见错误与边界

错误后果正确做法
new LinkedBlockingQueue<>()默认容量 Integer.MAX_VALUE,等于无界,最终 OOM显式给容量,或用 ArrayBlockingQueue
用 remainingCapacity() 判断能否放入判断与 put 之间状态已变直接用 offer 的返回值
以为 size() 是稳定快照并发下它只是某一瞬间的读数监控趋势,不做业务判断
在 ABQ 上开 fair = true 还期待高吞吐公平锁每次都要检查前驱只有确实需要防饥饿时才开
在 LBQ 上找公平构造参数不存在,两个锁都是非公平要公平只能换 ABQ,或外置调度
自己扩展队列时先拿 putLock 再拿 takeLock与 JDK 的加锁顺序相反,可能死锁任何时刻只持有一把锁

还有一个不常被提到的边界:ArrayBlockingQueue 的 count、takeIndex、putIndex 都是普通 int,迭代器 Itrs 也在锁下维护。这意味着它的 iterator() 不是快照,而是弱一致视图;遍历时队列被其他线程修改,不抛 ConcurrentModificationException,但可能看到部分更新。需要稳定快照时,应该在锁外自己复制,或者换用 6.1 的写时复制结构。

小结

ArrayBlockingQueue 用一把锁把数组、下标、计数和两个等待集串起来,简单、可公平、内存可预测;LinkedBlockingQueue 用两把锁把队头与队尾拆开,代价是必须用 AtomicInteger 维护跨锁的 count,并且只在“空→非空”“满→非满”的边界上做跨锁唤醒。前者适合容量小、要公平、要确定内存的场景,后者适合生产消费都猛、能接受每元素一个 Node 的场景。两者的吞吐差异在本机高负载下没有稳定方向,选型应先看容量语义与失败模式,再看锁的拓扑。

7.2 转向三个“不存储”或“非 FIFO”的队列:零容量的 SynchronousQueue、按堆序出队的 PriorityBlockingQueue、按到期时间出队的 DelayQueue。

阅读导航:上一节 6.3 并发集合选型矩阵 | 下一节 7.2 SynchronousQueue、Priority、Delay

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收