REJAVA / LEARNING NOTES
BlockingQueue(阻塞队列)
从“队满等、队空等”出发,理解四组方法、数组与链表实现、有界队列与背压,并手写生产者-消费者。
根据课程材料整理
本次更新说明
- 补充生产者-消费者的两层分工、多个消费者如何结束,以及非守护线程与程序退出的官方依据。
- 整理为公开学习笔记,保留技术内容、示例程序与练习。
热身 · 一 是什么 · 二 四组方法 · 三 常用实现 · 四 内部实现 · 五 有界与无界 · 六 手写生产者消费者 · 七 检验 · 八 讲出来 · 九 追问 · 十 隔日复测
学完这一课,你能做到
能说清 BlockingQueue(阻塞队列) 的四组方法在队满、队空时各怎么表现;能手写一个有界、会等待、能正常结束的生产者-消费者;并能脱稿讲清 ArrayBlockingQueue(数组阻塞队列) 与 LinkedBlockingQueue(链式阻塞队列) 的区别,以及线程池为什么要用有界队列。
和前几课的联系: 上一课学了 ConcurrentHashMap(并发哈希映射),这一课学另一类常用并发容器。它还接着第5课:线程池的 workQueue(任务队列)就是一个 BlockingQueue(阻塞队列),当时只用不拆,这一课把它拆开。
主读: Java 21 BlockingQueue(阻塞队列) 官方文档。读开头的类说明:四种方法的表、null(空值)、容量、结束标记;再各读一段 ArrayBlockingQueue(数组阻塞队列) 和 LinkedBlockingQueue(链式阻塞队列) 的类说明。第四节讲内部实现,以源码里的注释与代码为准。
热身:先回忆前几课(3 分钟)
先凭记忆回答,再展开核对。答不上来就记一句,接着往下学,这不是进入新课的门槛。
第5课:核心线程数 3、最大线程数 5、队列容量 2。已有 3 个线程都在忙,队列是空的。第 4 个任务去哪?第 6 个任务呢?
第 4 个任务入队等待:线程数到了核心数,先排队。第 5 个任务也入队,队列满了。第 6 个任务:队列已满、线程数 3 小于最大数 5,所以新建线程。对照第5课接纳流程。
第6课:ConcurrentHashMap(并发哈希映射) 为什么不允许 null(空值)?
get(读取方法) 返回 null(空值) 时分不清是“没有这个 key(键)”还是“值本来就是 null(空值)”;并发下两次调用之间表可能已被修改,再用 containsKey 补判也不可靠。本课第二节会看到阻塞队列禁止 null(空值) 的理由和它是同一类。
第3、4课:线程A 先写了一个字段,然后对锁 L 解锁;线程B 随后对同一把锁 L 加锁。B 能看到 A 写的字段吗?为什么?
能。同一把锁上,前一次解锁 happens-before(先行发生关系) 后一次加锁,解锁之前的写入对加锁之后的线程可见。本课第一节会看到,BlockingQueue(阻塞队列) 对“放入之前的动作”和“取出之后的动作”也给了同类保证。
一、阻塞队列是什么:队满放入的等,队空取出的等
BlockingQueue(阻塞队列) 是什么?和普通队列有什么区别?一般用在哪里?
场景:审批办结后,要给申请人发一条短信。处理网页请求的线程不应该自己去等短信网关,所以它只把“发给谁、发什么”放进一个队列,后台几个发送线程再从队列里一个一个取出来发。
这里有两个麻烦。第一,短信网关慢的时候,队列会越积越多,往里放的线程怎么办?第二,没有短信要发的时候,发送线程怎么办?总不能一直空转,白白占着 CPU(处理器)。
BlockingQueue(阻塞队列) 的回答很直接: 队满时,往里放的线程等;队空时,从里取的线程等。 官方的定义是:一个 Queue,额外支持“取元素时,等队列变成非空;存元素时,等队列有空位”的操作[1]。Queue 就是先进先出(FIFO)的排队结构:先放进去的,先被取出来。
这种用法有个名字:一部分线程只管往队列里放(producer,生产者),另一部分线程只管从队列里取并处理(consumer,消费者),队列夹在中间,让两边速度不一样也能配合。这叫 producer-consumer(生产者-消费者模式),也是官方文档说 BlockingQueue(阻塞队列) 主要的用途[1]。
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100); // 最多等待 100 条
queue.put("sms-1"); // 队满时,调用 put 的线程在这里等
String next = queue.take(); // 队空时,调用 take 的线程在这里等
验证:单线程直接运行这三行不会等(队列既没满也没空)。多线程下的等待效果由示例程序的 D 段验证:队满时调用 put(写入方法) 的线程状态是 WAITING,取走一个元素后才继续;队空时 take(阻塞取出方法) 同理。JDK 17/21 均通过。
还有两点官方保证值得补充。一是线程安全:所有放入、取出的方法,用内部的锁或其他并发控制保证各自是原子的[1]。二是可见性:一个线程在“放入某个对象之前”做的事,happens-before(先行发生关系) 另一个线程“取出这个对象之后”做的事[1]。这和热身第三题是同一类规则:生产者先把数据准备好再放入,消费者取出后一定看得到。
先自己说一遍,再看参考回答
BlockingQueue(阻塞队列) 是线程安全的队列,在普通队列的基础上多了等待:队满时放入的线程会等到有空位,队空时取出的线程会等到有元素。它主要用在生产者-消费者场景:生产者往里放,消费者往里取,两边速度不一样也能配合。放入之前的操作 happens-before(先行发生关系) 取出之后的操作,所以消费者能看到生产者准备好的数据。
二、四组方法:放不进去、取不出来时怎么办
add(追加方法)、offer(尝试放入方法)、put(写入方法) 有什么区别?poll(尝试取出方法)、take(阻塞取出方法) 呢?
“放不进去”(队满)和“取不出来”(队空)时,一个方法可以有四种处理方式:抛异常;返回一个特殊值;一直等;最多等一会儿。BlockingQueue(阻塞队列) 给每种方式各准备了一个方法[1]:
| 操作 | 抛异常 | 返回特殊值 | 一直等 | 最多等一会 |
|---|---|---|---|---|
| 放入 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
| 取出 | remove() | poll() | take() | poll(time, unit) |
| 查看队头 | element() | peek() | 不适用 | 不适用 |
具体到队满或队空时:
- 放入,队满: add(追加方法) 抛 IllegalStateException;offer(尝试放入方法) 立即返回 false;put(写入方法) 一直等到有空位;带超时的 offer(尝试放入方法) 等到时间到,仍没有空位就返回 false。
- 取出,队空: remove 抛 NoSuchElementException;poll(尝试取出方法) 立即返回 null(空值);take(阻塞取出方法) 一直等到有元素;带超时的 poll(尝试取出方法) 等到时间到,仍没有元素就返回 null(空值)。
验证:示例程序 A 段对容量为 2 的队列逐项运行:队满时 offer(尝试放入方法) 返回 false、add(追加方法) 抛 IllegalStateException;队空时 poll(尝试取出方法) 返回 null(空值)、remove 抛 NoSuchElementException;带 100 毫秒超时的 offer(尝试放入方法)、poll(尝试取出方法) 都等够时间后返回 false 或 null(空值)。JDK 17/21 均通过。
怎么选:
- 调用线程不能被卡住 (比如处理网页请求的线程):用 offer(尝试放入方法),马上知道成功还是失败。官方也说,用容量受限的队列时,一般更推荐 offer(尝试放入方法) 而不是 add(追加方法)[1]。第5课线程池提交任务入队,用的就是 offer(尝试放入方法),不会因为它叫“阻塞队列”就一直等空位。
- 后台线程没事做就该等: 用 take(阻塞取出方法)。队空时它自己等,不用你写“睡一会再看一眼”的循环。
- 愿意等,但不能无限等: 用带超时的 offer(尝试放入方法) 或 poll(尝试取出方法)。
两个容易被追问的细节
不接受 null(空值)。 往阻塞队列放 null(空值),会抛 NullPointerException。原因是 poll(尝试取出方法) 用返回 null(空值) 表示“队空,没取到”,如果元素本身也可能是 null(空值),就分不清了[1]。这和第6课 ConcurrentHashMap(并发哈希映射) 禁止 null(空值) 是同一类歧义。
等待可以被中断。 put(写入方法)、take(阻塞取出方法) 和带超时的 offer(尝试放入方法)、poll(尝试取出方法) 都声明了 InterruptedException(中断异常)。等待中的线程被别的线程调用 interrupt() 时,会立刻醒来并抛出它,这样等待才能被取消[1]。抛出时,线程身上的 interrupted status(中断标记)已经被清除了[5]。所以 catch 里一般要自己把标记恢复回去,再退出:
try {
queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标记,让上层知道这个线程被要求停止过
}
验证:示例程序 D 段让一个线程阻塞在 take(阻塞取出方法) 上,再从另一个线程中断它:线程抛出 InterruptedException(中断异常),此时 isInterrupted() 为 false;执行 Thread.currentThread().interrupt() 之后变回 true。JDK 17/21 均通过。
先自己说一遍,再看参考回答
这几个方法对“放不进去”的处理不同:add(追加方法) 队满时抛异常,offer(尝试放入方法) 队满时立即返回 false,put(写入方法) 队满时一直等,带超时的 offer(尝试放入方法) 等到时间到再返回 false。取出也是四种:remove 抛异常,poll(尝试取出方法) 返回 null(空值),take(阻塞取出方法) 一直等,带超时的 poll(尝试取出方法) 等一会再返回 null(空值)。请求线程不能被卡住时用 offer(尝试放入方法);后台消费线程用 take(阻塞取出方法);愿意等但不能无限等就用带超时的版本。阻塞队列不接受 null(空值),因为 poll(尝试取出方法) 用 null(空值) 表示没取到。等待可以被中断,抛出 InterruptedException(中断异常) 时中断标记已清除,catch 里要恢复标记。
三、常用实现:数组、链表、不存元素、按优先级
ArrayBlockingQueue(数组阻塞队列) 和 LinkedBlockingQueue(链式阻塞队列) 有什么区别?还有哪些阻塞队列?
| 实现 | 底层 | 容量 | 加锁 |
|---|---|---|---|
| ArrayBlockingQueue(数组阻塞队列) | 固定大小的数组,下标走到末尾后回到 0 继续使用[2] | 必须指定,创建后不能改[2] | 一把 ReentrantLock(可重入锁),放入和取出共用[2] |
| LinkedBlockingQueue(链式阻塞队列) | 链表节点,每次放入时才创建节点[3] | 可选;不指定时等于 Integer.MAX_VALUE(整数类型最大值)[3] | 两把锁:放入一把,取出一把[3] |
| SynchronousQueue(同步移交队列) | 不存元素:放入的线程必须等另一个线程来取,反过来也一样[4] | 没有容量,连 1 个也没有[4] | 本课不展开 |
| PriorityBlockingQueue(优先级阻塞队列) | 按优先级排序,不是先进先出[4] | 无界,可能因内存耗尽失败[4] | 本课只点名 |
| DelayQueue(延迟队列) | 元素到期后才能被取出[4] | 无界[4] | 本课只点名 |
验证:示例程序 C 段:不指定容量的 LinkedBlockingQueue(链式阻塞队列),剩余容量是 Integer.MAX_VALUE(2147483647);SynchronousQueue(同步移交队列) 的容量和元素个数都是 0,没有接收者时 offer(尝试放入方法) 立即返回 false。JDK 17/21 均通过。锁的数量来自源码,两个版本的这两个类源码一致。
ArrayBlockingQueue(数组阻塞队列) 和 LinkedBlockingQueue(链式阻塞队列) 的区别,抓住这四点: 底层是数组还是链表;容量是必须指定,还是可选;锁是一把还是两把;性能上,官方原话是链表队列“通常吞吐更高,但在多数并发场景下性能更难预测”(higher throughput … but less predictable performance)[3]。
怎么选:想要硬性的容量上限、表现稳定,用 ArrayBlockingQueue(数组阻塞队列)。用 LinkedBlockingQueue(链式阻塞队列),就 一定要传容量 :不传时上限约 21 亿,几乎等于没有上限。官方说容量参数的作用,就是防止队列过度膨胀[3]。为什么这很危险,第五节讲。
SynchronousQueue(同步移交队列) 适合“一手交一手”的场景:任务必须当场交给一个正在等的线程。第5课的 Executors.newCachedThreadPool 用的就是它[6]。
先自己说一遍,再看参考回答
ArrayBlockingQueue(数组阻塞队列) 底层是数组,容量必须指定且不能改,放入和取出共用一把 ReentrantLock(可重入锁)。LinkedBlockingQueue(链式阻塞队列) 底层是链表,容量可选,不指定就是 Integer.MAX_VALUE(整数类型最大值),放入和取出各用一把锁。官方说链表队列通常吞吐更高,但性能更难预测。所以用 LinkedBlockingQueue(链式阻塞队列) 一定要指定容量。另外还有 SynchronousQueue(同步移交队列),不存元素,放入必须等取出;PriorityBlockingQueue(优先级阻塞队列) 按优先级,无界;DelayQueue(延迟队列) 元素到期才能取。
四、内部实现:怎样做到“满了等、空了等”
阻塞队列是怎么实现等待的?ArrayBlockingQueue(数组阻塞队列) 为什么一把锁,LinkedBlockingQueue(链式阻塞队列) 为什么两把?
先认识两个东西。
- ReentrantLock(可重入锁): 也是一把锁,同一时刻只让一个线程进入,和 synchronized(内置同步关键字) 的作用相同。区别是加锁、解锁要自己写
lock()和unlock(),所以解锁一定放在 finally(最终清理代码块) 里,下面的源码都是这样写的。 - Condition(条件等待室): 挂在一把锁上的“等待室”。线程调用
await(),就放开这把锁,进等待室睡觉;别的线程调用signal(),叫醒等待室里的一个线程;被叫醒的线程必须重新拿到锁,才能继续往下走[5]。
ArrayBlockingQueue(数组阻塞队列):一把锁,两个等待室
它有一把主锁,保护所有访问;再有两个等待室:notFull,放入的线程在这里等空位;notEmpty,取出的线程在这里等元素[2]。put(写入方法) 的核心逻辑是这样:
// 简化自 Java 21 的 ArrayBlockingQueue.put
lock.lockInterruptibly();
try {
while (count == items.length) // 队满
notFull.await(); // 进“等空位”的等待室,同时放开锁
enqueue(e); // 放入;里面会 notEmpty.signal(),叫醒一个等元素的取出者
} finally {
lock.unlock();
}
验证:逐行对照JDK 21 源码摘录 B 节的 put(写入方法)、enqueue 原文。take(阻塞取出方法) 是对称的:队空(count == 0)就在 notEmpty 里等,dequeue 里会 notFull.signal()。Java 17 的这个类源码与 21 相同。
为什么用 while,不用 if? 官方文档说,等待 Condition(条件等待室) 时允许出现 spurious wakeup(虚假唤醒):没有人 signal,线程也可能自己醒来。所以应当总是在循环里等,醒来后重新检查一次条件[5]。
为什么一把锁就够? 放入要改 putIndex 和 count,取出要改 takeIndex 和 count,count(元素个数)两边共用,数组也是同一块。所以用一把锁把整个队列保护起来最直接[2]。
LinkedBlockingQueue(链式阻塞队列):两把锁,元素个数用原子变量
链表的放入只动尾部,取出只动头部,两头互不相干。所以它拆成 putLock(管放入)和 takeLock(管取出),各自带一个等待室,放入和取出可以同时进行[3]。
// 简化自 Java 21 的 LinkedBlockingQueue.put
putLock.lockInterruptibly();
try {
while (count.get() == capacity) // 队满
notFull.await();
enqueue(node); // 只改链表尾部
c = count.getAndIncrement(); // count 是 AtomicInteger
if (c + 1 < capacity) // 放完还有空位:顺手叫醒下一个等空位的放入者
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0) // 放入前队列是空的:叫醒一个等元素的取出者
signalNotEmpty();
验证:逐行对照源码摘录 C 节的 put(写入方法)、signalNotEmpty 原文。take(阻塞取出方法) 对称。注释和中文说明不能编译。
元素个数 count 是放入和取出两边都要读写的,所以它用 AtomicInteger(原子整数,第3课 CAS(比较并交换) 的应用),这样大多数时候不需要同时拿两把锁[3]。唤醒用“连锁”的办法:放入者发现队列刚从空变成非空(c == 0),才去叫醒取出者;被叫醒的取出者发现还有更多元素,再叫醒下一个[3]。remove(Object(对象类)) 和迭代器要同时拿两把锁[3],所以也不适合频繁使用。
先自己说一遍,再看参考回答
阻塞靠 ReentrantLock(可重入锁) 加 Condition(条件等待室)。ArrayBlockingQueue(数组阻塞队列) 有一把主锁和两个 Condition(条件等待室):notFull 和 notEmpty。put(写入方法) 时先加锁,队满就在 notFull 上 await,同时放开锁;放入成功后 signal notEmpty。take(阻塞取出方法) 对称。等待要放在 while 里,因为可能虚假唤醒。它用一把锁,是因为放入、取出共用 count 和同一块数组。LinkedBlockingQueue(链式阻塞队列) 是链表,放入只改尾、取出只改头,所以拆成 putLock 和 takeLock 两把锁,放入和取出可以同时进行;元素个数用 AtomicInteger(原子整数类),避免多数时候要拿两把锁。
五、有界还是无界:为什么线程池的队列要有上限
线程池为什么推荐用有界队列?Executors.newFixedThreadPool 有什么隐患?
第5课说过,线程池的 workQueue(任务队列)就是一个 BlockingQueue(阻塞队列)。现在可以讲清为什么 Executors(执行器工厂工具类) 的两个工厂方法容易出事[6]:
- newFixedThreadPool: 源码里是
new LinkedBlockingQueue<Runnable>(),没传容量,上限就是 Integer.MAX_VALUE(整数类型最大值)。队列几乎不会满,任务一直堆积,占满内存。而且第5课讲过,队列不满就不会新建超过核心数的线程,最大线程数形同虚设。 - newCachedThreadPool: 用 SynchronousQueue(同步移交队列),不存任务,最大线程数是 Integer.MAX_VALUE(整数类型最大值)。没有空闲线程来接,就新建线程,线程数基本不设限。
无界的风险,官方文档自己也承认:PriorityBlockingQueue(优先级阻塞队列) 逻辑上无界,但添加元素可能因资源耗尽而失败,也就是 OutOfMemoryError(内存溢出错误)[4]。
有界队列满了之后,放入方有三条路,对应三种方法:put(写入方法) 一直等,把压力传给上游;offer(尝试放入方法) 立即失败,由调用方决定怎么办,比如返回“系统繁忙”;带超时的 offer(尝试放入方法) 等一会儿再失败。这种“下游处理不过来时,让上游放慢或被拒绝,而不是让中间的队列悄悄变大”的做法,叫 backpressure(背压)。
BlockingQueue<String> smsQueue = new ArrayBlockingQueue<>(200);
if (!smsQueue.offer(task)) {
// 队满:按业务处理,例如提示“系统繁忙,请稍后重试”;不要在这里无限等
}
验证:队满时 offer(尝试放入方法) 立即返回 false,由示例程序 A 段验证;这段代码是片段,task(任务) 需要自己定义,不能直接编译。
容量怎么定: 沿用第5课的估算:容量 ≈ 每秒能处理的数量 × 可接受的等待秒数。例如短信网关每秒最多处理 100 条,申请人最多能接受等 2 秒,初始容量约 100 × 2 = 200。这只是起点,不是硬保证,任务耗时有波动、到达有突发,要用压测和监控验证。
先自己说一遍,再看参考回答
线程池的任务队列如果是无界的,任务一直堆积会耗尽内存,而且队列永远不满,最大线程数也起不了作用。newFixedThreadPool 用的是没指定容量的 LinkedBlockingQueue(链式阻塞队列),容量是 Integer.MAX_VALUE(整数类型最大值);newCachedThreadPool 用 SynchronousQueue(同步移交队列) 加上 Integer.MAX_VALUE(整数类型最大值) 的最大线程数,线程数不设限。所以生产环境要自己用 ThreadPoolExecutor(线程池执行器),配有界队列。队满后可以用 offer(尝试放入方法) 立即失败,或者带超时等一会,把压力传回上游,这就是背压。容量按“每秒处理量乘以可接受等待时间”估算,再用压测验证。
六、实战:手写有界生产者-消费者
手写一个生产者-消费者。消费者怎么结束?
要点有四个,代码里逐个标出:队列有界;等待交给 put(写入方法) 和 take(阻塞取出方法),自己不写 sleep 轮询;用结束标记通知消费者停止;被中断时恢复标记再退出。
先分清两层。 第四节讲的 await、signal 是队列 内部 的事。你写生产者和消费者时, 一行 await、signal、wait、notify 都不用写 :生产者只管 put(写入方法),消费者只管 take(阻塞取出方法),挂起和唤醒都在这两个方法里面完成了。也不需要谁去“唤醒生产者”:生产者放完数据,它的 run 方法执行完,线程就结束了。
为什么不能“取不到就退出”? 用 take(阻塞取出方法) 根本“取不到”:队列空了,take(阻塞取出方法) 会一直等。换成 poll(尝试取出方法),空了就退出,也不对:队列空,可能只是生产者这会儿慢,下一个任务马上就来,消费者却已经走了。所以“队列空了”和“不会再有任务了”是两件事,后者必须由生产者明确告诉消费者。
结束标记是怎么回事?官方文档说,BlockingQueue(阻塞队列) 本身没有“关闭”之类的操作,用来通知“不会再有新元素了”。常见做法是生产者放入一个特殊的结束对象(poison object(结束标记对象)),消费者取到它就退出[1]。
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2); // ① 有界:最多等待 2 个
final int END = -1; // ③ 结束标记
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 6; i++) {
queue.put(i); // ② 队满时在这里等
}
queue.put(END); // 最后放结束标记
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ④ 恢复中断标记再退出
}
});
Thread consumer = new Thread(() -> {
try {
while (true) {
int x = queue.take(); // ② 队空时在这里等
if (x == END) {
break; // ③ 取到结束标记就退出
}
System.out.println("处理 " + x);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ④
}
});
producer.start();
consumer.start();
验证:完整可运行版本是 ProducerConsumerExample.java,下载后用 Java 17 或 21 运行 java ProducerConsumerExample.java,依次输出“处理 0”到“处理 5”,最后输出“消费者结束”。示例程序 E1 另验证:取出顺序与放入顺序一致,生产者看到的队列长度从不超过 2。
多个消费者怎么停? 每个消费者取到一个结束标记就退出,而一个标记被取走就没了,所以一个标记只能停一个消费者。2 个消费者、生产者放完 10 个任务时,过程是这样的:
| 时刻 | 队列里 | 消费者1 | 消费者2 |
|---|---|---|---|
| 1 | 任务 0–9 陆续放入、陆续被取走 | take(阻塞取出方法),处理 | take(阻塞取出方法),处理 |
| 2 | 生产者放入 END、END,然后结束 | 取到 END → 退出 | 取到 END → 退出 |
| 若只放 1 个 | END 被消费者1取走,队列空了 | 退出 | 卡在 take(阻塞取出方法) 上,永远等不到 |
只放 1 个标记的后果:消费者2 永远卡在 take(阻塞取出方法) 上。用 new Thread(线程类) 创建的普通线程是非守护线程,官方文档规定,所有已启动的非守护线程都结束后,JVM(Java 虚拟机) 才开始关闭[7]。所以 main 方法都执行完了,程序还是退不出去。
验证:示例程序 E2:2 个消费者、只放 1 个标记时,还剩 1 个消费者在等;放够 2 个,才全部结束。另在 Java 21 上单独运行只放 1 个标记的版本:输出“main 结束”和“消费者1 取到结束标记,退出”后,4 秒时进程仍在运行(2026-10-05 运行)。
先自己说一遍,再看参考回答
用一个有界的 ArrayBlockingQueue(数组阻塞队列)。生产者循环 put(写入方法),队满时自动等;消费者循环 take(阻塞取出方法),队空时自动等,不需要自己 sleep 轮询。生产者放完数据后,再放一个结束标记;消费者取到结束标记就 break(跳出循环语句)。有几个消费者,就要放几个结束标记。捕获 InterruptedException(中断异常) 时,调用 Thread.currentThread().interrupt() 恢复中断标记再退出。
七、检验:先关掉上面,凭记忆做
6 道题:先回答,再展开核对
- 队列已满,调用
queue.offer(e)(不带超时)。结果是?
- A. 立即返回假值
- B. 一直等待空位
- C. 立即抛出异常
查看答案与解释
答案:A
offer(尝试放入方法) 不等待,队满时立即返回 false。一直等的是 put(写入方法);抛异常的是 add(追加方法)。
- 队列为空,调用
queue.take()。结果是?
- A. 立即返回空值
- B. 立即抛出异常
- C. 一直等待元素
查看答案与解释
答案:C
take(阻塞取出方法) 在队空时一直等到有元素,等待中可被中断。立即返回 null(空值) 的是 poll(尝试取出方法);抛异常的是 remove。
- 不指定容量创建
LinkedBlockingQueue(链式阻塞队列),它的容量上限是?
- A. 上限固定十六个
- B. 上限约二十一亿
- C. 上限固定一百个
查看答案与解释
答案:B
不指定时容量等于 Integer.MAX_VALUE(整数类型最大值),约 21 亿,几乎等于没有上限。所以用它一定要传容量。
- ArrayBlockingQueue(数组阻塞队列) 与 LinkedBlockingQueue(链式阻塞队列) 各用几把锁?
- A. 前者一把后者两把
- B. 前者两把后者一把
- C. 两者都完全不加锁
查看答案与解释
答案:A
数组版用一把主锁,放入、取出共用 count 和同一块数组;链表版拆成 putLock 和 takeLock,放入只动尾、取出只动头。
- 线程池的任务队列用没指定容量的 LinkedBlockingQueue(链式阻塞队列),最主要的风险是?
- A. 线程数量快速翻倍
- B. 任务总被直接拒绝
- C. 任务堆积耗尽内存
查看答案与解释
答案:C
队列几乎不会满,所以不会拒绝,也不会新建超过核心数的线程;任务一直堆积,最后耗尽内存。
- 3 个消费者共用一个队列,靠结束标记通知停止。生产者应放几个标记?
- A. 放一个就能全停
- B. 放三个才能全停
- C. 放两个就能全停
查看答案与解释
答案:B
每个消费者取到一个标记就退出,一个标记只能停一个消费者,所以要放三个。示例程序 E2 验证过。
八、用完整回答检验理解
连续回答下面四问,可以写下来,也可以先口述再整理成文字。写完再对照各节的参考回答。
- BlockingQueue(阻塞队列) 是什么?add(追加方法)、offer(尝试放入方法)、put(写入方法) 有什么区别?
- ArrayBlockingQueue(数组阻塞队列) 和 LinkedBlockingQueue(链式阻塞队列) 有什么区别?它们是怎么做到“队满等、队空等”的?
- 手写一个生产者-消费者,消费者怎么结束?
- 举一个适合用阻塞队列的场景。如果下游每秒只能处理 100 条通知,队列选哪种、容量多大、队满了怎么办?
第四问可以参考第九节末尾的应用场景候选。
练习:请在自己的笔记中写出答案,再展开参考内容核对。
九、常见追问
每问先自己答,再展开核对。答案都在前面各节里。
关于方法与行为(第一、二节)
put(写入方法) 在等待时被中断,会怎样?
线程立刻醒来,抛出 InterruptedException(中断异常),而且中断标记已经被清除。所以 catch 里要调用 Thread.currentThread().interrupt() 恢复标记,再退出。
为什么阻塞队列不允许 null(空值)?
poll(尝试取出方法) 用返回 null(空值) 表示“队空,没取到”。如果元素也可以是 null(空值),调用方就分不清是取到了 null(空值),还是什么都没取到。和 ConcurrentHashMap(并发哈希映射) 禁止 null(空值) 是同一类歧义。
addAll 是原子的吗?remove(x) 能随便用吗?
addAll、removeAll 这类批量方法不一定是原子的:addAll 可能只加了一部分就抛异常。remove(x) 能用,但效率一般不高,官方说只适合偶尔用,比如取消一条排队中的消息[1]。
关于实现(第三、四节)
await 为什么要放在 while 循环里?
因为允许虚假唤醒:线程没被 signal 也可能醒来。醒来后要重新检查条件,满足了才往下走[5]。
LinkedBlockingQueue(链式阻塞队列) 为什么用 AtomicInteger(原子整数类) 记元素个数?
放入和取出各用一把锁,但都要读写元素个数。用原子变量,大多数时候就不用同时拿两把锁[3]。
ArrayBlockingQueue(数组阻塞队列) 的公平策略是什么?
构造时可以选公平。默认不保证等待线程的顺序;设成 true,就按先进先出授予访问。官方说公平通常会降低吞吐,但能减少波动、避免饥饿[2]。
关于选型(第三、五节)
SynchronousQueue(同步移交队列) 适合什么场景?
需要“一手交一手”的移交:任务必须当场交给一个正在等的线程。它没有容量,没有接收者时放入会失败或等待。Executors.newCachedThreadPool 用的就是它[6]。
PriorityBlockingQueue(优先级阻塞队列) 和 DelayQueue(延迟队列) 分别适合什么?
PriorityBlockingQueue(优先级阻塞队列) 按优先级而不是先进先出取出,适合任务有高低优先级;DelayQueue(延迟队列) 的元素到期后才能被取出,适合延迟执行。两者都是无界的[4]。
阻塞队列和 ConcurrentLinkedQueue 有什么区别?
JavaGuide 的概括是:阻塞队列可以通过加锁实现,非阻塞队列可以通过 CAS(比较并交换) 实现[JG]。更关键的区别是:阻塞队列在队满、队空时会让线程等待;ConcurrentLinkedQueue 不会等,取不到就返回 null(空值)。这是一句概括,不是官方保证。
应用场景:可能用在哪里(候选)
四个候选场景,和一个必须会说的限制
- 办结通知异步发送: 请求线程用 offer(尝试放入方法) 把短信或站内信任务放进队列,队满时返回“系统繁忙”或降级;后台发送线程用 take(阻塞取出方法) 取出发送。
- 批量导出排队: 导出耗时长,请求线程只负责提交,后台线程按顺序处理,队列满了就拒绝新的导出请求。接第5课的导出场景。
- 操作日志、审计记录异步写库: 请求线程把日志对象放入队列,后台线程取出后落库,不拖慢业务请求。
- 对接外部系统的限速排队: 外部接口每秒只能调用固定次数,请求先排队,由数量固定的消费者线程去调用。
必须会说的限制: 队列放在进程的内存里。进程重启,队列里没处理完的任务就丢了;部署了多个实例时,每个实例各有一个队列,互相不知道。需要“任务不能丢”或“多个实例协同”时,要用消息队列。
十、隔日复测:明天换条件再答(约 5 分钟)
展开题目
- 队满时,put(写入方法)、offer(e)、offer(e, 1, SECONDS) 各会怎样?
- ArrayBlockingQueue(数组阻塞队列) 为什么只要一把锁,LinkedBlockingQueue(链式阻塞队列) 却用两把?
- 第6课:JDK 8 的 ConcurrentHashMap(并发哈希映射) 执行 put(写入方法) 时,什么情况用 CAS(比较并交换)?什么情况锁头节点?
作答后核对
① put(写入方法) 一直等到有空位;offer(e) 立即返回 false;offer(e, 1, SECONDS) 最多等 1 秒,仍没空位就返回 false。
② 数组版放入、取出共用 count 和同一块数组,一把锁保护最直接;链表版放入只动尾、取出只动头,所以拆成 putLock 和 takeLock,元素个数用 AtomicInteger(原子整数类) 共享。
③ 由桶的状态决定:桶是空的,用 CAS(比较并交换) 放入;桶里已有节点,直接锁住头节点,不先试 CAS(比较并交换)。只有空桶 CAS(比较并交换) 失败后,回到循环开头,发现桶已非空,才转去加锁。对照第6课第四节。
主来源与引用
[1] BlockingQueue(阻塞队列) 类说明: 官方文档,包括四种方法的表、null(空值)、容量、批量方法、结束标记、可见性。原文存于官方摘录 A 节。
[2] ArrayBlockingQueue(数组阻塞队列): 官方文档的类说明,以及 put(写入方法)、take(阻塞取出方法)、enqueue、dequeue 源码。摘录 B 节。
[3] LinkedBlockingQueue(链式阻塞队列): 官方文档的类说明、“两把锁”源码注释,以及 put(写入方法)、take(阻塞取出方法)、signal 源码。摘录 C 节。
[4] SynchronousQueue(同步移交队列)、PriorityBlockingQueue(优先级阻塞队列)、DelayQueue(延迟队列): 各自类说明的开头。摘录 D、E 节。
[5] Condition(条件等待室): 官方文档中 await 的行为、中断标记被清除、虚假唤醒。摘录 F 节。
[6] Executors(执行器工厂工具类) 工厂方法使用的队列: newFixedThreadPool 用无容量的 LinkedBlockingQueue(链式阻塞队列),newCachedThreadPool 用 SynchronousQueue(同步移交队列),见线程池官方摘录;第5课队列一节已用到。
[7] Thread(线程类) 类说明: 官方文档中“所有已启动的非守护线程结束后,关闭流程才开始”。原文见官方摘录 G 节。
[JG] JavaGuide: 并发容器总结,用于选题参考、PriorityBlockingQueue(优先级阻塞队列) 概述,以及阻塞与非阻塞队列的概括。
本课速查表 · 完整术语表 · 第6课 · 第5课 · 第4课
学完后,记下判断理由和仍不确定的地方。隔日回看时先尝试回答,再核对课程里的解释。