ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

线程生命周期与阻塞队列实战:wait/notify机制及线程池选型

线程生命周期与阻塞队列实战:wait/notify机制及线程池选型 搞并发编程的人迟早会碰一次“手写阻塞队列”这道坎。不管你是面试准备还是自研中间件只要你用了线程池用了生产者-消费者模型就会绕不开两个基础问题线程到底有哪些状态、状态之间怎么跳线程之间怎么用最朴素的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在队列里会被当作“空位”哨兵值用户如果放nulltake的时候就会和空位判断混淆。核心技巧是这个环形数组下标计算维护两个指针putIndex和takeIndex每次入队后putIndex (putIndex 1) % capacity出队后takeIndex (takeIndex 1) % capacity再用一个count记录当前元素数量。这样数组的物理空间始终是固定的逻辑上像一个循环转盘。判断满就是count capacity判断空就是count 0。3.2 第一版实现循环数组加wait/notifyAll直接上代码这是我压測过并且在线下模拟过高竞争的基础版本public class ObjectBlockingQueueE { 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 { ObjectBlockingQueueInteger 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和两个ConditionnotEmpty、notFull把两类等待线程分开唤醒生产者时不会惊动消费者。这也是我自己实现的队列在重竞争下吞吐不如JDK队列的根本原因。第二synchronized不支持可中断的锁获取和超时锁获取。虽然wait()本身可中断但进入synchronized时如果锁被持有只能一直等无法设超时。这对需要超时控制的路由场景就很别扭。第三公平性不好控制。默认synchronized是非公平锁高竞争下可能出现线程饥饿。如果你需要公平调度还是得用ReentrantLock(true)。所以在实际项目中我通常只在“代码量极少、并发量不高、且希望零依赖”的情况下保留Object方案。一旦进入高并发的核心链路直接换LockCondition或者直接用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、notFullput时如果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做实验加两个生产者、两个消费者把容量设小多跑几轮很容易碰到各种边界情况。这类问题最好的学习方式就是亲手复现一遍然后看线程栈去分析原因。等你哪天能不看资料自己把假死原因解释得明明白白那线程生命周期和阻塞队列这块就算真正吃透了。
返回列表