1. 概述
ArrayBlockingQueue 是 Java 并发包(java.util.concurrent)中提供的一个有界阻塞队列 ,其内部基于数组 实现,严格遵循 FIFO(先进先出) 的顺序。它是生产者-消费者模式中最基础的构建块之一,广泛应用于线程池任务缓冲、消息中间件内部队列等场景。
核心特点:
- 有界容量:容量在构造时指定,一经设定便不可动态扩容。这确保了内存使用的可控性,避免因生产者过快而导致的内存溢出。
- 阻塞操作 :提供
put(E e)和take()方法,当队列满时put会阻塞等待空间,当队列空时take会阻塞等待元素,完美支持背压(Backpressure)机制。 - 可选的公平性 :内部使用
ReentrantLock保护并发访问,支持在构造时指定锁的公平性。公平模式下,等待时间最长的线程将优先获得锁,从而降低线程饥饿概率,但代价是吞吐量下降。 - 单锁设计 :入队(put/offer)和出队(take/poll)操作共享同一把锁。这与
LinkedBlockingQueue的双锁设计(入队锁和出队锁分离)形成鲜明对比------单锁实现更简单,但在高并发竞争时吞吐量可能不及双锁队列。
典型应用场景:
- 线程池中的任务队列(如
ThreadPoolExecutor的workQueue)。 - 需要严格控制内存占用的生产者-消费者管道。
- 对 FIFO 顺序有严格要求的缓冲通道。
与
LinkedBlockingQueue的简要区别 :LinkedBlockingQueue可选界(默认无限)、基于链表节点、采用双锁分离入队出队操作,吞吐量通常更高,但节点分配会带来额外 GC 压力。而ArrayBlockingQueue内存预分配、GC 友好,但锁竞争可能成为瓶颈。本文不展开对比,后续LinkedBlockingQueue专题文章会详细分析。
2. 核心方法说明
以下表格汇总了 ArrayBlockingQueue 的核心方法及其行为特征。
| 方法 | 参数 | 返回值 | 阻塞行为 | 异常 |
|---|---|---|---|---|
ArrayBlockingQueue(int capacity) |
capacity:队列容量(必须 > 0) |
构造器,无返回值 | 无 | IllegalArgumentException(容量 ≤ 0) |
ArrayBlockingQueue(int capacity, boolean fair) |
capacity:容量;fair:是否公平锁 |
无 | 无 | IllegalArgumentException |
ArrayBlockingQueue(int capacity, boolean fair, Collection<? extends E> c) |
容量、公平性、初始集合 | 无 | 无 | IllegalArgumentException(容量不足或容量 ≤ 0)、NullPointerException(集合元素含 null) |
put(E e) |
e:要插入的元素(非 null) |
void |
队列满时线程阻塞,直到有空间可用 | InterruptedException、NullPointerException |
offer(E e) |
e:元素 |
boolean:成功 true,队列满返回 false |
不阻塞,立即返回 | NullPointerException |
offer(E e, long timeout, TimeUnit unit) |
元素、超时时间、时间单位 | boolean:成功 true,超时后队列仍满返回 false |
等待指定时间,期间可被中断 | InterruptedException、NullPointerException |
take() |
无 | E:队首元素 |
队列空时线程阻塞,直到有元素可用 | InterruptedException |
poll() |
无 | E:队首元素,队列空返回 null |
不阻塞 | 无 |
poll(long timeout, TimeUnit unit) |
超时时间、时间单位 | E:队首元素,超时后队列仍空返回 null |
等待指定时间 | InterruptedException |
peek() |
无 | E:队首元素(不移除),空返回 null |
不阻塞 | 无 |
size() |
无 | int:当前元素个数 |
无 | 无 |
remainingCapacity() |
无 | int:剩余容量(容量 - 当前元素数) |
无 | 无 |
drainTo(Collection<? super E> c) |
目标集合 | int:转移的元素数量 |
无阻塞,但全程持锁,可能阻塞其他线程的并发操作 | NullPointerException(目标集合为 null) |
drainTo(Collection<? super E> c, int maxElements) |
目标集合、最大转移数 | int:实际转移数 |
无阻塞,全程持锁 | NullPointerException |
3. 核心原理与源码分析(基于 JDK 8)
3.1 数据结构
ArrayBlockingQueue 的内部数据结构简洁而高效,所有并发控制均围绕一把 ReentrantLock 展开。
java
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
/** 存放队列元素的数组,大小固定 */
final Object[] items;
/** 下一次 take/poll/peek/remove 操作的索引位置 */
int takeIndex;
/** 下一次 put/offer/add 操作的索引位置 */
int putIndex;
/** 队列中当前元素的数量 */
int count;
/** 保护所有访问的主锁 */
final ReentrantLock lock;
/** 等待 take 的条件:当队列为空时,消费者线程在该条件上等待 */
private final Condition notEmpty;
/** 等待 put 的条件:当队列满时,生产者线程在该条件上等待 */
private final Condition notFull;
// ... 其他省略
}
字段说明:
items:final Object[]类型的循环数组,存储队列元素。长度在构造时固定。takeIndex:指向下一个将被取出的元素索引。出队时从此位置读取元素并置null,然后通过取模运算循环后移。putIndex:指向下一个插入位置的索引。入队时将元素放入此位置,然后取模后移。count:当前队列中的元素个数。count == 0表示空队列,count == items.length表示满队列。由于takeIndex和putIndex可能相等(空队列时两者指向同一位置,满队列时两者也指向同一位置,但通过count区分),因此count是判断空/满的唯一准确依据。lock:ReentrantLock实例,所有入队、出队操作都需先获取此锁,保证了数组操作和计数器更新的原子性。notEmpty/notFull:通过lock.newCondition()创建的两个条件对象,分别用于管理因队列空而阻塞的消费者线程和因队列满而阻塞的生产者线程。
3.2 构造器
ArrayBlockingQueue 提供了三个构造器,核心逻辑均围绕初始化数组和锁。
java
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair); // fair 决定锁的公平性
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
public ArrayBlockingQueue(int capacity, boolean fair,
Collection<? extends E> c) {
this(capacity, fair);
final ReentrantLock lock = this.lock;
lock.lock(); // 初始化时必须加锁以保证可见性
try {
int i = 0;
for (E e : c) {
if (e == null)
throw new NullPointerException();
items[i++] = e;
}
count = i;
putIndex = (i == capacity) ? 0 : i; // 如果集合元素填满,putIndex 回到 0
} finally {
lock.unlock();
}
}
关键点:
- 容量检查:
capacity <= 0抛出IllegalArgumentException。 - 根据
fair参数创建公平或非公平ReentrantLock。公平锁内部维护等待队列,能保证最长等待时间的线程优先获取锁。 - 通过
lock.newCondition()创建两个条件对象,条件队列机制依赖于 AQS。 - 带有初始集合的构造器在构造期间会加锁,确保数组初始化的线程安全性。注意遍历集合时如果元素为
null会抛出NullPointerException,这与ArrayBlockingQueue不允许null元素的约定一致。
3.3 put / take 核心流程
put 和 take 是阻塞队列的灵魂方法,体现了条件变量的经典使用范式。
put(E e) 源码分析
java
public void put(E e) throws InterruptedException {
checkNotNull(e); // 检查非 null,否则抛出 NullPointerException
final ReentrantLock lock = this.lock;
lock.lockInterruptibly(); // 可中断地获取锁
try {
while (count == items.length) // 队列满则循环等待
notFull.await(); // 释放锁,进入 notFull 条件队列阻塞
enqueue(e); // 被唤醒且成功获取锁后,执行入队
} finally {
lock.unlock();
}
}
流程解释:
- 加锁 :调用
lock.lockInterruptibly()可中断地获取锁,若线程被中断则抛出InterruptedException而不会永久阻塞在锁上。 - 满队列判断 :使用
while循环而非if,这是因为线程可能被虚假唤醒,或者被唤醒后但锁被其他线程抢占导致队列再次变满,必须重新检查条件。 - 阻塞等待 :
notFull.await()将当前线程加入notFull条件队列,释放锁 ,线程进入等待状态,直到被signal唤醒。 - 入队操作 :
enqueue(e)将元素放入数组,更新putIndex和count,并唤醒一个等待在notEmpty上的消费者。 - 解锁 :
finally块中确保锁被释放。
enqueue(E x) 内部实现
java
private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x; // 将元素放入当前 putIndex 位置
if (++putIndex == items.length) // 索引递增,若到达数组末尾则回绕到 0
putIndex = 0;
count++; // 元素计数加 1
notEmpty.signal(); // 唤醒一个等待在 notEmpty 上的消费者线程
}
take() 源码分析
java
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0) // 队列空则循环等待
notEmpty.await(); // 进入 notEmpty 条件队列阻塞
return dequeue(); // 被唤醒后执行出队并返回元素
} finally {
lock.unlock();
}
}
dequeue() 内部实现
java
private E dequeue() {
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex]; // 取出 takeIndex 位置的元素
items[takeIndex] = null; // 置空,帮助 GC
if (++takeIndex == items.length) // 索引回绕
takeIndex = 0;
count--; // 元素计数减 1
if (itrs != null)
itrs.elementDequeued(); // 维护迭代器状态(与核心逻辑无关)
notFull.signal(); // 唤醒一个等待在 notFull 上的生产者线程
return x;
}
对称性:
enqueue增加count,然后signal notEmpty;dequeue减少count,然后signal notFull。- 两个操作都在持锁状态下进行,确保唤醒信号不会丢失。
3.4 offer / poll 非阻塞版本
非阻塞版本不等待,立即返回结果。
java
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count == items.length)
return false; // 满则直接返回 false
else {
enqueue(e);
return true;
}
} finally {
lock.unlock();
}
}
public E poll() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
return (count == 0) ? null : dequeue();
} finally {
lock.unlock();
}
}
注意即使是非阻塞版本,成功入队/出队后仍会调用 enqueue/dequeue,从而执行相应的 signal 唤醒阻塞线程。这种设计确保即使生产者/消费者临时使用非阻塞方法,也不会导致阻塞线程永久等待。
3.5 超时版本 offer / poll
超时版本利用了条件变量的 awaitNanos(long nanosTimeout) 方法,该方法返回剩余等待时间(纳秒),用于处理虚假唤醒和超时重试。
java
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; // 超时则返回 false
nanos = notFull.awaitNanos(nanos); // 等待并更新剩余时间
}
enqueue(e);
return true;
} finally {
lock.unlock();
}
}
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0) {
if (nanos <= 0)
return null;
nanos = notEmpty.awaitNanos(nanos);
}
return dequeue();
} finally {
lock.unlock();
}
}
awaitNanos 返回值处理:
- 如果返回正数,表示线程被
signal唤醒,且剩余等待时间大于 0,循环将继续检查条件。 - 如果返回 ≤ 0,表示超时已到,下次循环时
nanos <= 0条件触发并返回失败。
这种模式是使用条件超时 API 的标准范式。
3.6 循环数组与索引计算
ArrayBlockingQueue 使用 环形缓冲区 技术,通过取模运算复用数组空间。
putIndex移动 :if (++putIndex == items.length) putIndex = 0;等价于putIndex = (putIndex + 1) % items.length,但避免了昂贵的取模运算(仅在索引触及边界时才归零),性能更优。takeIndex同理。count的核心作用 :由于takeIndex和putIndex在空队列和满队列时均可能相等,必须依赖count字段准确记录元素数量。例如:- 空队列:
count == 0,takeIndex == putIndex。 - 满队列:
count == items.length,takeIndex == putIndex。 - 非空非满:
count介于 0 和length之间,索引关系有效。
- 空队列:
3.7 公平性与非公平性
ArrayBlockingQueue 的锁通过 ReentrantLock 实现,构造参数 fair 直接影响锁的行为。
- 公平锁 (
fair = true) :ReentrantLock内部使用严格的 FIFO 队列管理等待线程。当锁被释放时,等待队列中等待时间最长的线程将获得锁。这避免了线程饥饿,但会增加上下文切换开销,因为每次释放锁都需要唤醒队列头的线程并让其参与调度。 - 非公平锁 (
fair = false):默认模式。锁被释放时,如果有线程恰好此时来抢锁,可能直接"插队"成功。这减少了线程挂起和恢复的次数,吞吐量通常更高,但可能导致某些线程长时间无法获取锁(饥饿)。
选择建议:除非你明确需要防止饥饿(例如某些线程优先级较低但在系统中又必不可少),否则使用非公平锁可以获得更好的整体性能。
4. 必要流程的 Mermaid 图
4.1 类图
以下 UML 类图展示了 ArrayBlockingQueue 的核心字段和方法。
文字描述 :该类包含一个不可变的对象数组 items 作为存储核心,takeIndex 和 putIndex 管理环形缓冲区的读写指针,count 记录元素数量。并发控制由 ReentrantLock 和两个 Condition 对象完成。公共方法提供了阻塞、非阻塞、超时等多种操作方式。
4.2 非阻塞 offer/poll 快速路径流程图
offer(E e) 流程图
poll() 流程图
文字描述 :非阻塞方法在获取锁后立即检查队列状态,若不满足条件则直接返回失败,无任何等待。若成功则调用内部 enqueue/dequeue,它们会顺便唤醒在相反条件上等待的线程。
4.3 阻塞 put/take 交互时序图
场景一:队列空,消费者先阻塞,生产者后放入
文字描述 :消费者因队列空在 notEmpty 条件上等待,释放锁。生产者获取锁并入队,调用 signal 将消费者线程从条件队列转移到 AQS 同步队列。生产者释放锁后,消费者重新获取锁并完成出队,最后唤醒可能在 notFull 上等待的其他生产者。
场景二:队列满,多个生产者阻塞,消费者出队唤醒一个生产者
文字描述 :队列满时,多个生产者依次阻塞在 notFull 条件队列。消费者出队后调用 notFull.signal() 只会唤醒其中一个生产者(FIFO 顺序取决于条件队列的实现),该生产者完成入队后又会唤醒一个消费者,形成对称协作。
4.4 超时 offer 的等待-超时恢复流程
文字描述 :超时版本的核心是循环中的 awaitNanos 调用。每次唤醒后(无论是因为 signal 还是虚假唤醒),都会检查剩余超时时间 nanos。若 nanos <= 0 则认定超时,返回失败;否则继续等待。这种模式确保即使发生虚假唤醒,线程也不会错误地认为超时。
4.5 drainTo 批量消费的内部循环流程图
文字描述 :drainTo 在持锁状态下一次性转移最多 maxElements 个元素。循环内部执行与 dequeue 类似的逻辑,但跳过了逐次 signal 的步骤,仅在循环结束后调用 notFull.signalAll()。由于批量操作释放了多个空间,使用 signalAll 唤醒所有等待的生产者更为高效。注意:由于全程持锁,此操作期间其他消费者无法出队,可能造成短暂饥饿。
您说得对,第 4.6 节的原始 Mermaid 图使用 graph TD 配合 subgraph 表达条件队列与同步队列的协作关系,在视觉上稍显混乱,且没有直观体现"转移 "这一关键动作。为了在 Mermaid 10.0.2 中更好地展示 AQS ConditionObject 的内部机制,我重新设计了一个时序图(Sequence Diagram),它能更精确地表达线程、锁、条件队列和同步队列之间的交互。
4.6 条件队列与锁的协作机制(基于 AQS)
在 ArrayBlockingQueue 中,notEmpty.await() 和 notEmpty.signal() 的行为并非简单的线程挂起与唤醒,而是依赖 AbstractQueuedSynchronizer(AQS)内部的两个关键队列:条件队列 (Condition Queue)与同步队列(CLH Queue)。理解这一转移过程是掌握阻塞语义的核心。
以下时序图清晰展示了 take() 阻塞、put() 唤醒的完整生命周期。
2. 完全释放锁 deactivate Lock T1-->>T1: T1 在条件队列中阻塞 Note over CondQ: 此时生产者线程运行... participant T2 as 生产者线程 T2 T2->>ABQ: put(e) ABQ->>Lock: lockInterruptibly() activate Lock Lock-->>ABQ: 成功获取锁 ABQ->>ABQ: enqueue(e) ABQ->>CondQ: signal() Note over CondQ, SyncQ: 3. 将条件队列头节点(T1)转移到同步队列尾部 CondQ-->>SyncQ: 转移 T1 节点 deactivate Lock T2->>Lock: unlock() 释放锁 SyncQ-->>T1: 4. T1 在同步队列中竞争锁 activate Lock T1->>ABQ: 从 await() 返回,重新获取锁 ABQ->>ABQ: dequeue() 取出元素 ABQ-->>T1: 返回元素 deactivate Lock
详细文字描述:
-
等待(await) :当消费者线程
T1调用take()且队列为空时,执行notEmpty.await()。此时 AQS 将当前线程封装为一个节点(Node)添加到notEmpty的条件队列 尾部,并完全释放ReentrantLock锁。因此,在T1挂起期间,生产者线程可以获取锁并向队列放入元素。 -
唤醒(signal) :生产者线程
T2执行put(e)后调用notEmpty.signal()。此操作不会立即唤醒T1,而是将条件队列的头节点(即T1)转移到 AQS 的同步队列 (CLH 队列)的尾部。这意味着T1现在的状态从"等待条件"变为"等待锁"。 -
重新竞争锁 :生产者
T2执行完unlock()释放锁后,同步队列中的T1开始与其他线程一起竞争锁。当T1成功获取锁时,它才会从await()调用中返回,重新检查循环条件(此时count > 0已成立),并安全地执行dequeue()取出元素。
核心意义 :
这种设计保证了线程在等待条件期间不持有锁 ,从而不会阻塞生产者线程;同时确保了被唤醒的线程在继续执行之前,必须先重新获得锁,以维护共享变量的内存可见性和操作的原子性。
5. 实际应用场景与代码举例(JDK 8 兼容)
以下示例均基于 JDK 8,可直接复制编译运行。
5.1 基础生产者-消费者模型
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BasicProducerConsumer {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
// 生产者线程
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
System.out.println("生产: " + i);
queue.put(i); // 队列满时阻塞
Thread.sleep(100); // 模拟生产耗时
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// 消费者线程
Thread consumer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
Integer value = queue.take(); // 队列空时阻塞
System.out.println("消费: " + value);
Thread.sleep(300); // 模拟消费耗时
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
}
}
5.2 使用超时 offer/poll
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
public class TimeoutExample {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new ArrayBlockingQueue<>(2);
// 生产者:尝试在 1 秒内放入元素,超时则放弃
Thread producer = new Thread(() -> {
String[] items = {"A", "B", "C", "D"};
try {
for (String item : items) {
boolean offered = queue.offer(item, 1, TimeUnit.SECONDS);
if (offered) {
System.out.println("成功放入: " + item);
} else {
System.out.println("放入超时,放弃: " + item);
}
Thread.sleep(50);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// 消费者:慢速消费,导致队列很快变满
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 4; i++) {
Thread.sleep(2000); // 消费很慢
String item = queue.poll(500, TimeUnit.MILLISECONDS);
if (item != null) {
System.out.println("消费: " + item);
} else {
System.out.println("消费超时,队列为空");
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
}
}
5.3 使用 drainTo() 批量消费
java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class DrainToExample {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(100);
// 生产者快速生产 100 个元素
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 100; i++) {
queue.put(i);
}
System.out.println("生产完毕,队列元素数: " + queue.size());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
producer.join(); // 等待生产完成
// 消费者批量取出,每次最多 20 个
List<Integer> batch = new ArrayList<>();
int total = 0;
while (total < 100) {
int drained = queue.drainTo(batch, 20);
if (drained == 0) break; // 防御性编程
System.out.println("批量取出 " + drained + " 个元素: " + batch);
total += drained;
batch.clear();
// 模拟处理耗时
Thread.sleep(100);
}
System.out.println("所有元素处理完毕,剩余队列大小: " + queue.size());
}
}
5.4 结合线程池(ThreadPoolExecutor)
java
import java.util.concurrent.*;
public class ThreadPoolWithArrayBlockingQueue {
public static void main(String[] args) {
// 核心线程数 2,最大线程数 4,队列容量 5
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(5);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS, workQueue,
new ThreadPoolExecutor.AbortPolicy() // 队列满且线程达最大时抛出异常
);
// 提交 10 个任务(超出容量)
for (int i = 1; i <= 10; i++) {
final int taskId = i;
try {
executor.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
try {
Thread.sleep(2000); // 模拟任务执行
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("任务 " + taskId + " 提交成功");
} catch (RejectedExecutionException e) {
System.err.println("任务 " + taskId + " 被拒绝: " + e.getMessage());
}
}
executor.shutdown();
try {
executor.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("线程池已关闭");
}
}
输出分析:前 2 个任务直接由核心线程执行,接下来的 5 个任务进入队列,再接下来的 2 个任务会创建新线程(直到最大线程数 4),最后的 1 个任务因队列满且线程数达最大而触发拒绝策略。
6. 吞吐量与性能分析
6.1 单锁设计的影响
ArrayBlockingQueue 使用一把锁 保护 put 和 take 操作。这意味着在任意时刻,仅有一个线程可以执行入队或出队。高并发场景下,生产者和消费者会激烈竞争这把锁,导致大量线程在锁上自旋或阻塞,从而限制吞吐量。
与之相对,LinkedBlockingQueue 采用双锁设计(putLock 和 takeLock),生产者和消费者在大多数情况下可以并行操作,仅当队列边界(空或满)时才需要相互通知。因此,在高并发且生产消费速率均衡 的场景下,ArrayBlockingQueue 的吞吐量通常低于 LinkedBlockingQueue。
然而,单锁设计带来的好处是实现简单、内存占用少、GC 友好,在并发度不高或对延迟稳定性要求较高的场景下依然是不错的选择。
6.2 公平锁 vs 非公平锁
- 非公平锁(默认):当锁被释放时,新到达的线程可能直接"插队"获取锁,减少了线程挂起和上下文切换。实验表明,非公平锁的吞吐量比公平锁高出数倍甚至一个数量级。
- 公平锁:严格按照等待时间分配锁,避免了线程饥饿,但每次锁释放都需要唤醒队列头线程并等待其被调度执行,增加了系统开销。
经验建议 :除非应用中有明确的线程饥饿风险(例如部分线程总是得不到执行机会),否则始终使用非公平锁以获得更好的性能。
6.3 数组预分配内存的优劣
优势:
- 无动态扩容开销 :与
ArrayList不同,ArrayBlockingQueue创建后数组长度固定,入队出队仅操作已有数组位置,无需数组复制。 - GC 压力小 :不产生额外的节点对象(对比
LinkedBlockingQueue每次入队需new Node),减少了垃圾回收的频率。 - 内存访问局部性好:数组元素在内存中连续存储,有利于 CPU 缓存行命中,访问速度更快。
劣势:
- 容量固定:必须提前预估合适的容量。容量过小会导致频繁阻塞,容量过大会浪费内存。在负载波动大的系统中,这可能成为问题。
6.4 高竞争下的性能表现
当生产者和消费者线程数量较多且速率接近时,单锁的竞争会急剧增加。此时大量 CPU 时间消耗在锁的获取与释放上,上下文切换频繁。监控工具(如 jstack、perf)可能显示大量线程处于 BLOCKED 或 WAITING 状态。
缓解建议:
- 如果业务允许,可以考虑使用
LinkedBlockingQueue(双锁)提升吞吐量。 - 对于极端低延迟、高吞吐场景,可评估无锁队列实现(如
Disruptor)。 - 使用
LongAdder等无锁数据结构进行辅助统计,而非在锁内操作。
7. 注意事项与常见陷阱
-
容量必须指定且不能动态扩容
- 原因 :底层数组
items是final修饰的定长数组。若容量设置过小,生产者会频繁阻塞;若过大,浪费堆内存。需根据生产消费速率合理估算。
- 原因 :底层数组
-
公平锁模式性能显著下降
- 原因 :公平锁的队列管理和线程调度开销远大于非公平锁。如非必要,勿设
fair=true。
- 原因 :公平锁的队列管理和线程调度开销远大于非公平锁。如非必要,勿设
-
队列元素不能为
null- 原因 :
BlockingQueue接口约定null作为特殊返回值(如poll()超时或队列空返回null),插入null会立即抛出NullPointerException。
- 原因 :
-
peek()/poll()非阻塞版本误用导致 CPU 忙等- 原因 :在循环中不断调用
poll()检查元素,若无元素则返回null,循环继续,造成 CPU 空转。应优先使用阻塞take()或超时版本。
- 原因 :在循环中不断调用
-
drainTo()批量消费可能饿死其他消费者- 原因 :
drainTo全程持锁,且一次性转移大量元素。若一个消费者频繁调用drainTo,其他消费者可能长时间无法获取元素。可考虑限制单次maxElements数量或交替使用take()。
- 原因 :
-
中断响应必须正确处理
- 原因 :
put、take、offer超时版等均抛出InterruptedException。捕获后必须恢复中断状态(Thread.currentThread().interrupt())或妥善退出,避免中断信息丢失。
- 原因 :
-
内存可见性与弱一致性方法
- 原因 :
size()、isEmpty()、remainingCapacity()等方法在无锁或弱同步情况下返回的是瞬时值。在并发环境下,这些值可能在返回后立即过期,不能作为精确的业务决策依据。
- 原因 :
-
remainingCapacity()的瞬时性- 原因:同上,在多线程环境中,该方法返回的值仅表示某一瞬间的剩余容量,可能在你依据该值做决策时队列状态已经改变。
8. 总结与学习指引
ArrayBlockingQueue 作为 Java 并发包中最基础的阻塞队列实现,展示了如何利用 ReentrantLock 和 Condition 构建线程安全的有界缓冲区。其核心特点总结如下:
- 有界、数组、FIFO:内存占用可控,顺序严格。
- 单锁保护所有操作:实现简单,但高并发下存在性能瓶颈。
- 公平性可选:默认非公平锁提供更高吞吐量,公平锁可防止饥饿。
使用建议:
- 适用于生产者-消费者速率相对稳定、对内存敏感、不需要动态扩容的场景。
- 不适用于极高并发(锁竞争成为瓶颈)或需要无限队列的场景。
后续学习指引:
在掌握 ArrayBlockingQueue 的基础上,下一篇阻塞队列系列文章将深入分析 LinkedBlockingQueue。它将展示双锁分离设计 如何显著提升吞吐量,以及与 ArrayBlockingQueue 在内存模型、GC 行为等方面的差异。通过对比,你将更深刻地理解不同阻塞队列在设计与性能权衡上的取舍。
本文所有源码分析基于 OpenJDK 8,代码示例均通过 JDK 8 环境测试。