1. 它到底是什么:一个被名字“剧透”完的队列
有段时间我在调一个异步订单处理模块,生产者是网关回调,消费者是业务落库,中间用了一个无界队列做缓冲。结果大促流量一上来,队列直接吃掉十几个G内存,把整台机器打到Full GC。后来把缓冲换成了ArrayBlockingQueue,限定了容量上限,问题立刻变了——不再是内存爆炸,而是要考虑“满了怎么办”,这就是有界队列存在的意义。
ArrayBlockingQueue这个名字其实把关键信息都写在脸上了:底层是数组(Array),容量是固定的(Blocking表示满了会等),队列语义是FIFO(先进先出)。它是java.util.concurrent包里最基础、也最容易被低估的一个有界阻塞队列实现。
适合谁看?想搞懂并发容器原理的开发者、正在用线程池做异步任务的团队、以及一切被“无界队列导致OOM”坑过的人。
它解决的问题本质上是生产速度和消费速度不一致时的流量削峰问题。生产者太快了,队列蓄水;消费者追上了,队列放空。ArrayBlockingQueue提供的阻塞语义保证了:队列满的时候,生产者线程不会继续无脑往里塞,而是阻塞等待;队列空的时候,消费者线程不会空转消耗CPU,而是休眠等待唤醒。
从接口实现上看,它实现了BlockingQueue<E>接口,这个接口定义了put/take/offer/poll等核心方法。其中put和take是阻塞版本的,offer和poll可以传入超时时间,超时后返回特殊值而不是永久阻塞。这种API设计让使用方可以根据业务场景灵活选择“死等”还是“超时放弃”。
不过说实话,ArrayBlockingQueue虽然API简单,内部实现里的门道并不少。从环形数组的指针设计到锁和条件变量的协作,从公平锁到迭代器的弱一致性,这些细节才是真正区分“会用”和“懂它”的地方。
2. 数组 + 环形指针:为什么是数组而不是链表
先说数据结构。ArrayBlockingQueue内部维护了一个定长数组,三个关键字段:
final Object[] items:存放队列元素的数组,长度在构造时确定,永远不变。int takeIndex:下一个被取走元素的位置。int putIndex:下一个被放入元素的位置。
另外还有一个count字段记录当前元素数量。
这三个字段的组合方式很有意思。入队时,元素放在items[putIndex]位置,然后putIndex = (putIndex + 1) % items.length;出队时,从items[takeIndex]位置取,然后takeIndex = (takeIndex + 1) % items.length。当指针走到数组末尾时,通过取模运算绕回头部,这就是环形数组(Circular Buffer)的经典实现。
为什么选数组而不是链表?这个问题我经常在团队里被问到。核心原因有两个:
一是内存紧凑性。数组在内存里是一段连续空间,每个元素之间没有额外的节点开销。链表每个节点要维护前驱后继两个引用(双向链表甚至更多),在同样容量下,数组省内存,而且局部性更好——CPU缓存命中率高。这在吞吐量敏感的场景下有实际意义。
二是容量本质不同。数组的长度是创建时就确定死的,不能扩容。这听起来像个缺点,但恰恰是它“有界”语义的根基。如果底层是链表并且允许无限增长,那“有界”就成了一句空话。ArrayBlockingQueue用数组这个物理结构,从底层兜底保证了队列长度不会超过构造时设定的值——这个“不可变容量”不是约定俗成,而是结构上强制保证的。
入队出队的指针移动用取模运算实现,有人会担心%运算的性能。其实这里JDK做了个细节优化:在inc方法里,用的是(++putIndex == items.length) ? 0 : putIndex这样的判断而不是取模,因为putIndex只会加一,判断是否到末尾比每次做一次取模运算更快。这种微小的优化在单次操作上看不出来,但在千万级并发操作下能省下不少CPU周期。
关于环形数组的另一个值得注意的点是:数组不清理已取出元素的引用。items[takeIndex] = null这一步是必须做的,如果漏了,即使元素已经被take走了,它依然被数组强引用着,会导致内存泄漏——尤其是当存储的是大对象或者持有外部资源的对象时。这个问题的严重性在长生命周期队列中尤其明显,后面我会专门说这个坑。
3. 锁与条件变量:一个持锁者如何协调两侧等待
ArrayBlockingQueue的线程安全机制用一个词概括就是“单锁双条件”。
内部只有一个ReentrantLock lock,所有入队出队操作都必须持锁。这个锁又派生出了两个Condition:
notEmpty:等待队列非空的条件。消费者线程在这个条件上等待。notFull:等待队列非满的条件。生产者线程在这个条件上等待。
这个设计和LinkedBlockingQueue有本质区别——后者用的是“两把锁”:一个是putLock管入队,一个是takeLock管出队,生产者之间竞争putLock,消费者之间竞争takeLock,生产和消费可以并行。而ArrayBlockingQueue在入队和出队时用的是同一把锁,所以在任何时刻,要么有一个线程在入队,要么有一个线程在出队,生产和消费永远不可能真正并发。
这就引出一个经典问题:为什么ArrayBlockingQueue不做成双锁?网上调研说Doug Lea本人被问过这个问题,回答大意是:数组队列的出队和入队操作都涉及takeIndex和putIndex的修改,且数组本身是共享结构,双锁需要额外增加复杂的协调逻辑(比如处理两个索引交错的情况),实现复杂度会指数上升。而链表队列天然可以把头尾分离成两个独立数据结构,拆锁的成本低。所以ArrayBlockingQueue选择牺牲一点并发度,换取实现复杂度的控制。
单锁双条件的工作流程可以用一个订单处理的场景来理解:队列相当于一个仓库,入库员(生产者)和出库员(消费者)共用同一扇门。门口一次只允许一个人进出。仓库满了,入库员在门口等着,等出库员拿走一件货之后,仓库管理员喊一声“有位置了”,入库员才进去;仓库空了,出库员在门口等着,等入库员放了一件货之后,管理员喊一声“有货了”,出库员才进去。
这个流程落到代码上就是put和take的经典实现逻辑:
put(E e)的核心流程:
- 加锁(可中断)。
while (count == items.length),在notFull条件上等待。- 队列不满,写入元素,更新
putIndex和count。 - 唤醒一个
notEmpty条件上等待的消费者。 - 释放锁。
take()的核心流程:
- 加锁(可中断)。
while (count == 0),在notEmpty条件上等待。- 队列不空,取出元素,更新
takeIndex和count,并将原位置置空。 - 唤醒一个
notFull条件上等待的生产者。 - 释放锁。
注意这里的while而不是if。这是条件等待的标准写法,为了防止“虚假唤醒”(spurious wakeup)和“信号丢失”问题。即使被唤醒,也需要重新检查条件是否满足。比如两个消费者同时被唤醒,但队列里只有一个元素,如果不用while而用if,第二个消费者就会拿到null。JDK源码里是标准的while (count == 0) notEmpty.await(),这个写法是并发编程的教科书级别示范,值得所有写多线程代码的人记住。
唤醒策略也有细节:signal用的是通知单个线程,而不是signalAll广播所有线程。因为每次入队或出队操作只改变一个元素的位置——生产一个唤醒一个消费者,消费一个唤醒一个生产者——唤醒一个就够了。如果每次都用signalAll,会造成大量线程无谓竞争锁,产生惊群效应,性能会大幅下降。
公平模式这块也值得提一下。构造时可以传入fair参数:new ArrayBlockingQueue<>(100, true)。公平模式下,锁会优先分配给等待时间最长的线程,代价是吞吐量下降;非公平模式下,新来的线程有可能插队成功,等待久的线程饿得更久,但吞吐量更高。实际场景中,除非有明确的公平性需求(比如某些业务对处理顺序有要求),否则默认非公平就好。我用非公平队列压测过吞吐量,比公平模式高出10%到20%不等。
4. 源码级拆解:put/take全流程与三个隐藏细节
这里我直接从JDK 8的源码聊起,把关键方法完整过一遍。注意JDK版本之间实现有差异,后文用的都是Java 8的版本。
put方法全流程
public void put(E e) throws InterruptedException { // 非空校验 checkNotNull(e); final ReentrantLock lock = this.lock; // 可中断加锁 lock.lockInterruptibly(); try { // 队列满了就在notFull上等 while (count == items.length) notFull.await(); // 写入元素 enqueue(e); } finally { lock.unlock(); } } private void enqueue(E x) { // 把元素放到putIndex上 final Object[] items = this.items; items[putIndex] = x; // 指针绕回 if (++putIndex == items.length) putIndex = 0; count++; // 通知一个等在notEmpty上的消费者 notEmpty.signal(); }注意lockInterruptibly()和lock()的区别:前者允许线程在等待锁的过程中响应中断,后者不允许。put和take都是阻塞型方法,所以必须支持中断,否则线程无法被外部打断——这是阻塞队列的一个通用约束,也是实现优雅关闭的关键。
checkNotNull(e)拒绝null元素。阻塞队列有个约定:poll在队列为空时返回null,offer在队列满时返回false,这些都用null作为特殊值。如果允许放入null,取的时候就没法区分“队列空了返回null”和“取到了null元素”,语义就混了。
take方法全流程
public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { // 队列空了就在notEmpty上等 while (count == 0) notEmpty.await(); // 取出元素 return dequeue(); } finally { lock.unlock(); } } private E dequeue() { final Object[] items = this.items; @SuppressWarnings("unchecked") E x = (E) items[takeIndex]; // 这一行很重要,释放引用防内存泄漏 items[takeIndex] = null; if (++takeIndex == items.length) takeIndex = 0; count--; // 通知一个等在notFull上的生产者 notFull.signal(); return x; }这段代码包含三个隐藏细节,很多教科书不会刻意提,但在实践中都很关键:
第一是items[takeIndex] = null这一行的内存语义。上面说过了,不清引用就会内存泄漏。在Java 8的ArrayBlockingQueue里这个动作是明确存在的,但在更早期的JDK版本里有过不同的处理方式。如果你自己实现一个环形缓冲队列,一定记得这个清理动作。简单说就是:取走元素后让数组引用断开,GC才能回收那个对象。我见过同事手写了一个环形队列,忘了这行,结果内存监控日志里老年代持续上涨,排查了半天。
第二是signal和signalAll的微秒级性能差异。前面提到过这个问题。JDK 8的dequeue方法用的是notFull.signal()。这两个方法在并发量大的场景里性能差距非常明显。signalAll会唤醒所有等待线程,这些线程会一起争抢锁,最终只有一个能抢到,其他全部重新挂起——代价是大量上下文切换和锁竞争,这在极端情况下能把CPU干到100%。所以阻塞队列内部几乎全部用signal,如果你在自己实现生产者消费者的等待逻辑,同样应该优先用signal而不是signalAll。
第三是强一致性的保证。因为所有操作都在同一把锁下完成,取元素时看到的队列状态必然是“某个生产者入队之后”的状态,不存在中间状态。锁本身保证了可见性——锁的释放会建立happens-before关系,一个线程入队的元素,另一个线程随后取走,必然能看到完整的数据。这也是单锁结构的一个优点:一致性模型极其简单,不需要考虑LinkedBlockingQueue那种双锁下两个条件之间的交叉唤醒问题。
offer和poll的超时版本
offer(E e, long timeout, TimeUnit unit)和put的区别在于:队列满时,它不会无限期等待,而是用awaitNanos等待指定时间,到期还没位置就返回false。
public boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException { checkNotNull(e); long nanos = unit.toNanos(timeout); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) { if (nanos <= 0) return false; nanos = notFull.awaitNanos(nanos); } enqueue(e); return true; } finally { lock.unlock(); } }注意awaitNanos返回的是剩余等待时间,也就是说它被唤醒后,nanos会被更新为“还剩多少时间”。这个返回值要接到循环里重新判断,而不是每次用初始的超时时间。很多新手写自己的超时逻辑时会忽略这点,直接在循环里用Thread.sleep或者错误地用awaitNanos(固定值),导致实际等待时间严重超过设定的超时时间。
超时版本的生产者语义是“在限定时间内尝试入队,做不到就放弃”,这其实是最贴合真实业务场景的做法。真正没完没了死等put的生产者场景其实很少,多数时候我们面对的是“这一批任务如果在500毫秒内进不了队列就降级处理”的需求。所以**offer(timeout)和poll(timeout)的出现频率远高于put/take**。
迭代器为什么不会抛ConcurrentModificationException
遍历别的集合时如果一边遍历一边修改,通常会收到ConcurrentModificationException。但ArrayBlockingQueue的迭代器比较特殊——它是弱一致性的,而且设计上明确支持“迭代过程中允许并发修改”。
实现思路是:迭代器通过快照记录当前takeIndex和count,每次next()时直接从底层数组读出对应位置的元素,读取过程不需要持有锁。但这里有个环形数组带来的麻烦:当putIndex绕回小于takeIndex时,数组的有效区域横跨了数组的尾部到头部,迭代器必须知道什么时候该停。
JDK的实现里,Itr维护了一个nextItem字段,提前缓存下一个元素。迭代开始时,它记录当前的takeIndex和剩余数量,然后按环形顺序遍历。因为数组只有固定长度,所以遍历必然会终止——最多走完一圈遇到count个元素就停止。
弱一致性带来的问题是:迭代过程中元素被并发取走或加入,迭代器不保证能看到“某个瞬间的完整快照”。你可能遍历到一半发现某些元素不见了(被take走了),或者明明添加了新元素但迭代器看不到(它只认自己初始化时看到的边界)。这在大多数场景下是能接受的——比如批量导出数据时,偶尔少一条或者多一条往往不影响最终结果,但如果你用迭代器做精确的数据统计,那就得小心了。
5. 实战选型:ArrayBlockingQueue和LinkedBlockingQueue到底怎么选
我知道很多人纠结过这个问题。线程池默认的队列是LinkedBlockingQueue,但很多场景用ArrayBlockingQueue反而更合适。这里我总结一段自己的选型经验。
| 维度 | ArrayBlockingQueue | LinkedBlockingQueue |
|---|---|---|
| 底层结构 | 定长数组(环形) | 单向链表(节点动态创建) |
| 容量 | 构造时必须指定,不可变 | 可以不指定(默认Integer.MAX_VALUE) |
| 锁 | 单锁,入队出队互斥 | 双锁,入队出队并行 |
| 内存 | 一次性分配连续内存,更紧凑 | 每个节点额外开销,节点动态创建 |
| 吞吐量 | 高竞争下单锁可能成为瓶颈 | 双锁降低竞争,通常更高 |
| 迭代器 | 弱一致性,支持并行读 | 弱一致性 |
| 语义控制 | 严格有界 | 不设容量就不是真“有界” |
如果你要的是“真的有界”,选ArrayBlockingQueue。这不是废话,而是很多人踩过的坑。LinkedBlockingQueue如果构造时不传容量,默认容量是Integer.MAX_VALUE,这基本等于无界。即使传了容量,因为链表节点是动态创建的,它的有界性依赖外部传参的使用习惯,而不是结构上强制的。数组结构的ArrayBlockingQueue在物理上就不可能超过容量——items数组的长度在构造时定死,放不进去就是放不进去。
如果你非常看重吞吐量,选LinkedBlockingQueue。双锁设计让生产和消费真正并行。我用一个数据缓冲场景做过对比压测:8个生产者线程,8个消费者线程,容量都设为1024,LinkedBlockingQueue的吞吐量比ArrayBlockingQueue高出约15%。但换来的是更大的内存占用和更复杂的一致性模型。
如果你的消费者比生产者慢很多,且队列长期处于满状态,ArrayBlockingQueue更可控。满状态下,LinkedBlockingQueue的节点不会预分配,每次入队都要new一个Node对象,频繁触发GC;ArrayBlockingQueue的数组早就分配好了,入队出队只移动引用,不产生新的对象分配,GC压力小很多。这一点在长时间满负载运行的系统中非常关键。
还有一点和线程池的配合值得单独说。ThreadPoolExecutor的构造参数里用的是BlockingQueue<Runnable> workQueue。很多教程建议默认用LinkedBlockingQueue,理由是它吞吐高,但线程池的场景里,任务队列通常不会无限生产——提交的任务都是有上限的业务请求。如果队列是无界的,线程池的maximumPoolSize参数就永远没机会触达——线程池提交任务时先入队,队列永远满不了,核心线程之外的线程不会被创建。ArrayBlockingQueue的满队列状态能迫使线程池扩容到maximumPoolSize,或者触发拒绝策略。所以在需要对负载做弹性控制的场景中,用ArrayBlockingQueue配合CallerRunsPolicy或者AbortPolicy是更符合预期的选择。
6. 完整案例:基于ArrayBlockingQueue的可靠异步消费模型
下面我给一个实际生产级别的使用案例。场景是:从消息网关接收推送任务,符合频率要求的进入队列,由多个消费者线程异步推送下游服务。
public class PushService { private final ArrayBlockingQueue<PushTask> queue; private final List<Thread> workers; private volatile boolean running = true; public PushService(int capacity, int workerCount) { // 公平模式,让任务尽量按提交顺序被处理 this.queue = new ArrayBlockingQueue<>(capacity, true); this.workers = new ArrayList<>(); for (int i = 0; i < workerCount; i++) { Thread t = new Thread(new Worker(), "push-worker-" + i); workers.add(t); } } public void start() { workers.forEach(Thread::start); } public void shutdown() { running = false; workers.forEach(Thread::interrupt); } public boolean submit(PushTask task, long timeout, TimeUnit unit) { boolean ok = false; try { ok = queue.offer(task, timeout, unit); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } if (!ok) { // 队列已满、超时未入队,走降级补偿 fallbackToLocalStore(task); } return ok; } private void fallbackToLocalStore(PushTask task) { // 写入本地文件或者内存表,后续定时扫描补偿 // 这里省略具体实现 } private final class Worker implements Runnable { @Override public void run() { while (running) { try { PushTask task = queue.poll(3, TimeUnit.SECONDS); if (task != null) { task.execute(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 中断时退出循环 if (!running) break; } } } } }这个模型的几个设计决策值得说明。使用ArraysBlockingQueue的公平模式:推送任务对顺序敏感,公平模式能让先提交的任务尽量先被消费,虽然吞吐量下降一些,但在业务顺序要求大于吞吐的场景下值得这么做。
任务提交用带超时的offer而不是put,这是我最常强调的一个点——千万避免在生产者的调用链路上无限阻塞。生产者的执行链路可能涉及网络IO、数据库事务等,如果卡在put上等待队列位置,整个业务响应就会被打爆。超时offer加降级路径,才是生产级系统的常见做法。submit里把InterruptedException重新设置中断标记而不是吞掉,也是线程协作的基本素养。
消费者用poll(3, TimeUnit.SECONDS)超时轮询,这样线程每隔几秒检查一次running状态,即使空闲状态下也能及时响应关闭信号。如果用take(),线程会一直挂起在条件变量上,打断只能靠interrupt抛异常,处理起来相对笨重。用带超时的poll做消费,用带超时的offer做生产,是阻塞队列在真实系统里最主流的用法,没有之一。
再说一下容量设置的参考计算。假设生产速率的峰值是每秒5000个任务,消费者单线程每秒能处理300个任务,我们需要让系统在峰值流量下可以缓冲最多5秒的任务,那么容量计算如下:
容量 = 生产速率峰值 × 可容忍的缓冲时长 = 5000 × 5 = 25000这个容量并不是越大越好。容量越大,意味着峰值过后积压的任务越晚被处理完,消费者追平数据的时间越长,“延迟损耗”就越高。对实时性敏感的业务,容量可以设小一点,配合丰富的消费者线程来应对峰值;对允许异步慢慢处理的业务,容量可以设大一点。反过来,如果消费者处理速度远低于生产速度,再大的容量也不够用,最终还是会打满。容量只能削峰,不能治本,治本的办法是限流或者扩容消费者。
7. 生产环境中的三个典型问题与排查经验
7.1 用ArrayBlockingQueue做线程池任务队列时的“线程数假象”
很多人以为给线程池设置了corePoolSize和maximumPoolSize,任务多的时候线程池就会自动扩容到最大线程数。这个认知在无界队列下是完全错误的。
用ThreadPoolExecutor提交任务时,实际流程是:
- 运行线程数小于
corePoolSize,创建新线程。 - 运行线程数大于等于
corePoolSize,尝试把任务入队。 - 队列满了,运行线程数小于
maximumPoolSize,创建新线程到最大数。 - 线程数到最大了还满,走拒绝策略。
如果队列是ArrayBlockingQueue并且容量设得很大,第2步永远成功,第3步永远不触发,maximumPoolSize形同虚设。排查这类问题时,别只看到线程池设置,先看队列的容量和当前长度。我排查过一个诡异的现象:线程池配了200个最大线程,但高峰期永远只有10个线程在跑,CPU还有大量空闲,任务却在堆积——最后查到队列设了个五万容量,所有任务都堆在队列里,线程池根本没有扩容的压力。
7.2 消费者“假死”与中断处理不当
take()方法被中断时会抛出InterruptedException并立即返回。如果消费者的循环代码写的是:
while (true) { try { Task t = queue.take(); process(t); } catch (InterruptedException e) { // 什么都不做,吞掉了中断请求 } }那么问题来了:take被中断后立即返回,循环继续执行,下一次take又阻塞。因为中断标记已经被吞掉,下一次take不会再被中断,线程就进入了“永远阻塞”的状态。从外部看,这个消费者线程还活着,但已经不消费任何任务了,队列在不断堆积。
排查这种“假死”线程的经验是:用jstack看线程堆栈,如果多个消费者线程都停在await()方法上,且running状态正常,那基本就是中断处理写错了。正确做法是:中断异常捕获后要么恢复中断标记,要么退出循环:
catch (InterruptedException e) { Thread.currentThread().interrupt(); // 外部要求关闭 break; }7.3 数组结构下的“内存逃逸”:元素引用未及时释放
ArrayBlockingQueue的数组元素被取出后引用置空,这个内部逻辑没问题,但如果队列的消费者在处理完元素后,把元素对象引用存放在其他长期存活的容器里(比如一个全局缓存Map),那这个对象的生命周期就被无限拉长了。这不是ArrayBlockingQueue的锅,但排查内存泄漏时容易找错方向——看到队列容量不大,觉得不可能是队列的问题,结果问题恰恰出在“队列消费后的引用转移”上。
另一个容易被忽略的方向是消费者线程池的ThreadLocal。如果消费者线程在处理任务时往ThreadLocal里放了数据但不清理,线程复用时旧数据会被下一个任务读到,轻则数据错乱,重则OOM。这种问题在ArrayBlockingQueue场景中出现频率很高,因为队列的消费者线程往往是常驻的。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 队列一直满,消费者处理不过来 | 消费者数量不足或下游太慢 | 查看消费者线程堆栈、下游耗时指标 |
| 线程池线程数一直不增长 | 队列容量过大,任务全部入队 | 调整队列容量,观察线程池活跃线程数 |
| 消费者线程“假死” | 中断标记被吞 | jstack查看线程状态,检查异常处理逻辑 |
| 内存持续增长 | 元素引用未释放或消费端持有引用 | 堆dump分析引用链 |
| 生产任务大量超时降级 | 容量设计不足或消费者故障 | 监控队列长度曲线,看峰值持续时间 |
| 使用了公平模式吞吐下降明显 | 公平锁开销 | 评估是否真的需要顺序保证,非必要时改回非公平 |
| 队列元素包含大对象时GC压力大 | 大对象频繁入队出队 | 考虑用对象池复用对象,减少分配 |
这些问题的共同启发是:不要把ArrayBlockingQueue当做一个黑盒来用,你需要关注的不仅是对它自身的调用,更要对它上下游的生产者逻辑和消费者逻辑有全局的理解。队列只是中间的一环——上游谁来写、下游怎么读、写满后谁负责降级、读空后谁负责等待——这些角色和逻辑的设计才是决定整个系统的承压能力与可靠性的关键。
8. 聊几句源码之外的事
写到这里,想聊聊我对ArrayBlockingQueue的总体感受。
这个类在JDK里不算复杂,几百行源码,结构清晰,但它是并发编程中“锁 + 条件队列 + 数据结构”三者结合得最典型的例子。如果你能完全吃透它的源码,再看Semaphore、CountDownLatch、甚至是ReentrantLock内部的ConditionObject实现,会有一种豁然开朗的感觉。这些类的本质都是同一套东西:状态变量 + 等待条件 + 唤醒通知。
我在团队带新人的时候,有一个固定的练习:让新同学不参照源码,用锁和条件变量自己实现一个有界阻塞队列,然后跑一遍生产者消费者测试,验证阻塞和唤醒行为是否正确。这个练习看起来很基础,但能暴露很多对并发理解不深的问题——比如条件判断用了if而不是while、忘了在finally里释放锁、唤醒时用了signalAll导致惊群、取元素后没有清空引用等等。做完这个练习再去读ArrayBlockingQueue源码,每个设计决策的用意就一目了然了。
另外关于版本差异,我在JDK 8、11、17几个版本上都跑过ArrayBlockingQueue的压测。核心实现逻辑基本没变,但JDK 9之后引入了VarHandle来替代部分Unsafe操作,内部字段的原子访问方式有所调整,性能上有细微差异。实际业务系统里,升级JDK版本时如果没有特殊问题,ArrayBlockingQueue基本可以无感升级,不需要改代码。
最后掏个实际经验:线上系统如果要上ArrayBlockingQueue,一定要把队列的监控做好——当前元素数量、入队出队速率、生产者阻塞次数和时长、消费者空闲时间,这些指标全部打进监控系统。队列长度是最直观的负载信号:队列一直空说明消费者有余量,队列持续堆高说明下游吃紧,队列频繁打满说明容量设计不合理或者消费者快挂了。没有监控的队列就像一个没有仪表盘的发动机,你只听到声音越来越大,但不知道温度已经到临界点了。我见过不止一次因为队列满了沉默降级,业务人员完全不知情,直到用户反馈变多才被发现的案例。有监控、有告警,才能让队列这个“缓冲层”真正成为系统的保护层而非暗雷。