搞并发编程的人,迟早会碰一次“手写阻塞队列”这道坎。不管你是面试准备还是自研中间件,只要你用了线程池,用了生产者-消费者模型,就会绕不开两个基础问题:线程到底有哪些状态、状态之间怎么跳;线程之间怎么用最朴素的Object方法来排队。这两块如果不透,后面看ArrayBlockingQueue、看线程池源码基本就是看天书。这篇文章我就用实际代码把“线程生命周期”和“基于Object的阻塞队列”彻底串起来讲,从状态机原理到wait/notify的底层行为,再到手写一个有界队列的完整过程,最后再聊聊线程池里到底该选哪类队列——全程按我踩过的坑来讲,适合想搞懂并发底层、又不想只看理论的人,也适合准备面试时想拿出点“真动手”素材的人。
1. 线程生命周期全景拆解:六种状态到底在说什么
1.1 一张状态转换表看懂全部流转
Java的线程生命周期不像操作系统的进程状态那么复杂,JVM帮我们收敛成了六种状态,直接定义在Thread.State枚举里:
NEW:线程对象创建了,但还没调用start()。RUNNABLE:线程已经启动,可能正在运行,也可能在等待CPU时间片。注意它包含了操作系统里的“运行中”和“就绪”两个状态。BLOCKED:线程在等待一把对象锁,想进入synchronized代码块或方法,但锁被别人持有。WAITING:线程主动让自己无限期等待,靠其他线程来唤醒。典型调用是Object.wait()、Thread.join()、LockSupport.park()。TIMED_WAITING:带超时时间的等待,超时后会被自动唤醒。典型调用是Thread.sleep()、wait(long)、join(long)、parkNanos()。TERMINATED:线程执行完run()方法,或者抛出了未捕获异常而结束。
只看定义容易混,我建议你把它想成一条流水线:NEW是领料未开工,RUNNABLE是干活或排队的作业员,BLOCKED是去食堂打饭时排队等那个窗口(锁),WAITING是坐那儿等别人发无声指令,TIMED_WAITING是“说好5分钟后来叫我”,TERMINATED就是下班离场。我实际排查线上问题的时候,看线程dump主要就看BLOCKED和WAITING这两大类,因为RUNNABLE太多反而不好定位,这一点后面第五节会展开讲。
为了验证每个状态,我写过一段小实验代码,你可以直接跑一下看输出:
public class ThreadStateDemo { public static void main(String[] args) throws Exception { Thread t = new Thread(() -> { synchronized (ThreadStateDemo.class) { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }); System.out.println("刚创建:" + t.getState()); // NEW t.start(); System.out.println("启动后:" + t.getState()); // RUNNABLE Thread.sleep(100); System.out.println("抢锁前:" + t.getState()); // RUNNABLE或BLOCKED Thread t2 = new Thread(() -> { synchronized (ThreadStateDemo.class) { System.out.println("t2 获取到锁"); } }); t2.start(); Thread.sleep(50); System.out.println("t2 被t1挡住时:" + t2.getState()); // BLOCKED t2.join(); } }我实测下来,t2.getState()输出BLOCKED是稳定的,前提是主线程确认拿到那把锁并sleep住。这个状态判断在调试上非常有用,比如你看到一个线程长期停在BLOCKED,那大概率就是锁竞争或者锁没释放,而不是CPU不够。
1.2 为什么阻塞态和等待态不是一回事
很多新手会把BLOCKED和WAITING混在一起,但它们在语义上有本质区别:BLOCKED是在“被动等锁”,线程本身没别的事可干,只能等别人释放synchronized监视器;WAITING则是“主动放弃执行”,可能是自己在等待条件满足。这两个状态在jstack的线程栈里长得也不一样,BLOCKED的栈顶通常能看见locked <0x...>的提示,附近有waiting for monitor entry;而WAITING通常能看到java.lang.Object.wait()或LockSupport.park()的字样。
这里最容易踩坑的一点是:Thread.sleep(long)不会释放锁,而Object.wait(long)会释放锁。很多人写着写着就把sleep当wait用,导致别的线程压根进不来。我曾经在一个生产者-消费者Demo里把wait(100)写成sleep(100),结果生产者死等,消费者拿不到锁,整条链路直接僵住。后来看线程dump才发现全部卡在monitor entry上。
另外还要注意join()底层的等待:t.join()本质上是当前线程调用t对象上的wait(),直到t线程结束才会被JVM通知。所以你在主线程里去join一个子线程,主线程会进入WAITING状态,等子线程终止。
1.3 生命周期在工程上的真正价值
学状态不是背概念,而是为了能“看懂现场”。线上排查死锁、假死、线程池打满,第一手资料就是线程快照。比如线程池里所有线程都处于WAITING,说明任务队列为空,大家闲着;如果大量线程BLOCKED在某个锁上,说明存在激烈竞争或持锁线程卡住了。有了这个判断力,你才能对“阻塞队列为什么要有”“满了之后谁阻塞”“队列空了谁等待”这些设计有直觉。
也正因为状态机是后面一切的基础,我在讲阻塞队列之前必须把这个地基夯实:阻塞队列的本质,就是通过控制线程在WAITING和RUNNABLE之间的转换,来实现生产与消费的速率匹配。
2. Object的wait/notify机制:线程通信的底层密码
2.1 每个对象都是一把锁:监视器与对象头
Java里任何一个普通对象都能成为锁,是因为JVM在对象头里维护了与监视器(Monitor)相关的信息。synchronized其实就是JVM层面的monitorenter和monitorexit指令:进入同步代码块时尝试获取monitor,成功则持有,失败则当前线程进入阻塞队列。这也就解释了为什么wait()和notify()必须出现在synchronized代码块里——它们操作的本就是同一个monitor上的等待集。
如果你在synchronized外面调用wait(),会直接抛IllegalMonitorStateException。我第一次看到这个异常特别困惑:wait为什么非得“霸占着锁”才能等?实际它的语义是:只有已经持有该对象监视器的线程,才有资格决定“我要暂时交出监视器,进入这个对象的等待集”。如果不持锁就随便调用,等待集的管理就乱了。
这里顺便提一下“object representation”这个概念:JVM里一个对象的内存布局大致是对象头、实例数据、对齐填充。对象头里除了Mark Word和类型指针,还隐含了锁状态、偏向锁信息、以及指向monitor的指针。很多人追问为什么synchronized加锁有性能开销,就是因为每次进入监控区域都要查Mark Word、更新锁状态。虽然现代JVM做了偏向锁和轻量级锁优化,但理解这层底层表示,对你后面理解“为什么锁竞争激烈时性能下降”很有帮助。
2.2 wait到底干了什么
调用wait()之后,当前线程做三件事:释放监视器锁;把自己加入该对象的等待集(wait set);状态进入WAITING(或TIMED_WAITING)。直到其他线程调用同一对象上的notify()或notifyAll(),它才有机会从等待集出来;但它并不会立即继续执行,而是回到“重新竞争锁”的队列里,重新抢到锁之后,从wait()返回的位置继续往下走。
这里最容易被忽视的是:wait被唤醒后,原有条件不一定满足。比如队列满了,多个生产者都在wait,一个消费者取走元素后notifyAll,所有生产者都醒了,但只有一个能抢到锁并放入元素,其他生产者抢到锁时必须再次检查队列是否仍然满。如果不检查,就会出现“超过容量”的bug。所以标准写法是:
synchronized (lock) { while (!condition) { lock.wait(); } // do something }为什么一定是while而不是if?除了上面说的多线程醒后条件可能不成立,还有一个原因是伪唤醒(spurious wakeup)。虽然JVM规范没有强制,但允许等待线程在没有通知的情况下自行醒来。用while重新检查条件,就是为所有意外醒来兜底。我把这条当成铁律,任何用wait的代码,一律while。
再对比一下wait(long)和sleep(long):sleep不会释放锁,wait(long)会释放锁;sleep是Thread的静态方法,wait是Object的方法;sleep到期自动继续,wait(long)到期后也要重新抢锁。
2.3 notify还是notifyAll:选择背后的原因
notify()随机唤醒等待集里的一个线程,而notifyAll()唤醒所有等待线程。看起来notify()更省事,但在生产者-消费者场景里,用notify()很可能出事。
我举个具体例子:一个容量为1的队列,一个生产者等待放元素,两个消费者等待取元素。生产者放入元素后调用notify(),如果恰好唤醒的是另一个生产者,那个生产者醒来自查条件是count == capacity?还是满的,于是继续wait。这就导致:明明队列里有一个元素可取,却没有消费者被唤醒,整体假死。这种bug非常隐蔽,因为它是概率性的,要运气差才触发。
所以我的经验是:如果你用的只有一个等待集合(Object方案只有一个wait set),那必须用notifyAll,宁可让多唤醒的线程重新抢锁,也不能让该唤醒的线程漏掉。notify()只在非常明确“唤醒任何一个等锁线程都等价”的时候才用,比如简单的读写锁计数器,但那种场景其实我建议直接换Lock和Condition更清晰。
notifyAll的代价是“惊群”:多个线程同时醒来,同时竞争锁,但没有拿到锁的线程会重新进入BLOCKED。这是内存与CPU的浪费,可换来的安全性在大多数自研场景更划算。等进入第五节我会讲,JDK的ArrayBlockingQueue是怎么通过两个Condition来规避这个问题的。
3. 手写一个基于Object的阻塞队列:从0到1完整实现
3.1 需求分析与设计取舍
现在我们把前面的知识拧成一个真东西:一个有界阻塞队列,支持两个核心方法:
put(E e):放入元素,队列满则阻塞当前线程,直到有空位。take():取出并移除队首元素,队列空则阻塞当前线程,直到有元素。
在动手前先做几个设计取舍。第一,数据结构用循环数组。为什么不用链表?因为我们要求有界,数组天然固定容量,而且环形数组的入队出队都能做到O(1),空间也连续,对缓存友好。链表虽然扩容灵活,但有界场景下需要额外维护节点引用,开销更大。第二,并发控制用synchronized锁整个方法。这是因为我们要用wait/notify,它们要求先持有该对象的锁。把put和take都声明为synchronized,自然就保证了同一时刻只有一个线程在修改队列内部状态。第三,不允许放入null元素,因为null在队列里会被当作“空位”哨兵值,用户如果放null,take的时候就会和空位判断混淆。
核心技巧是这个环形数组下标计算:维护两个指针putIndex和takeIndex,每次入队后putIndex = (putIndex + 1) % capacity,出队后takeIndex = (takeIndex + 1) % capacity,再用一个count记录当前元素数量。这样数组的物理空间始终是固定的,逻辑上像一个循环转盘。判断满就是count == capacity,判断空就是count == 0。
3.2 第一版实现:循环数组加wait/notifyAll
直接上代码,这是我压測过并且在线下模拟过高竞争的基础版本:
public class ObjectBlockingQueue<E> { private final Object[] items; private int putIndex; private int takeIndex; private int count; public ObjectBlockingQueue(int capacity) { if (capacity <= 0) { throw new IllegalArgumentException("容量必须大于0"); } items = new Object[capacity]; } public synchronized void put(E e) throws InterruptedException { if (e == null) { throw new NullPointerException("不允许放入null"); } while (count == items.length) { wait(); } items[putIndex] = e; putIndex = (putIndex + 1) % items.length; count++; notifyAll(); } @SuppressWarnings("unchecked") public synchronized E take() throws InterruptedException { while (count == 0) { wait(); } E e = (E) items[takeIndex]; items[takeIndex] = null; // 帮助GC takeIndex = (takeIndex + 1) % items.length; count--; notifyAll(); return e; } public synchronized int size() { return count; } }逐段拆解一下为什么这么写。
wait()为什么要写在while循环里,前面已经强调过了,这是防止伪唤醒和条件不满足时误继续执行。put里的条件是“队列满时等待”,take里的条件是“队列空时等待”,一定要注意条件方向别写反。
notifyAll()在put和take的最后都要调用:put之后队列从可能空变成非空,要唤醒可能等待的消费者;take之后队列从可能满变成非满,要唤醒可能等待的生产者。这里绝对不能省成notify(),原因就是2.3节说的假死风险。
items[takeIndex] = null这行很容易漏,但不做的话,数组里还残留着已经取走的对象的引用,会造成对象无法被GC回收。长期运行的队列如果一直保留旧引用,内存会悄悄增长,这是我在实际监控中踩到过的坑。
方法的synchronized会带来一个代价:所有访问队列的线程全部串行,包括只读的size()。但在这个教学实现里,用整个对象加锁是为了让wait/notify的语义最简单可靠。后面我会说这种方案在什么情况下该被替换。
3.3 手写测试:验证阻塞与唤醒行为
光写实现不算完,要证明它真的会阻塞、真的能唤醒。我写了一个标准的生产者-消费者验证脚本:
public class QueueTest { public static void main(String[] args) throws Exception { ObjectBlockingQueue<Integer> queue = new ObjectBlockingQueue<>(2); Runnable producer = () -> { for (int i = 1; i <= 5; i++) { try { queue.put(i); System.out.println("生产: " + i + ",队列大小: " + queue.size()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }; Runnable consumer = () -> { for (int i = 1; i <= 5; i++) { try { Integer val = queue.take(); System.out.println("消费: " + val); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }; Thread p1 = new Thread(producer); Thread c1 = new Thread(consumer); p1.start(); c1.start(); p1.join(); c1.join(); System.out.println("最终队列大小: " + queue.size()); } }我实测后的关键现象是:当生产者连续put到第3个元素时,队列容量为2已经满了,此时生产者线程会进入WAITING状态;消费者take走一个元素后,生产者被notifyAll唤醒,再继续放入。整个过程消费者不需要主动介入,生产速度会被队列容量自然限流。这其实就是阻塞队列最核心的“背压”机制,你做消息队列、任务调度缓冲,靠的就是这个节奏。
为了验证while的必要性,我做过一个危险实验:把while换成if,然后启动2个生产者线程同时往容量为1的队列里塞元素。跑了大约几十万次之后,终于复现了一次count == 2的情况,而两个生产者都已经“成功”入队了。这种bug不压测根本看不见,可一旦在生产环境出现,就是数据错乱。所以再次强调:wait外面必须是while,没有例外。
3.4 基于这个实现,聊聊Object方案的局限性
这套基于Object的队列,能用,而且我觉得是理解并发原语的绝佳教具。但它有三点局限:
第一,只有一个等待集合。所有因队列满、队列空而等待的线程都挤在同一个对象的wait set里,所以唤醒时只能全唤醒,带来无谓竞争。JDK的ArrayBlockingQueue通过ReentrantLock和两个Condition(notEmpty、notFull)把两类等待线程分开,唤醒生产者时不会惊动消费者。这也是我自己实现的队列在重竞争下吞吐不如JDK队列的根本原因。
第二,synchronized不支持可中断的锁获取和超时锁获取。虽然wait()本身可中断,但进入synchronized时如果锁被持有,只能一直等,无法设超时。这对需要超时控制的路由场景就很别扭。
第三,公平性不好控制。默认synchronized是非公平锁,高竞争下可能出现线程饥饿。如果你需要公平调度,还是得用ReentrantLock(true)。
所以在实际项目中,我通常只在“代码量极少、并发量不高、且希望零依赖”的情况下保留Object方案。一旦进入高并发的核心链路,直接换Lock+Condition,或者直接用JDK现成的ArrayBlockingQueue。
4. 线程池里的阻塞队列选型:懂原理才能选对型
4.1 线程池为什么需要排队的队列
ThreadPoolExecutor的调度逻辑,核心就四步:核心线程数没满,先开新线程跑任务;核心线程满了,任务先往队列里放;队列满了,才开新线程到最大线程数;最大线程数也满了,走拒绝策略。
这四步里,队列是缓冲,也是调节器。队列选型不同,线程池的整体表现可以是“来多少接多少”,也可以是“超过阈值直接丢弃”。很多人直接把Executors.newFixedThreadPool一用就完事,其实那个池子默认用了无界LinkedBlockingQueue,一旦任务积压,队列会无限增长,最后内存先爆。我见过几次线上服务内存持续上升,排查到最后都是某个线程池任务堆积,队列无限膨胀。这就是选型问题。
从我们手写阻塞队列的角度看,线程池里这个队列本质上也是“生产者-消费者”模型里的缓冲区:业务线程是生产者,池里的工作线程是消费者。正因为有容量限制和有界/无界之分,线程池的调度策略才有意义。
4.2 四类常见队列对比
经常用的队列无非这四种,我用表格直接对比:
| 队列 | 边界性 | 数据结构 | 是否支持优先级 | 典型使用场景 |
|---|---|---|---|---|
| ArrayBlockingQueue | 有界 | 数组 | 否 | 有界任务缓冲,选型最常用 |
| LinkedBlockingQueue | 有界/无界 | 链表 | 否 | 默认线程池常用,无界时需警惕积压 |
| SynchronousQueue | 无缓冲 | 直接交接 | 否 | 希望任务直接交给工作线程,不排队 |
| PriorityBlockingQueue | 无界 | 堆 | 是 | 任务有优先级语义 |
| DelayQueue | 无界 | 堆 + 延迟时间 | 是 | 延迟任务、定时任务 |
ArrayBlockingQueue和LinkedBlockingQueue的差异还体现在性能和内存上:数组因为固定容量,默认一次性分配所有槽位的内存,占用的常驻内存更稳定;链表则是每个节点动态分配,入队时有一定分配开销,但可以做到“队列长度按需增长”。所以如果你要严格限制线程池等待队列的长度,用ArrayBlockingQueue更顺。
SynchronousQueue很有意思,它内部没有缓存,生产者放一个元素必须等消费者立刻来取,否则就一直阻塞。它适合那种“任务要立刻有人处理”的场景,配合newCachedThreadPool可以做到来一个任务直接开一个工作线程。但如果你以为它省内存就无脑用,后果就是线程数疯狂创建,资源照样绷不住。
选队列的关键和选容量一样:你要明确“队列满了之后怎么办”。ThreadPoolExecutor提供了四种拒绝策略,默认的AbortPolicy是直接抛RejectedExecutionException,而CallerRunsPolicy是让提交任务的线程自己跑这个任务,相当于变相把压力传回去。这个我没少踩坑,默认策略之前导致过一些非核心任务被直接丢弃,后续加了CallerRunsPolicy+监控告警才算兜住。
4.3 从Object实现反观JDK队列的核心差异
把我们手写的ObjectBlockingQueue和JDK的ArrayBlockingQueue源码对照看,差异就非常明显。ArrayBlockingQueue内部用ReentrantLock lock和两个Condition notEmpty、notFull,put时,如果count == items.length,执行notFull.await(),放完执行notEmpty.signal();take则相反。它本质上还是“满了等、空了等、操作完唤醒对方”,但因为有独立的等待集合,signal()只唤醒对应条件里的一个线程,竞争少得多,吞吐自然高得多。
这也解释了为什么我前面说Object方案必须notifyAll:我们的队列只有一个wait set,如果不全唤醒,就可能出现“队列有元素但消费者没人被通知”的假死。
从源码看它和我手写版的另一个差别是可中断锁:ArrayBlockingQueue的锁获取可以响应中断,await也可以限时。这些能力靠synchronized是拿不到的。
所以我的建议是:你完全可以用Object方案去理解上下文切换、理解日常并发逻辑,但真正上生产,还是选JDK自带队列更稳。选择ArrayBlockingQueue还是LinkedBlockingQueue,核心看两个维度:要不要限制队列长度,以及你更怕内存常驻还是更怕动态分配。想严格限流,选Array;不想丢任务且内存充足,选Linked无界,但一定要配合监控,队列长度到达预警值就要响应。
5. 实操中的典型问题与排查技巧:从状态到锁一眼看穿
5.1 死锁排查
手写阻塞队列很容易写出死锁,而且它还不是教科书里那种AB-BA经典死锁,而是“队列满,生产者等;队列空,消费者等;两边都在等对方先动”的活锁变体。如果生产者数量不够,消费者数量也不够,队列两头就会慢慢都陷入WAITING。这时候用jstack看线程dump,你能看到所有工作线程都停在ObjectBlockingQueue.put或take的wait()里,没有任何一个处于RUNNABLE。
排查死锁的标准动作是先拿到线程快照:
jstack <pid> > thread_dump.txt然后搜BLOCKED和WAITING,看每个等锁线程在等哪把锁的monitor,锁被谁持有。如果持有锁的线程长时间停在某个wait()上,就要倒推是谁该唤醒它。我曾经在生产环境排查过一个问题:一个线程池的消费线程因为业务代码里一个while(true)死循环,导致它一直持有锁不释放,其他线程全部BLOCKED在monitor entry上,现象是服务接口大面积超时。从dump里一眼就能看到那个RUNNABLE的线程栈顶是死循环,这就是突破口。
5.2 假死与无响应
假死的典型表现是:队列有元素,但消费者全都WAITING。这类问题我们前面分析过,一旦用了notify()且运气不好,就可能发生。我在早期做Demo时亲历过一次:生产者和消费者各一个的时候没事,改成两个消费者后,跑了半天突然卡住,队列里明明有元素,但消费者全在wait。后来复现时加高并发压力,很快就稳定复现了。原因就是生产者放入元素后调用了notify(),它唤醒了一个不满足条件的生产者,而消费者没被叫醒。
排查假死时,除了看线程状态,还要关注有多少线程在等待、等待的条件是什么。结合我们手写队列的字段,你可以直接检查count、putIndex、takeIndex的值来判断队列内部是不是处于一种“死锁但数据结构没坏”的状态。这也是为什么我在实现里加的size()方法不仅仅是给业务用的,它也是排查时的探针。
5.3 线程池队列使用中的坑
线程池队列选型上的坑,我在4.2节已经提到了内存打爆的问题,这里再补充两个具体的。
第一,核心线程数、最大线程数、队列容量三者必须一起算。线程池的创建顺序是“核心线程先跑,满了进队列,队列满了才加线程”。如果你的核心线程数设了10,队列容量设了10000,那最大线程数哪怕设了100,也几乎永远不会触发。因为10000个任务都排队去了,谁会去开新线程呢?所以这配置其实是“固定10个线程在跑,其他任务全排队”,和newFixedThreadPool(10)没本质区别。我建议演进方向是:队列容量能反映“你能容忍的积压延迟”,最大线程数能反映“峰值时你允许开多少并发”,两者根据业务QPS和RT来估,而不是拍脑袋。
第二,拒绝策略不是洪水猛兽。我以前总觉得拒绝策略就是丢任务,后来发现合理使用CallerRunsPolicy能天然限流:任务被拒绝时,由提交任务的那个线程亲自执行,这个线程本来还要继续提交任务,现在被占用了,提交速度自然降下来。这是不依赖中间件的“背压”方案。如果你配合监控,还能在拒绝量超过阈值时报警,比无界队列悄悄膨胀安全得多。
其实这些实操问题回到根本,还是回到线程状态和等待唤醒机制的理解上:队列会不会满、谁在等、谁该被唤醒、唤醒后干什么,所有答案都藏在状态转换和wait/notify语义里。
我个人在实际操作里有个习惯:任何用了自研阻塞队列或复杂等待逻辑的代码,上线前不加压测不放心。你们用我上面那个ObjectBlockingQueue做实验,加两个生产者、两个消费者,把容量设小,多跑几轮,很容易碰到各种边界情况。这类问题最好的学习方式就是亲手复现一遍,然后看线程栈去分析原因。等你哪天能不看资料,自己把假死原因解释得明明白白,那线程生命周期和阻塞队列这块就算真正吃透了。