BlockingQueue
BlockingQueue 是支持等待的队列接口:队满时生产者可以等待,队空时消费者可以等待。本文先比较入队与出队方法,再说明有界队列怎样限制积压。随后介绍生产者消费者模型、常见实现和停机方式。实现细节以 OpenJDK 21 为准。
1. 面试核心
先记住这几条,再读正文- 阻塞与超时。
put在队满时等待,take在队空时等待,限时offer、poll会在超时后返回失败结果。卡 · 入队与出队方法 - 缓冲容量。
ArrayBlockingQueue容量固定,LinkedBlockingQueue默认上限为Integer.MAX_VALUE。卡 · 有界队列 - 直接交接。
SynchronousQueue容量为 0,插入与取出直接交接。卡 · 有界队列 - 安全发布。 生产者入队前的写入 happens-before 消费者取出该元素后的动作,因此消费者能看到已发布的字段值。卡 · 任务发布与后续修改
- 背压。 有界队列在积压达到容量上限时让生产者等待或拒绝入队,将消费速度不足转化为背压。
2. 入队与出队方法
队满或队空时,调用方需要决定等待还是立即返回。BlockingQueue 为同一操作提供四种处理方式:
| 操作 | 抛异常 | 返回特殊值 | 一直等待 | 限时等待 |
|---|---|---|---|---|
| 插入 | add | offer | put | offer(e, timeout, unit) |
| 取出 | remove | poll | take | poll(timeout, unit) |
| 查看队首 | element | peek | 不适用 | 不适用 |
BlockingQueue<Job> queue = new ArrayBlockingQueue<>(1_000);
queue.put(job); // 满时等待,可被中断
Job next = queue.take(); // 空时等待,可被中断
无等待时间参数的 offer 入队失败时返回 false。poll 在没有可领取元素时返回 null,peek 则在队列为空时返回 null。上例使用 put、take,所以调用线程会等待空间或任务出现,也可以通过中断退出等待。
选择方法时,需要明确允许调用线程等多久,以及超时后由谁处理任务。任务交接失败后,调用方仍要决定重试、拒绝或转存。
3. 有界队列
生产速度长期高于消费速度时,尚未处理的任务会持续积压。有界队列用容量限制积压数量,满队列时再让生产者等待或处理失败。这种把下游压力传回上游的方式称为背压。下面只等待 100 毫秒,超时后由调用方拒绝或转存任务:
boolean accepted = queue.offer(job, 100, TimeUnit.MILLISECONDS);
if (!accepted) {
rejectOrPersist(job);
}
队列达到容量上限后,业务可以选择以下处理方式:
- 阻塞生产者,让上游自然降速。
- 等待有限时间后失败。
- 拒绝低优先级任务。
- 持久化到可靠队列。
- 由调用线程处理,传播压力。
缓冲可以吸收短时峰值,让消费者稍后处理积压。持续过载时,加大队列只会推迟填满的时间,还需要降低生产速度、拒绝任务或增加消费能力。
4. 生产者消费者模型
生产者负责提交任务,消费者负责领取并处理任务。双方通过队列交接,消费者可以在没有任务时等待。下面的消费者在收到中断后退出:
final class Worker implements Runnable {
private final BlockingQueue<Job> queue;
Worker(BlockingQueue<Job> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
Job job = queue.take();
process(job);
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
}
}
take 在队空时等待,生产者入队后,队列会唤醒等待的消费者。队列还负责对象发布,也就是让接收线程看到任务入队前已经写好的字段。这样,业务代码可以集中处理任务,无需自行实现等待和唤醒。
示例捕获 InterruptedException 后恢复中断标记,随后结束 run。除了正常交接,业务还需定义失败与停机规则:
process失败是否重试。- 重试是否保持顺序。
- 任务是否可以重复执行。
- 进程崩溃时内存任务是否允许丢失。
- 停机时等待、排空还是转存。
5. 常见实现
选择实现时,先确定是否需要缓冲,以及按什么顺序取出任务。数组、链表、优先级和延迟队列提供的容量与取出规则各不相同。
5.1 ArrayBlockingQueue
ArrayBlockingQueue 在构造时确定容量,适合需要明确积压上限的任务缓冲。OpenJDK 21 使用循环数组,入队和出队位置到达数组末端后回到开头,因此可以复用槽位。每次入队无需创建链表节点,节点分配开销较少。
生产和消费共用一把锁,并分别等待“队列非满”和“队列非空”两个条件。构造时可选择公平模式,让已经等待的线程按先后顺序获得访问机会。
5.2 LinkedBlockingQueue
LinkedBlockingQueue 用链式节点保存任务,构造时可以指定容量。省略容量时,上限为 Integer.MAX_VALUE,实际使用中内存会先成为瓶颈。
OpenJDK 21 分别使用 put 锁和 take 锁协调入队与出队,让生产和消费有机会同时进行。代价是每次入队都需要分配节点,因此要一起衡量并行收益与对象分配开销。
5.3 SynchronousQueue
SynchronousQueue 用于直接交接任务,容量为 0。生产者成功交出一个元素时,必须有消费者接收它。put、take 可以等待另一方,无等待时间参数的 offer、poll 则在无法立即配对时返回失败结果。它适合把任务直接移交给消费者,积压需要由队列之外的机制处理。
5.4 PriorityBlockingQueue
PriorityBlockingQueue 适合按优先级领取任务,逻辑上无界。它可以在没有任务时阻塞消费者,但不会因容量已满而让生产者等待。同优先级元素的取出次序未指定,也就是没有先进先出(FIFO)保证。比较器应使用稳定字段,避免入队后修改优先级破坏排序。
5.5 DelayQueue
DelayQueue 适合在同一进程内延迟触发任务。元素实现 Delayed,用 getDelay 给出剩余延迟,并定义元素的比较顺序。消费者只能领取已经到期的元素。
相对延迟可以用 System.nanoTime() 计时,避免系统日期时间调整改变等待时长。大量任务取消时,还需要及时移除或清理取消的任务,减少无用节点和对象的保留。进程重启后要恢复任务,则需要另外保存持久记录。
6. 中断、关闭与 poison pill
消费者正在等待任务时,需要明确的信号才能结束。BlockingQueue 没有统一的 close 方法,可以选择以下中断或结束信号:
- 中断消费者线程。
- 发送特殊结束元素,即 poison pill。
- 外部关闭标志配合限时 poll。
- 由拥有线程的 ExecutorService 执行关闭协议。
使用 poison pill 时,要保证结束信号能够到达每个消费者:
- 结束值不会与普通业务任务混淆。
- 多个消费者收到足够数量的结束信号。
- 队列已满时仍能完成停机。
- 不会越过仍需处理的任务。
线程池任务的停机应通过 ExecutorService 的关闭方法协调,直接操作它的内部队列会绕过线程池的生命周期管理。自定义消费者则需自行定义结束协议,包括何时停止生产、怎样处理剩余任务。
7. 内存队列与消息队列
BlockingQueue 负责同一 JVM 内的线程交接,任务保存在进程内存中。进程崩溃后,这些任务会丢失。业务需要以下可靠传递能力时,应交给持久消息系统实现:
- 崩溃后恢复尚未完成的任务。
- 跨进程或跨服务传递任务。
- 确认消费结果,将持续失败的消息放入死信队列,并按需重放。
- 按分区扩展处理能力,并通过副本容忍节点故障。
如果只需要持久保存和领取任务,也可以使用数据库任务表。内存队列仍可作为消息消费者内部的有界缓冲,但确认时机要与业务完成协议一致。例如,刚放进内存就确认外部消息,随后进程崩溃,这条任务就会丢失。
8. 任务发布与后续修改
队列的可见性保证覆盖入队前的写入,这个先后关系称为 happens-before。例如,生产者先设置 job.payload,再执行 queue.put(job)。消费者成功取出该对象后,就能读取已经写好的 payload。
入队后的字段修改需要另行同步,因为队列传递的是同一个对象的引用。生产者继续修改 Job 时,就会与消费者形成读写竞争。可以使用不可变任务,或在入队后转移对象所有权,也就是让生产者停止访问,由消费者接管。
9. 常见问题
9.1 LinkedBlockingQueue 的默认容量
默认上限是 Integer.MAX_VALUE,生产者很难及时收到队满信号。消费变慢时,节点就会持续积压,增加堆占用和垃圾回收(GC)压力。积压严重时会造成长时间 GC,甚至内存溢出(OOM)。服务端线程池和任务管道应按可承受的积压量显式设置容量。
9.2 容量检查与入队竞态
调用 remainingCapacity() 后,其他生产者仍能抢先填满队列,所以随后执行 put 仍会等待。直接调用 offer 或限时 offer,让队列原子地完成容量判断与入队,再处理返回值。
9.3 任务重试与幂等
业务提交后、记录完成前发生故障,重试就会再次处理同一任务。调用方重复入队也会产生相同问题。因此,业务需要用幂等键和状态记录识别重复任务,保证重复执行仍得到同一业务结果。涉及消息与事务协调时,还需要相应的事务消息协议。
9.4 公平模式与吞吐
ArrayBlockingQueue 的公平模式按等待顺序协调访问,减少某些线程长期等待的机会。公平排队会限制线程抢先执行,因此需要权衡等待公平与吞吐。业务明确要求等待公平时再开启,并通过实际负载验证效果。
10 · 面试题卡片
10.1 怎样用 BlockingQueue 实现生产者消费者
出现公司:快手
考察重点
- 说明
put、take分别何时等待,以及收到中断后如何结束等待。 - 说明有界容量怎样形成背压。
- 说明任务失败后由谁重试、停机时怎样处理积压,以及进程崩溃后任务能否恢复。
相关内容:入队与出队方法至中断、关闭与 poison pill。
参考回答
生产者用 put 或限时 offer 向有界队列提交任务,消费者循环调用 take 领取并处理。
put 在队满时等待,限时 offer 超时后返回失败,消费者则在队空时等待。因此,积压会反过来限制生产速度,队列也保证消费者能看到入队前写好的任务字段。
消费者可以在捕获中断后恢复标记并退出,也可以识别专用结束元素后停止。任务提交失败由生产者处理,消费重试与幂等则由业务协议保证。停机时还要决定排空还是转存任务;进程崩溃后的恢复需要持久存储。
10.2 ArrayBlockingQueue、LinkedBlockingQueue 和 SynchronousQueue 怎样选择
出现公司:去哪儿、快手
考察重点
- 说明固定数组、链式节点和零容量交接分别怎样保存或传递任务。
- 说明容量、分配、锁竞争与背压的关系。
- 解释默认容量为何难以及时产生背压,以及持续积压会怎样影响内存。
参考回答
固定容量且希望减少节点分配时,选择 ArrayBlockingQueue。
需要链式缓冲时,可使用有界 LinkedBlockingQueue。无需缓冲、只做直接交接时,使用 SynchronousQueue。
OpenJDK 21 的 ArrayBlockingQueue 让生产和消费共用一把主锁。LinkedBlockingQueue 分别使用 put 锁和 take 锁,允许入队与出队重叠,但每次入队需要分配节点。它的默认容量为 Integer.MAX_VALUE,因此服务端应显式限定容量,并定义队满后的超时或拒绝方式。吞吐量还要结合实际负载验证。
10.3 任务入队后继续修改字段,消费者能安全读取吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明队列的 happens-before 保证覆盖哪些写入,以及怎样通过对象所有权避免后续竞争。
相关内容:任务发布与后续修改。
参考回答
消费者安全读取入队后的字段修改,需要生产者与消费者另行同步。
队列建立的 happens-before 关系只覆盖生产者入队前的写入。
双方持有的是同一个任务对象,生产者在入队后继续写入,就会与消费者的读取竞争。使用不可变任务,或约定入队后只由消费者访问,可以避免这种竞争。