覆盖考点:025|阻塞队列体系:BlockingQueue 四组方法与三种典型实现
适合读者:高级 Java 工程师、技术专家、架构师候选人
预计阅读时长:26 分钟(文末问答不计入)
上一篇讲的 CopyOnWriteArrayList 解决的是"并发读"的问题,这一篇要讲的 BlockingQueue 解决的是并发编程里另一个极其常见的结构——生产者-消费者模型。很多人知道 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 这几个名字,但说不清楚它们锁的粒度有什么区别、offer() 和 put() 混用会有什么后果,以及为什么"默认无界"这个看起来很贴心的设计,恰恰是很多线程池 OOM 事故的根源。这篇把 BlockingQueue 体系从接口设计一直讲到三种典型实现的底层差异。
面试官在这个考点上通常这样追问:为什么需要阻塞队列,普通 Queue 有什么不足 → BlockingQueue 的四组方法(抛异常/返回特殊值/阻塞/超时)分别适用什么场景 → ArrayBlockingQueue 和 LinkedBlockingQueue 底层结构和锁粒度有什么区别 → PriorityBlockingQueue 的出队顺序是怎么决定的 → 为什么很多线程池 OOM 事故都跟 LinkedBlockingQueue 有关 → 三种阻塞队列该怎么选。本文按这条链展开。
一、为什么需要阻塞队列
生产者-消费者模型是并发编程里最常见的结构:一个或多个线程生产数据放进队列,一个或多个线程从队列里取数据处理。如果用一个普通的 Queue,会立刻碰到两个麻烦:
- 消费者要不断轮询。 队列为空时,
Queue没有"等待新数据"的能力,消费者只能写一个循环不停调用isEmpty()/poll()检查,查不到就再查一次——这是纯粹的忙等(busy-wait),白白消耗 CPU。 - 生产者可能把队列压爆。 如果用无界的普通集合当队列,生产速度长期超过消费速度,队列会无限增长,最终内存溢出。
BlockingQueue 从设计上直接解决了这两个问题:take() 在队列为空时会让消费者线程自动阻塞,一旦有新数据就被唤醒,不需要手写轮询;put() 在队列已满时会让生产者线程自动阻塞,直到有空间腾出来。等待和唤醒的逻辑被封装进了队列本身,业务代码只需要调用 take()/put(),不用关心"什么时候该等,什么时候该继续"。

二、BlockingQueue 的四组方法:四种不同的"面对满/空"策略
BlockingQueue 接口对同一个操作(入队/出队)提供了四组风格完全不同的方法,核心区别在于队列满了/空了之后怎么处理:
| 操作 | 抛异常 | 返回特殊值 | 阻塞 | 超时 |
|---|---|---|---|---|
| 入队 | add(e) |
offer(e) |
put(e) |
offer(e, time, unit) |
| 出队 | remove() |
poll() |
take() |
poll(time, unit) |
| 查看队首 | element() |
peek() |
不适用 | 不适用 |
- 抛异常组:队列满时
add()抛IllegalStateException,队列空时remove()抛NoSuchElementException。 - 返回特殊值组:队列满时
offer()直接返回false,队列空时poll()直接返回null,不阻塞、不抛异常,业务代码自己判断返回值。 - 阻塞组:
put()/take()会让线程一直等,直到条件满足(有空间/有数据)。 - 超时组:介于"永久阻塞"和"立刻返回"之间,最多等指定时间,超时了再返回失败结果。
选错方法在高负载场景下会有完全不同的表现:用了 put() 而不是 offer(),队列满时线程会被无限期挂起而不是快速失败;用了 offer() 而不是 put(),则会在队列满时悄悄丢弹(返回 false 却没人处理),业务上表现为"数据不见了"。

三、ArrayBlockingQueue:定长环形数组 + 一把锁
ArrayBlockingQueue 底层是一个创建时就固定长度、不能扩容的数组,用作环形缓冲区(circular buffer):内部维护 takeIndex(下一个出队位置)和 putIndex(下一个入队位置)两个指针,在数组里循环前进,到了数组末尾就绕回头部,避免了"删除头部元素后整体搬移"的开销。
并发控制上,ArrayBlockingQueue 用一把**ReentrantLock** 同时保护入队和出队操作——也就是说,同一时刻,不管是 put() 还是 take(),都要抢这同一把锁,两者互相排斥。锁上挂了两个 Condition:
notEmpty:队列为空时,take()在这个条件上await();只要有新元素入队成功,就signal(notEmpty)唤醒等待的消费者。notFull:队列已满时,put()在这个条件上await();只要有元素被取走,就signal(notFull)唤醒等待的生产者。


四、LinkedBlockingQueue:链表结构 + 两把独立的锁
LinkedBlockingQueue 底层是单向链表,可以指定容量上限,如果构造时不传容量参数,默认是 Integer.MAX_VALUE,相当于近似无界——这是后面要讲的一个重要陷阱。
它和 ArrayBlockingQueue 最本质的区别在锁的粒度:LinkedBlockingQueue 用了两把独立的锁——takeLock 只保护出队操作,putLock 只保护入队操作,彼此互不影响。这意味着同一时刻可以有一个线程在入队、另一个线程在出队,两者真正并行,不像 ArrayBlockingQueue 那样读写互相排斥。当前元素个数用一个 AtomicInteger(count)来记录,因为两把锁都可能同时修改这个计数,需要用原子类保证。

五、PriorityBlockingQueue:不排队,排优先级
PriorityBlockingQueue 底层是一个基于数组实现的二叉小顶堆,和 PriorityQueue 的堆结构完全一样,只是加上了阻塞语义。它彻底打破了"先进先出"——每次 take()/poll() 拿到的,永远是当前堆中按 Comparable(或传入的 Comparator)比较出来的"最小"(也就是优先级最高)的元素,跟入队顺序无关。
它是一个无界队列:堆数组会在容量不足时自动扩容,因此 put() 永远不会因为"队列满了"而阻塞——PriorityBlockingQueue 内部只有 notEmpty 这一个条件,没有 notFull。并发控制上和 ArrayBlockingQueue 类似,用一把锁保护堆的读写。
需要注意的是:优先级队列不保证同优先级元素之间的相对顺序,堆结构本身不是稳定排序结构,两个 compareTo() 返回 0 的元素谁先出队是不确定的。

六、三者对比一览
| 底层结构 | 是否有界 | 锁粒度 | 出队顺序 | |
|---|---|---|---|---|
| ArrayBlockingQueue | 定长环形数组 | 有界,创建时固定 | 一把锁,读写互斥 | 先进先出 |
| LinkedBlockingQueue | 单向链表 | 可指定,默认近似无界 | 两把独立锁,读写并行 | 先进先出 |
| PriorityBlockingQueue | 二叉小顶堆 | 无界,自动扩容 | 一把锁,读写互斥 | 按优先级,非入队顺序 |
七、常见误区
- 以为 LinkedBlockingQueue 默认无界很"安全"。 恰恰相反——如果生产速度长期大于消费速度,这个近似无界的队列会持续增长直到内存溢出。很多线程池 OOM 事故的根源就是:用
Executors.newFixedThreadPool()时默认搭配了未指定容量的LinkedBlockingQueue,任务提交速度超过处理速度,队列无限堆积,最终把内存吃满。 - 以为 PriorityBlockingQueue 的 put() 会阻塞。 因为它无界且自动扩容,
put()实际上永远不会因为队列满而挂起。 - 混淆 offer() 和 put()。 队列满时,
offer()直接返回false(快速失败,数据可能被静默丢弃),put()会让线程一直等待。选错方法,系统在高负载下的表现完全不同:一个是"任务悄悄丢了",一个是"线程卡住了"。 - 以为 ArrayBlockingQueue 和 LinkedBlockingQueue 高并发性能一样。 因为锁粒度不同——前者读写互斥,后者读写可以并行——在读写都频繁的场景下,
LinkedBlockingQueue通常有更高的吞吐量。
八、面试追问链

延伸阅读
ArrayBlockingQueue/LinkedBlockingQueue/PriorityBlockingQueue源码(JDKjava.util.concurrent包)- 上一期:《CopyOnWriteArrayList:不是复制数据省内存,是复制数组换安全》
- 《Java 并发编程实战》第 5 章:并发容器
问答(不计入阅读时长)
Q1:阻塞队列和普通队列最本质的区别是什么?
A:普通 Queue 在队列为空或已满时只能立刻返回失败结果(或抛异常),没有"等待"的能力,所以消费者只能自己写循环轮询,白白消耗 CPU;BlockingQueue 把"等待"直接内置到了 put()/take() 里——队列为空时 take() 自动挂起线程,队列满时 put() 自动挂起线程,等条件满足后被自动唤醒,业务代码不用手写任何轮询逻辑。
Q2:BlockingQueue 的四组方法,什么时候该用哪一组?
A:需要"绝不能失败、失败就是 bug"的场景用抛异常组(很少用,因为异常处理成本高);需要"失败了自己决定怎么处理"用返回特殊值组(offer/poll),比如限流场景里队列满了就直接丢弃或走降级逻辑;需要"必须等到成功为止"用阻塞组(put/take),比如线程池内部任务队列;需要"最多等一会儿,等太久就放弃"用超时组,比如带超时控制的任务提交。
Q3:ArrayBlockingQueue 为什么用环形数组而不是普通数组?
A:如果用普通数组,出队时把头部元素删掉,后面所有元素都要往前搬移一位,删除操作是 O(n)。环形数组用 takeIndex/putIndex 两个指针标记当前的头尾位置,出队只需要把 takeIndex 往后移一位(到末尾就绕回开头),完全不需要搬移数据,出队和入队都是 O(1)。
Q4:ArrayBlockingQueue 的 notEmpty 和 notFull 是两把锁吗?
A:不是。notEmpty 和 notFull 是同一把 ReentrantLock 上创建的两个不同的 Condition,本质上还是同一把锁在管理,只是把"等待队列空"和"等待队列满"这两种不同的等待场景分开管理,避免用 notify() 时误唤醒错误的等待线程。加锁互斥的范围没有变化,put() 和 take() 依然共享同一把锁。
Q5:LinkedBlockingQueue 用两把锁,会不会导致数据不一致?
A:不会。takeLock 只保护出队相关的状态(比如从链表头部取节点),putLock 只保护入队相关的状态(比如往链表尾部加节点),两者操作的是链表的不同端点,不会互相踩踏。唯一需要跨两把锁保护的是元素计数,所以用 AtomicInteger 来记录,不依赖任何一把锁就能保证计数的原子性。
Q6:为什么很多线程池 OOM 事故都跟 LinkedBlockingQueue 有关?
A:因为 Executors.newFixedThreadPool()/newSingleThreadExecutor() 等工厂方法内部默认用的就是不指定容量的 LinkedBlockingQueue,容量默认是 Integer.MAX_VALUE,相当于近似无界。如果任务提交速度长期超过线程处理速度,队列会无限堆积任务对象,而不会像有界队列那样触发拒绝策略,最终把堆内存耗尽。生产环境应该显式指定队列容量,配合合理的拒绝策略。
Q7:PriorityBlockingQueue 取出的元素一定是全局最小的吗?
A:是的,只要正确实现了 Comparable 或传入了 Comparator,每次 take()/poll() 拿到的就是当前堆中"最小"(优先级最高)的那个元素。但要注意两点:它不保证入队顺序,也不保证同优先级元素之间的相对顺序——两个 compareTo() 结果相等的元素,谁先出队是不确定的,不能依赖这个顺序做业务逻辑。
Q8:三种阻塞队列该怎么选?
A:吞吐量要求高、大小可以提前规划好,用 ArrayBlockingQueue,内存占用稳定且没有动态扩容开销;读写都很频繁、想要更高并发度,用 LinkedBlockingQueue 并显式指定容量;业务上有明确的任务优先级需求(比如需要让高优先级任务插队、跳过普通排队顺序优先处理),用 PriorityBlockingQueue,但要接受它无界这个特点,做好上游限流,避免无限堆积。