LinkedBlockingQueue

LinkedBlockingQueue 源码分析

链表实现的阻塞队列, 解决的问题和 ArrayBlockingQueue 一样(生产者-消费者), 但它把”入队”和”出队”拆成两把独立的锁: put 只碰尾巴, take 只碰头, 两边可以真正并行。
能这么拆的前提是一个 AtomicInteger count 在两把锁之间充当唯一的共享裁判。

代码块JAVA · 85 行收起展开
// 基于 JDK 17。链表阻塞队列:takeLock/putLock 两锁分离,头尾各管各的,吞吐高于单锁的 ArrayBlockingQueue。
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

    static class Node<E> {
        E item;
        Node<E> next;       // 出队后会指向自己(self-link),帮 GC 断开跨代引用
        Node(E x) { item = x; }
    }

    private final int capacity;        // 容量上限。默认构造给 Integer.MAX_VALUE,"有界"名存实亡

    private final AtomicInteger count = new AtomicInteger();   // 两把锁唯一共享的状态,必须原子

    transient Node<E> head;            // 不变式:head.item == null,是个哨兵。只有 takeLock 侧动它
    private transient Node<E> last;    // 不变式:last.next == null。只有 putLock 侧动它

    private final ReentrantLock takeLock = new ReentrantLock();    // take/poll 用
    private final Condition notEmpty = takeLock.newCondition();    // 消费者在这上面等"非空"

    private final ReentrantLock putLock = new ReentrantLock();     // put/offer 用
    private final Condition notFull = putLock.newCondition();      // 生产者在这上面等"非满"

    // put 侧发现队列由空变非空时调用:signal 别人锁上的 Condition 必须先持有那把锁
    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();                // 跨锁唤醒的代价:put 线程偶尔要摸一下 takeLock
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }

    // take 侧发现队列由满变不满时调用,对称
    private void signalNotFull() {
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {
            notFull.signal();
        } finally {
            putLock.unlock();
        }
    }

    private void enqueue(Node<E> node) {
        // assert putLock.isHeldByCurrentThread();
        // assert last.next == null;
        last = last.next = node;        // 只动 last,不碰 head —— 两锁互不干扰的物理基础
    }

    private E dequeue() {
        // assert takeLock.isHeldByCurrentThread();
        // assert head.item == null;
        Node<E> h = head;
        Node<E> first = h.next;
        h.next = h; // help GC          // 自指:既标记"已出队",又避免老年代节点拽住新节点
        head = first;                   // 出队 = 把下一个节点变成新哨兵
        E x = first.item;
        first.item = null;
        return x;
    }

    // remove/clear/迭代器修改这类要同时动头尾的操作,才需要两把锁全拿
    void fullyLock() {
        putLock.lock();
        takeLock.lock();
    }

    void fullyUnlock() {
        takeLock.unlock();
        putLock.unlock();
    }

    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);        // 默认"无界"。Executors.newFixedThreadPool 用的就是这个构造
    }

    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);    // 哨兵节点:空队列时 head == last,两把锁也不会摸到同一个字段
    }
    // ...
}

put 和 take 是完全对称的一对, 各自只拿自己那把锁:

代码块JAVA · 52 行收起展开
// 基于 JDK 17。java.util.concurrent.LinkedBlockingQueue:put 与 take
    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 {
            /*
             * Note that count is used in wait guard even though it is
             * not protected by lock. This works because count can
             * only decrease at this point (all other puts are shut
             * out by lock), and we (or some other waiting put) are
             * signalled if it ever changes from capacity. Similarly
             * for all other uses of count in other wait guards.
             */
            while (count.get() == capacity) {   // 持有 putLock 时 count 只会被 take 减小,读到"满"顶多虚惊一场
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();        // c 是加之前的旧值
            if (c + 1 < capacity)               // 加完还没满 -> 顺手唤醒下一个生产者(级联唤醒)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)                             // 旧值为 0 = 队列刚从空变非空,才需要跨锁叫醒消费者
            signalNotEmpty();
    }
    // ...
    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)                      // 旧值为 capacity = 刚从满变不满,才跨锁叫醒生产者
            signalNotFull();
        return x;
    }

原理串讲

先回答最核心的问题: 两把锁凭什么不打架。链表队列的入队和出队天然操作两个不同的端点, enqueue 只写 last, dequeue 只写 head, 构造函数里 last = head = new Node<E>(null) 的哨兵节点保证即使队列为空, put 改的是哨兵的 next(以 last 的身份), take 读的是 head.item(永远是 null 的哨兵), 结构上没有任何字段会被两侧同时写。
唯一逃不掉的共享状态是元素个数: put 要用它判满, take 要用它判空, 而它同时被两把锁下的代码修改, 谁的锁都罩不住它, 所以必须是 AtomicInteger, 用 getAndIncrement/getAndDecrement 保证原子并顺带拿到旧值。
对比之下 ArrayBlockingQueue 的 takeIndex/putIndex/count 全是普通 int, 因为它只有一把锁, 所有状态都在同一把锁的保护下。
数组做不了双锁还有个更根本的原因: 环形数组里 putIndex 追上 takeIndex 时两个”端点”就是同一个槽位, 头尾在物理上分不开。

再看 while (count.get() == capacity) 这个等待条件, 它读的是没有被当前锁保护的变量, 看起来像数据竞争, 其实是精心安排过的单向误差: 持有 putLock 期间其他生产者全被挡在外面, count 只可能被消费者减小。
所以这个读要么准确, 要么读到一个”偏大”的过期值——把不满误判成满, 大不了在 notFull 上多睡一轮, 而队列真的从满变不满时 take 一定会 signalNotFull, 不会睡死; 反过来”把满误判成不满”这种会导致超容量的错误方向, 被锁排除了。
源码里那段 “count is used in wait guard even though it is not protected by lock” 的注释讲的正是这件事。

然后是唤醒设计, 这是双锁版本最精巧的地方。put 成功后做两个判断: c + 1 < capacity 时 signal 自己锁上的 notFull, c == 0 时才跨锁 signalNotEmpty。
前者是级联唤醒(cascading signal): 一次 signal 只叫醒一个等待者, 那如果十个生产者都在 notFull 上睡, 消费者只在”从满变不满”那一刻 signal 一次, 剩下九个谁来叫? 答案是每个被唤醒的 put 完成入队后, 只要发现还有空位就再 signal 下一个, 唤醒像多米诺一样自己传播下去, take 侧的 c > 1 对 notEmpty 完全对称。

代码块JAVA · 2 行收起展开
为什么不直接用 signalAll? 因为 signalAll 会让全部等待者同时涌起来抢锁, 抢到的只有一个, 其余全部再睡回去, 白白付出 N 次上下文切换; 级联唤醒让每次唤醒都"有位置可用"才发生, 几乎不空转。
后者的条件 `c == 0`(旧值为空)同样是为了省: 跨锁 signal 要先 lock 对面的锁, 是双锁模型里唯一的交叉点, 如果每次 put 都做, 两把锁就退化成一把; 只在"空转非空"这个边沿触发, 是因为只有这一刻才可能有消费者在 notEmpty 上睡——队列非空时来的消费者根本不会睡。

注意 signalNotEmpty 在 putLock 释放之后才调用, 进一步缩短了同时持有两把锁的窗口(实际上根本不同时持有)。

最后是那个著名的坑: 默认构造容量是 Integer.MAX_VALUE。Executors.newFixedThreadPool(n) 内部就是 new LinkedBlockingQueue<Runnable>(), 线程数固定, 队列却事实无界——当任务生产速度长期高于消费速度, 任务会无限堆积在队列里, 最终 OOM, 而且在 OOM 之前不会触发任何拒绝策略(队列永远 offer 成功, RejectedExecutionHandler 形同虚设), 也不会创建超过 corePoolSize 的线程(线程池只在队列满时才加线程到 maximumPoolSize)。
这就是阿里规约强制手动 new ThreadPoolExecutor 并显式传有界队列的原因。

设计取舍

  • 双锁换吞吐, 代价是复杂度: count 必须原子、跨锁 signal、size() 只能给瞬时值; remove/contains/迭代这类全局操作还要 fullyLock 两把全拿, 比单锁更贵。
  • 级联唤醒 + 边沿触发跨锁唤醒, 把 signal 次数压到最少; 代价是逻辑绕——c + 1 < capacityc == 0 判的都是旧值, 很多人在这里被绕懵。
  • 链表按需分配节点, 不用预分配大数组, 但每个元素多一个 Node 对象, GC 压力比 ArrayBlockingQueue 大; dequeue 里的 self-link 就是为了缓解跨代引用。
  • ArrayBlockingQueue 支持公平锁参数, LinkedBlockingQueue 的两把锁写死非公平——双锁模型下公平性本来也没法跨锁定义。
  • 默认容量 Integer.MAX_VALUE 让”有界队列”变成事实无界, newFixedThreadPool/newSingleThreadExecutor 的 OOM 风险即来源于此; 生产环境一律显式传 capacity。

延伸阅读