阻塞队列体系:不是队列满了才处理,是从设计上就不让你空转(025)

集合框架、泛型与函数式编程约 10 分钟阅读5,634 字符
知识库文档 26 / 251

覆盖考点:025|阻塞队列体系:BlockingQueue 四组方法与三种典型实现

适合读者:高级 Java 工程师、技术专家、架构师候选人

预计阅读时长:26 分钟(文末问答不计入)

上一篇讲的 CopyOnWriteArrayList 解决的是"并发读"的问题,这一篇要讲的 BlockingQueue 解决的是并发编程里另一个极其常见的结构——生产者-消费者模型。很多人知道 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 这几个名字,但说不清楚它们锁的粒度有什么区别、offer() 和 put() 混用会有什么后果,以及为什么"默认无界"这个看起来很贴心的设计,恰恰是很多线程池 OOM 事故的根源。这篇把 BlockingQueue 体系从接口设计一直讲到三种典型实现的底层差异。

面试官在这个考点上通常这样追问:为什么需要阻塞队列,普通 Queue 有什么不足 → BlockingQueue 的四组方法(抛异常/返回特殊值/阻塞/超时)分别适用什么场景 → ArrayBlockingQueue 和 LinkedBlockingQueue 底层结构和锁粒度有什么区别 → PriorityBlockingQueue 的出队顺序是怎么决定的 → 为什么很多线程池 OOM 事故都跟 LinkedBlockingQueue 有关 → 三种阻塞队列该怎么选。本文按这条链展开。

一、为什么需要阻塞队列

生产者-消费者模型是并发编程里最常见的结构:一个或多个线程生产数据放进队列,一个或多个线程从队列里取数据处理。如果用一个普通的 Queue,会立刻碰到两个麻烦:

  1. 消费者要不断轮询。 队列为空时,Queue 没有"等待新数据"的能力,消费者只能写一个循环不停调用 isEmpty()/poll() 检查,查不到就再查一次——这是纯粹的忙等(busy-wait),白白消耗 CPU。
  2. 生产者可能把队列压爆。 如果用无界的普通集合当队列,生产速度长期超过消费速度,队列会无限增长,最终内存溢出。

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 二叉小顶堆 无界,自动扩容 一把锁,读写互斥 按优先级,非入队顺序

七、常见误区

  1. 以为 LinkedBlockingQueue 默认无界很"安全"。 恰恰相反——如果生产速度长期大于消费速度,这个近似无界的队列会持续增长直到内存溢出。很多线程池 OOM 事故的根源就是:用 Executors.newFixedThreadPool() 时默认搭配了未指定容量的 LinkedBlockingQueue,任务提交速度超过处理速度,队列无限堆积,最终把内存吃满。
  2. 以为 PriorityBlockingQueue 的 put() 会阻塞。 因为它无界且自动扩容,put() 实际上永远不会因为队列满而挂起。
  3. 混淆 offer() 和 put()。 队列满时,offer() 直接返回 false(快速失败,数据可能被静默丢弃),put() 会让线程一直等待。选错方法,系统在高负载下的表现完全不同:一个是"任务悄悄丢了",一个是"线程卡住了"。
  4. 以为 ArrayBlockingQueue 和 LinkedBlockingQueue 高并发性能一样。 因为锁粒度不同——前者读写互斥,后者读写可以并行——在读写都频繁的场景下,LinkedBlockingQueue 通常有更高的吞吐量。

八、面试追问链

文章配图
文章配图

延伸阅读

  • ArrayBlockingQueue/LinkedBlockingQueue/PriorityBlockingQueue 源码(JDK java.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,但要接受它无界这个特点,做好上游限流,避免无限堆积。