题目描述

✅ 1188. 设计有限阻塞队列

题意分析

实现容量固定的线程安全先进先出队列。入队时若已满,调用必须等待直到出现空位;出队时若为空,必须等待直到有元素,不能直接返回失败或占位值。size 返回一次受同步保护的当前元素数。

多个生产者和消费者可能同时调用接口,因此不仅要保证元素顺序,还要保证不会超出容量、从空队列读值,或让并发读写观察到不一致的头尾位置与数量。

解法:锁 + 条件变量 + 环形数组

核心思路

[!blue]

用定长环形数组保存队列,head 指向下一个要取出的元素,tail 指向下一个要写入的位置,移动时对容量取模。size 表示有效元素数,范围始终是零到容量;因为空和满时都可能出现 head == tail,必须结合数量区分两者。

一把互斥锁同时保护数组读写、头尾下标和数量。线程取得锁后先检查是否能执行;入队写尾部并增加数量,出队读头部并减少数量,这几项更新在同一临界区完成。其他线程只能在拿到同一把锁后检查,所以不会看到中间某一项刚更新、其他项还没有跟上的状态。

队列满时,生产者等待 notFull;队列空时,消费者等待 notEmpty。条件等待会把释放锁和进入等待连成一个操作,让对侧线程有机会改变队列;被唤醒后,必须重新取得同一把锁才能继续。若只是持锁反复检查,对侧无法入队或出队,条件就永远不会改变。

唤醒只表示条件可能成立,不是预留了一个元素或空位。Java 还允许虚假唤醒;Go 的条件等待由通知唤醒,但同样不保证醒来后优先抢到锁,其他同侧调用可能已经占用了资源。因此两种实现都必须在循环里复检满或空,只有当前持锁检查仍满足时才执行操作。这与 Java Condition 和 Go Cond 的等待语义一致。

入队完整更新后,通知一个等待非空的消费者;出队完整更新后,通知一个等待非满的生产者。两类等待者分开,才能把刚产生的一份资源通知给能使用它的对侧。所有退出路径通过 finally 或 defer 解锁;size 也用同一把锁读取,得到的是读取时刻的有效快照。

Go 的两个条件对象持有 &q.mu,构造函数因此返回队列指针,之后也不能复制这个队列值。否则新对象中被加锁的互斥量可能与条件对象仍引用的旧锁不同,破坏整套同步关系。

解题步骤

  1. 初始化容量对应的数组、头尾下标和数量,创建互斥锁及绑定它的非满、非空两个条件。
  2. 入队取得锁后,循环等待队列非满;写入尾部、环形推进尾指针并增加数量,再通知非空条件。
  3. 出队取得锁后,循环等待队列非空;取出头部、环形推进头指针并减少数量,再通知非满条件。
  4. 完成或异常退出时都释放锁;出队返回刚取出的值。
  5. 查询数量时同样加锁读取,再解锁返回。

代码实现

class BoundedBlockingQueue {
    private final int[] data;
    private int head;
    private int tail;
    private int size;
    private final java.util.concurrent.locks.ReentrantLock lock;
    private final java.util.concurrent.locks.Condition notFull;
    private final java.util.concurrent.locks.Condition notEmpty;

    public BoundedBlockingQueue(int capacity) {
        this.data = new int[capacity];
        this.lock = new java.util.concurrent.locks.ReentrantLock();
        // 两个条件变量必须派生自同一把锁。
        this.notFull = lock.newCondition();
        this.notEmpty = lock.newCondition();
    }

    public void enqueue(int element) throws InterruptedException {
        lock.lock();

        try {
            // 必须用 while:应对虚假唤醒,以及醒来后空位被同侧线程抢走。
            while (size == data.length) {
                notFull.await();
            }

            data[tail] = element;
            tail = (tail + 1) % data.length;
            size++;

            // 状态更新完成后再唤醒对侧。
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public int dequeue() throws InterruptedException {
        lock.lock();

        try {
            // 消费者同样循环复检,醒来时元素可能已被其他消费者取走。
            while (size == 0) {
                notEmpty.await();
            }

            int val = data[head];

            head = (head + 1) % data.length;
            size--;

            notFull.signal();

            return val;
        } finally {
            lock.unlock();
        }
    }

    // 只读查询也必须和入队、出队使用同一把锁。
    public int size() {
        lock.lock();

        try {
            return size;
        } finally {
            lock.unlock();
        }
    }
}
import "sync"

type BoundedBlockingQueue struct {
    cap      int
    data     []int
    head     int
    tail     int
    size     int
    mu       sync.Mutex
    notFull  *sync.Cond
    notEmpty *sync.Cond
}

// 必须返回指针:Cond 持有 &q.mu,返回值拷贝会让锁与条件变量指向不同的对象。
func Constructor(capacity int) *BoundedBlockingQueue {
    q := &BoundedBlockingQueue{
        cap:  capacity,
        data: make([]int, capacity),
    }
    q.notFull = sync.NewCond(&q.mu)
    q.notEmpty = sync.NewCond(&q.mu)
    return q
}

func (q *BoundedBlockingQueue) Enqueue(element int) {
    q.mu.Lock()
    defer q.mu.Unlock()
    // 必须用 for:醒来后重新抢锁时,空位可能已被同侧 goroutine 抢走。
    for q.size == q.cap {
        q.notFull.Wait()
    }

    q.data[q.tail] = element
    q.tail = (q.tail + 1) % q.cap
    q.size++

    // 状态更新完成后再唤醒对侧。
    q.notEmpty.Signal()
}

func (q *BoundedBlockingQueue) Dequeue() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    // 消费者同样循环复检,醒来时元素可能已被其他消费者取走。
    for q.size == 0 {
        q.notEmpty.Wait()
    }

    val := q.data[q.head]
    q.head = (q.head + 1) % q.cap
    q.size--

    q.notFull.Signal()
    return val
}

// 只读查询也必须和入队、出队使用同一把锁。
func (q *BoundedBlockingQueue) Size() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    return q.size
}

复杂度分析

  • 时间复杂度:构造初始化数组为 $O(capacity)$。成功入队、出队的实际数据更新及一次数量读取为 $O(1)$;等待、锁竞争和重新唤醒可能持续多久,取决于其他调用,不能承诺整次调用恒定时间。
  • 空间复杂度:队列自身保存 $O(capacity)$ 个槽位,锁和条件对象数量固定;等待线程的运行时开销不计入数组存储。

关键点总结

[!green]

  • 环形数组保持 FIFO,数量区分头尾重合时的空与满。
  • 条件检查和状态更新都使用同一把锁,等待期间则必须释放锁。
  • 被通知后重新加锁并循环检查,通知不代表资源已经归当前线程所有。
  • 等待本侧需要的条件,完成后通知对侧,所有返回路径保证解锁。
  • Go 的条件对象和互斥量必须保持绑定在同一个队列实例上。

易错点总结

[!yellow]

  • 用 if 代替循环等待,醒来后可能在队列再次变满或变空时继续错误操作。
  • 持锁空转等待,阻止对侧线程取得锁改变条件,造成无法推进。
  • 只给修改方法加锁,却无同步地读取数量,仍会和并发写入发生竞争。
  • Java 等待被中断后没有通过 finally 解锁,会把其他调用永久挡在锁外。
  • 入队后通知非满、出队后通知非空,唤醒了不能利用新状态的错误一侧。
  • 复制 Go 队列值后继续使用,会让条件等待和普通操作可能锁住不同的互斥量。

相似题目

题目 难度 关联与区别
622. 设计循环队列 中等 普通有界队列满或空时直接报告失败,本题需要阻塞并在条件变化时唤醒等待线程。
1115. 交替打印 FooBar 中等 同样通过等待与通知控制可执行时机,本题条件由队列容量和元素数决定,而非固定交替。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2022/11043513
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!