先说结论:Java 里一个线程从出生到销毁,拢共就六个状态,而阻塞队列的实现,核心就是 wait/notify 这两个最朴素的等待唤醒机制在撑场面。很多人用 ThreadPoolExecutor 用得飞起,但一问到线程什么时候处于 WAITING、什么时候处于 BLOCKED,立马含糊。我带过好几个新人,十个里八个卡在这一关。这篇就把线程生命周期和基于 Object 的 wait/notify 阻塞队列一次讲透,最后再聊聊线程池里的队列到底该怎么选、为什么这么选。这里的 Object,就是 Java 的 java.lang.Object,别想歪了。
1. 线程生命周期:六个状态背后的真实含义
六个状态分别是NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。死记硬背没用,你得把它理解成"线程当前在干嘛"的描述。这六个状态按行为可以归成四类:还没开始跑、能抢 CPU、被挡住、已经结束。
1.1 NEW、RUNNABLE、TERMINATED,相对简单的三个
先说最简单的两个。NEW是你new Thread()之后、还没调用start()之前的状态,线程对象已经存在,但底层操作系统线程还没创建,啥也不干。TERMINATED则是run()方法正常返回或者抛异常结束之后的状态,线程已经凉透了,不能重启,重启会直接抛IllegalThreadStateException。
RUNNABLE这个最容易误解。很多人以为 RUNNABLE 就是"正在运行",错。Java 把"就绪"和"运行中"合并成了 RUNNABLE 一个状态:线程拿到 CPU 时间片在跑是 RUNNABLE,线程在等待队列里排队等 CPU 同样是 RUNNABLE。你去看 Linux 下 top 命令,一个进程里可能有一堆 RUNNABLE 状态的线程,但 CPU 只有那么几个核,不可能所有线程同时在跑。RUNNABLE 只是说"这线程没在睡觉也没被锁挡着,随时可以被调度器选中执行"。所以Thread.yield()这个操作其实就是从"运行中"退回"就绪",状态还是 RUNNABLE,只是主动让出 CPU。
想直观验证,写几行代码就能看到状态:
Thread t = new Thread(() -> { try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); System.out.println(t.getState()); // NEW t.start(); Thread.sleep(100); System.out.println(t.getState()); // RUNNABLE(就绪或运行中) Thread.sleep(100); System.out.println(t.getState()); // 大概率 TIMED_WAITING t.join(); System.out.println(t.getState()); // TERMINATED这里有个细节值得留意:t.getState()是让你观察别人家的线程,不是查自己的状态。你没法在一个线程内部准确拿到"我正在跑"这种信息,因为此刻你反手一查,自己当然是 RUNNABLE。
1.2 BLOCKED 和 WAITING,重点中的重点
BLOCKED和WAITING日常最容易混。区别一句话就能说清:BLOCKED 是进不去 synchronized 的门口,WAITING 是已经进到门里之后主动挂起、等一个信号。
BLOCKED 只出现在抢synchronized内置锁的场景。比如锁被另外的线程持有,你这边执行到synchronized(lock)进不去,线程就停在 BLOCKED。注意:ReentrantLock的lock()抢不到锁时线程处于 WAITING,而不是 BLOCKED。这是一个特别容易踩的面试坑——JUC 的锁走的是LockSupport.park(),走的是 WAITING,跟 synchronized 的 BLOCKED 是两套机制。
WAITING 则是有明确触发条件的:Object.wait()无超时版本、Thread.join()无超时版本、LockSupport.park()。这三个动作的共同点是线程主动表示"我不跑了,等有人叫我"。进入 WAITING 后不消耗 CPU,也不参与锁竞争,就死等一个唤醒信号。
还有个细节,很多人不知道:wait()被唤醒、重新去抢锁的时候,如果锁还被别人占着,线程会从 WAITING 转入 BLOCKED,而不是直接回到 RUNNABLE。wait 唤醒只是拿到了竞争锁的资格,还没拿到锁本身。这个细节和你后面自己写阻塞队列的体验强相关,因为 put/take 操作里 wait 和 synchronized 是嵌套在一起的。
TIMED_WAITING可以理解为带闹钟的 WAITING:Thread.sleep()、wait(timeout)、join(timeout)、parkNanos()都是这个状态。到时间自动醒,不需要外部通知。
2. 为什么值得把阻塞队列手写一遍
JDK 里现成的阻塞队列都有现成的:LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue,随便用一个不就完了吗?自己手写不是脱裤子放屁?我年轻的时候也这么想,直到面试被问傻、线上排查死锁抓瞎,才明白手写一遍的价值在哪。
2.1 手写的价值:面试、排查、读源码三合一
面试必考题就是"请手写一个生产者消费者模型"或者"实现一个有界阻塞队列"。你要是只会new ArrayBlockingQueue<>(10),这道题基本白给。但手写一遍之后你会真的理解两件事:第一,wait()被调用后会释放锁,这句话很多人背得滚瓜烂熟,但只有自己写了队列、压测了多线程程序,才知道"释放锁"和"等待"之间那个原子性有多重要;第二,为什么队列空和队列满必须用两个不同的等待条件,以及 JUC 源码里那些 lock 和 condition 到底在替代什么。
另一个用得上手写知识的场景是线上排查。你拿 top -H 看到一堆线程堆在 WAITING,得能分辨它们卡在哪个 condition 上。如果线程是被 JUC 的Condition.await()挂起的,线程栈里能看到park字样;如果是Object.wait(),栈里会显示对应的 wait 方法。没有手写过 while 循环等待,你根本不会去想"为什么 wait 要放在循环里"这个问题,也就不会警觉线上可能出现虚假唤醒导致的脏读。
2.2 Object 的 wait/notify 是怎么工作的
synchronized加在对象上,本质是拿这个对象当锁,锁的状态其实存在对象头里。HotSpot 的 mark word 里就记录了锁相关信息,锁竞争升级后,对象头会指向一个 ObjectMonitor 对象,这个 monitor 内部维护两个关键集合:一个用来记录当前持有锁的线程,一个用来挂起调用了wait()的线程(对应 WAITING 状态)。
wait()的语义是:调用线程必须已经持有该对象的锁,调用后原子地做三件事——把自己加入该对象的等待集合、释放持有的锁、挂起。为什么强调"原子"?因为如果释放锁和挂起不是一步完成的,中间被其他线程插进来,就可能出现"通知丢失":你还没挂起来,别人已经把数据放进去并调了 notify,等你去等的时候已经没有信号了。这一块在后面第 4 章会专门展开。
notify()随机唤醒一个正在该对象上 wait 的线程,notifyAll()唤醒全部。被唤醒的线程不会立刻执行,它要先重新竞争对象锁,抢到锁才能从wait()返回。这也是为什么wait()之后的代码不能假设"条件已满足",必须重新检查。
3. 基于 Object 的阻塞队列实现,保姆级拆解
现在动真格的。我用java.lang.Object的 wait/notify 实现一个容量固定的有界阻塞队列,支持并发 put 和 take,满时 put 阻塞,空时 take 阻塞。所有代码可以直接复制跑。
3.1 数据结构设计:环形数组怎么选
队列底层我用Object[]数组存储元素,配合head、tail、count三个指针,做成环形数组。为什么用环形?因为普通数组队列在重复入队出队后,队头指针往前移动,数组头部空间就废弃了,需要频繁搬移数据。环形数组让 head 和 tail 都在数组范围内循环移动,模拟一个逻辑上无限长的队列,空间复用率最高。这是 JDKArrayBlockingQueue的做法。
容量用capacity固定,构造时传入。索引推进统一用(index + 1) % capacity取模回绕。count 记录当前元素数量,它同时是"空"和"满"的判断依据:count == 0空,count == capacity满。用一个共享的锁对象lock保护所有变量和判断逻辑。
public class ObjectBlockingQueue<E> { private final Object[] items; private final int capacity; private int head; private int tail; private int count; private final Object lock = new Object(); public ObjectBlockingQueue(int capacity) { if (capacity <= 0) { throw new IllegalArgumentException("容量必须为正数"); } this.capacity = capacity; this.items = new Object[capacity]; } public void put(E e) throws InterruptedException { if (e == null) { throw new NullPointerException(); } synchronized (lock) { while (count == capacity) { lock.wait(); } items[tail] = e; tail = (tail + 1) % capacity; count++; lock.notifyAll(); } } @SuppressWarnings("unchecked") public E take() throws InterruptedException { synchronized (lock) { while (count == 0) { lock.wait(); } E e = (E) items[head]; items[head] = null; head = (head + 1) % capacity; count--; lock.notifyAll(); return e; } } public int size() { synchronized (lock) { return count; } } }为什么用 lock 对象而不是直接用 synchronized 加在 this 上?主要是可读性和封装性考虑。队列对外暴露的 API 是 put、take、size,但对象锁 this 是公开可访问的,外部代码如果也对这个实例加锁,会和你内部 put/take 的锁形成同一个竞争入口,增加死锁风险。用一个私有的 lock 对象,相当于把锁的访问范围限定在类内部,这点和 JDK 源码的 ReentrantLock 是同一个思路。
3.2 put 的核心逻辑:入队、通知、释放
put 的逻辑分四步。第一步校验非空,阻塞队列不允许存 null,这是 JDK 的约定,因为 null 在 take 端还被用来做"元素清空"标记,混入 null 会制造混乱。第二步进入 synchronized 临界区,检查容量:如果满了,lock.wait()让当前线程挂起并释放锁。这里必须用 while 而不是 if,原因放在第 4 章细说,先记住"wait 永远活在 while 里面"。第三步入队:往 tail 位置放元素,tail 前移,count 自增。第四步notifyAll(),唤醒所有正在这个锁上等待的线程,然后在方法返回时释放锁。
这里有一个关键点:notifyAll()必须放在锁内、在修改完共享状态之后调用。因为唤醒的消费者线程从 wait 返回后会重新抢锁,抢到后要检查 count 是不是真的大于 0 了。如果通知发生在状态修改之前,消费者醒来读到的还是旧值,就会造成"信号比数据先到"的脏读。
3.3 take 的核心逻辑:出队、置空、避免内存泄漏
take 流程和 put 正好对称。第一步检查空:count == 0就 wait。第二步从 head 位置取元素。第三步特别容易漏:items[head] = null。这一步必须做,否则数组里那个位置的引用还指向旧对象,这个对象本来已经被消费者拿走业务上用完了,但队列数组还攥着它的引用,垃圾收集器永远回收不掉,等于人为制造内存泄漏。数组和 ArrayList 扩容缩容时底层数组也常留着这种"过期引用",这是老手和新手的一个典型区分点。
第四步让 head 前移、count 自减、notifyAll()。注意这里唤醒的是谁——唤醒的是正在等待队列有空位、准备 put 的生产者线程。因为只有 take 会让队列变空出一格。同理,put 里唤醒的是等数据、准备 take 的消费者线程。虽然notifyAll()是全部唤醒,但被唤醒的线程醒来后都要重新检查条件,不该干活的(比如生产者醒来发现队列还是满的)会再次wait(),所以功能上是安全的,只是多几次无谓的锁竞争,这就是前面说的"为了正确性牺牲少许性能"。
3.4 写一个多生产者多消费者测试验证
单靠看代码心里没底,直接上多线程压一下:
import java.util.concurrent.TimeUnit; public class QueueDemo { public static void main(String[] args) throws Exception { ObjectBlockingQueue<Integer> queue = new ObjectBlockingQueue<>(3); Thread producer = new Thread(() -> { try { for (int i = 1; i <= 10; i++) { queue.put(i); System.out.println(Thread.currentThread().getName() + " 放入 " + i); TimeUnit.MILLISECONDS.sleep(300); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Producer"); Thread consumer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { Integer value = queue.take(); System.out.println(Thread.currentThread().getName() + " 取出 " + value); TimeUnit.MILLISECONDS.sleep(500); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Consumer"); producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println("全部完成"); } }你跑起来会看到生产者产得快(300ms 一个),消费者消得慢(500ms 一个),很快队列就被塞满,生产者在第 4 次 put 之后开始等待;消费者每取走一个,生产者才会被唤醒放入下一个。整个过程队列容量始终不超过 3,这就是"阻塞"的作用——生产速度被消费速度反向压制,而不是无脑往内存里灌。
想测试多生产者多消费者,再开两个线程塞 100 个任务,最后断言 count 归零即可。我在实测中发现,多线程场景下notifyAll换成notify会出现偶发的线程永远挂起,也就是第 4 章要讲的丢失唤醒。
3.5 和 JDK ArrayBlockingQueue 对比,我们缺了什么
自己写的版本和ArrayBlockingQueue对比,功能上基本一致,但有几个缺失点值得知道。第一,JDK 用的是ReentrantLock加两个Condition(notEmpty 和 notFull),把"队列满"和"队列空"分成两个独立的等待队列,这样 put 时只需要唤醒 notEmpty 上的消费者,take 时只唤醒 notFull 上的生产者,不会像我们这样无差别唤醒全部。第二,JDK 的lockInterruptibly()可以在等待锁期间响应中断,我们的 synchronized 无法响应中断,所以在 put/take 之外加了InterruptedException抛出,等锁期间想中断是做不到的。第三,JDK 用itrs维护迭代器一致性,我们完全不涉及迭代。
这些差距不说明手写白费。恰恰相反,正因为你亲手写出了这几个条件组合,再回去看 JDK 源码,看到lock.lockInterruptibly()、while (count == items.length) notFull.await()这些行,才会觉得每一句都眼熟,而不是天书。
4. 手写阻塞队列最容易踩的四个坑
我把这些年线上线下见过的坑整理成四类,每一个都是我或者身边同事真真切切踩过的,属于"不做不知道,做了才后怕"的类型。
4.1 条件判断用 if 还是 while,这是个生死问题
网上能找到大量教程把条件判断写成:
synchronized (lock) { if (count == 0) { lock.wait(); } // 取元素 }单生产者单消费者,这样偶尔也能跑通,于是很多人就一直这么交作业。一旦换成多生产者多消费者,必出 bug。原因有两个。
第一个是 wait 的语义要求:线程从wait()返回的那一刻,它只是重新获得了锁,并不代表条件已经满足。比如两个消费者同时等空队列,生产者放入一个元素并notifyAll(),两个消费者都醒了,一个抢到锁把唯一的元素取走,另一个抢到锁后发现 count 已经是 0——如果它用的是 if,就会直接越过取元素逻辑,读到 head 位置的 null,轻则空指针,重则把脏数据发出去。必须用 while,醒来后再检查一次,不满足继续等。
第二个是虚假唤醒。Java 官方文档明确说过,wait 可能在没有被 notify、没有中断、没有超时的情况下自己醒来,这是底层实现的允许行为(在 Linux 的 futex 等机制下并不常见,但规范层面就是允许的)。如果你用 if,一次虚假唤醒就能让你的线程在条件不成立的情况下继续往下执行。用 while 天然免疫这个问题,因为每次醒来都会重新验证。
4.2 notify 和 notifyAll 选错,信号可能永远丢失
前面测试代码里我用的是notifyAll。为什么不用notify?单消费者场景下 notify 看起来没什么问题——队列里只有一个线程在等,唤醒它恰好。但多线程场景,notify 是随机唤醒"一个"正在等待的线程,如果被挑中的那个线程和自己的业务方向不一致,就会出事。
举一个具体例子:队列已满,一个生产者正在 wait,一个消费者正在 wait(没满但空了——等等,实际不可能两个 wait 同时发生,得理顺场景)。更经典的是空队列加一个满队列:假设队列空,两个消费者在等;同时队列还有一个生产者刚被阻塞?不对,队列空的时候生产者不会阻塞。我们换一个简单直接的场景:队列容量为 1,已满,一个生产者 P1 在 wait,同时队列空,两个消费者 C1、C2 在 wait——但一个队列不可能既满又空。好,实际能同时 wait 的只有两类:一类等"非空"(消费者在空队列上等),一类等"非满"(生产者在满队列上等)。如果队列非满非空,谁都不用等。
关键场景如下:队列已满,生产者 P1 在等空位。此时消费者 C1 调用 take,取走一个元素,然后notify()。如果 wait 集合里只有 P1,P1 被唤醒,一切正常。但如果 wait 集合里同时还有另一个消费者 C2(它之前因为某种原因在等),而 notify 偏偏选中了 C2 而不是 P1,C2 醒来发现队列没满(它是消费者,它等的是非空,条件其实不满足),于是继续 wait。P1 没人通知,永远挂起。这就是「唤醒错对象导致的信号丢失」。notifyAll把所有线程都拉起来,让它们自己判断谁该干活谁继续睡,虽然笨但正确。
4.3 中断异常不能一 catch 了之
put 和 take 抛InterruptedException,测试代码里我 catch 之后调用了Thread.currentThread().interrupt()恢复中断标志。很多人不理解为什么要多此一举。
中断标志是线程的一个属性,interrupt()设置它,Thread.sleep()、wait()这类方法在检测到中断标志后会先清除标志,再抛异常。也就是说,异常抛出来的时候,中断标志已经被清掉了。如果你 catch 住什么都不做,外层代码继续用isInterrupted()去判断,会发现"这个线程好像从来没被中断过",中断信号就这样被吞掉了。
正确的习惯是:如果你不打算立刻响应中断(比如当前正在自己实现的框架里做清理工作),catch 后必须Thread.currentThread().interrupt()把标志补回去,让上层调用方看到。如果当前方法语义就是"被中断就退出",那可以不上抛也不补,直接处理完 return,但必须在注释里说明清楚。这个习惯属于并发编程的基本素养,规则和小费文化差不多——可以不给,但默认该给。
4.4 共享变量必须进锁,否则可见性没保证
我在第 3 章的实现里,head、tail、count、items 的所有读写全部在 synchronized(lock) 内部。这不仅仅是互斥,还解决了内存可见性:synchronized建立 happens-before 关系,线程退出同步块时对共享变量的写,对于后续进入同一个锁的线程是可见的。
假设有人偷懒,take 方法里为了减少锁竞争,把 count 的读取放到 synchronized 外面做预检查:
if (count == 0) { // 直接返回或做别的,不进锁 }这在单线程下没问题,多线程下 count 可能是过期的脏值。你可能读到 count=0 就返回"队列为空",但实际上另一个生产者刚 put 了元素,count 已经是 1。这就是为什么 JUC 里ConcurrentLinkedQueue这类无锁队列要用 volatile 加 CAS,你手写 wait/notify 队列时根本没有 CAS,所有共享状态的读写只能统一交给一把锁来管。任何逃出锁的读写都在破坏这个模型。
5. 从手写队列到线程池选型:队列决定线程池的脾气
把队列搞明白之后,再回头看线程池,会发现ThreadPoolExecutor的很多行为不过是"队列满了/空了"这件事的延伸。这一节把线程池的阻塞队列选型讲透,这也是面试里排队出现的追问点。
5.1 ThreadPoolExecutor 里队列扮演什么角色
ThreadPoolExecutor的构造参数里,workQueue就是阻塞队列。线程池的工作流程是这样的:提交一个任务,如果核心线程数没满,直接开新线程执行;核心线程满了,任务进队列排队;队列也满了,再尝试扩到最大线程数;最大线程数也满了,就触发拒绝策略。队列在这里是核心线程和最大线程之间的缓冲层,它的大小直接决定了线程池从"排队"到"扩线程"的切换点。
假如队列是无界的,比如默认构造用的LinkedBlockingQueue不设容量,那么任务永远进得去队列,"队列满"这个条件永远不会发生,线程数也就永远不会超出核心线程数。这带来的一个副作用是:突然爆发的十万个任务全部堆在内存里,核心线程按自己的速度慢慢消化,看起来线程池稳如老狗,实际上内存已经涨到吓人。FixedThreadPool 就是这么设计的,适合任务量平稳的场景,但你在代码里如果敢用默认构造的无界队列接业务流量,迟早要吃一次 OOM 的教训。
5.2 四种队列对应四种脾气
| 队列 | 是否有界 | 特点 | 典型搭档 |
|---|---|---|---|
| LinkedBlockingQueue | 默认无界(可指定容量) | 链表结构,吞吐量高,创建时可传容量 | FixedThreadPool |
| ArrayBlockingQueue | 必须有界 | 数组结构,可指定公平性,锁竞争时支持公平模式 | 自定义业务线程池 |
| SynchronousQueue | 无容量,相当于 0 | 不存任务,put 必须等 take,直接交接 | CachedThreadPool |
| PriorityBlockingQueue | 无界 | 按优先级而不是 FIFO 出队 | 任务带优先级排序的场景 |
SynchronousQueue是最有意思的一个,它内部不存任何元素,生产者 put 必须等待消费者 take,反之亦然。这个"直接交接"的语义恰恰就是我们第 3 章手写队列在容量为 0 时的极限情况。CachedThreadPool用它,配合maximumPoolSize为整型的最大值,实现的效果是:来一个任务就开一个线程,线程空闲 60 秒后回收。因为队列永远装不下任务,所以永远不会排队,所有任务都在寻求直接执行。代价是如果高并发任务量很大,线程数会无限膨胀。我之前在服务里压测过,CachedThreadPool 在持续峰值下能开出几百上千个线程,光是线程栈内存就能吃掉几百兆。
PriorityBlockingQueue优先级队列,出队顺序按任务的compareTo决定,适合有优先级诉求的场景。但它有个问题要留意:无界意味着内存风险,一旦生产速度超过消费速度,任务堆积同样没有上限。
5.3 我给的真实选型建议
性能敏感且任务量可控的接口,优先考虑自定义线程池,搭配有界ArrayBlockingQueue。队列容量怎么定?我一般按「期望的排队任务上限」来设,比如接口平均耗时 100ms,核心线程 8 个,希望一个任务最多等 500ms,那么排队容量大概就是8 * 5 = 40这个量级,再加一点余量,定 64。这不是精确公式,但比拍脑袋强。
LinkedBlockingQueue(带容量构造)相比ArrayBlockingQueue,在有界场景下往往有更好的吞吐,因为链表入队出队操作更分散,锁竞争概率更低。JDK 里Executors.newFixedThreadPool默认用的是无界LinkedBlockingQueue,Executors.newCachedThreadPool用的是SynchronousQueue,这两个都是拿出来即用的典型,但Executors工具类整体上不建议在正式项目里直接用,因为它给不了你拒绝策略和队列容量的控制权。
如果任务允许丢弃,优先用SynchronousQueue配合CallerRunsPolicy或者AbortPolicy,效果好且不会积压。我踩过最惨的一个坑就是给一个消息推送服务用了无界队列,后端消费变慢后,任务肉眼可见地堆到了上千万,最后 OOM 直接把进程打挂。自那以后我给自己立了一个规矩:凡是自定义线程池,队列容量必须显式声明,最大线程数和拒绝策略必须显式声明,省得后人(包括三个月后的我自己)看着默认参数猜半天。
最后再分享一个小技巧:线程生命周期那个状态机,平时写代码可能用不上,但排查问题的时候就是武器。我处理过一次诡异的线上故障,看线程 dump 发现几十个线程都卡在同一个ObjectBlockingQueue.take的 wait 上,顺着状态一查就是队列空了、生产者挂了没人唤醒。那一刻我终于明白,当初老老实实手写一遍阻塞队列,真没白费。