ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java线程状态与生命周期:从控制块到死锁排查的实战指南

Java线程状态与生命周期:从控制块到死锁排查的实战指南 1. 线程到底“长”什么样从线程控制块到资源占用网上讲Java线程的文章多如牛毛但大部分都在教你怎么new Thread()、怎么写Runnable至于线程在JVM和操作系统里到底是怎样的一个“实体”很多人其实没搞清楚。上一篇我已经聊过了线程的基本创建方式和生命周期入口这篇直接进入正题——线程的特点、状态流转以及我在实际开发中最头疼的开始和终止控制。先把一个最基础但经常被面试官拿来“钓鱼”的概念理清楚Java线程本质上是操作系统线程的映射它并不是一个虚无缥缈的逻辑概念而是有实实在在的运行时结构。每个线程都对应一个线程控制块TCB, Thread Control BlockTCB里记录了线程的全部“命脉”——线程标识符TID、寄存器组PC指针、栈指针、线程运行状态、优先级、自己私有的栈区域、以及线程特有的存储区。你可以把它理解成每个员工工位上贴着的那张“员工信息卡手头工作清单”操作系统调度器就是看着这张卡来决定谁上CPU执行、谁下来等待。这里有个特别容易混淆的点热搜词里也有人在搜“线程控制块和私有存储区的关系”。我一次性说透线程共享进程的堆和方法区Java里对应共享的Heap和Method Area但每个线程独享自己的程序计数器PC Register、Java虚拟机栈VM Stack和本地方法栈Native Method Stack。这句话翻译成人话就是同一进程里的多个线程可以互相访问对方的对象引用因为堆是共享的这就是多线程协作的基础但每个线程执行到哪一条字节码指令、调用栈有多深、局部变量存了多少这些是互相看不见的。正因为栈是私有的线程A的局部变量不可能被线程B直接引用只有通过堆传递对象引用才能“间接看到”。很多并发问题之所以难排查本质上就是因为——共享的数据“人人可改”私有的栈“各人自扫门前雪”。理解了TCB之后再回答“线程有哪些特点”就很简单了。线程是轻量级实体创建和切换的开销远小于进程进程需要复制独立地址空间线程只是多一个TCB和栈线程是独立调度单位CPU调度的最小粒度就是线程不是进程线程可以并发执行在多核环境下真正做到并行线程共享进程资源通信效率高但也因此带来了同步互斥的难题。这些特点反过来决定了线程的一切“脾气”——为什么快、为什么容易出问题、为什么需要一堆锁和状态控制机制来约束它。2. 六种状态和一张状态流转图别再靠死记硬背2.1 从Thread.State枚举看JVM定义的状态Java把线程状态定义得非常清晰就是Thread.State这个枚举里的六个值。我见过很多初学者背了又忘根本原因是没有理解“状态是事件驱动的”——线程不会凭空变状态一定是你调用了某个方法、或者某个外部条件触发状态才发生迁移。这六种状态是NEW线程已被创建new Thread()之后但还没调用start()。RUNNABLE调用了start()之后的状态。注意这个状态**同时包含了“正在运行”和“在就绪队列里等待CPU时间片”**两种情况JVM层面不再细分底层由操作系统调度。BLOCKED线程试图进入一个synchronized块/方法但锁被别的线程持有于是被阻塞等待监视器锁。WAITING线程由于调用了Object.wait()、Thread.join()、LockSupport.park()而进入无限期等待只有被显式唤醒才能继续。TIMED_WAITING和WAITING类似但有时间限制超时自动唤醒。典型场景是Thread.sleep(ms)、wait(timeout)、join(timeout)。TERMINATED线程执行完run()方法体正常结束或因未捕获异常提前结束。NEW到RUNNABLE只有一条路调用start()。这个start()方法很讲究它只能被调用一次第二次调用直接抛IllegalThreadStateException。Java的start()源码里有个threadStatus字段做状态校验不为0就抛异常。为什么Java不允许重复start()因为一个线程对应操作系统里一个真实的执行实体生命周期是单向的线程终止之后TCB会被销毁没有“重启”的概念。你要是想“再跑一次”只能重新new Thread()。2.2 状态流转里的隐形路径WAITING和BLOCKED的边界在哪里我面试别人的时候最喜欢问的一个问题就是“BLOCKED和WAITING有什么区别”。答案的核心在于BLOCKED是“想进锁但进不去”它发生在synchronized场景下WAITING是“我主动歇着等别人通知我”它发生在wait/park/join场景下。举个最典型的例子public class StateDemo { private static final Object lock new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(() - { synchronized (lock) { try { Thread.sleep(6000); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2 new Thread(() - { synchronized (lock) { System.out.println(t2 got lock); } }); t1.start(); Thread.sleep(100); t2.start(); Thread.sleep(100); System.out.println(t1 state: t1.getState()); // RUNNABLE正在sleep System.out.println(t2 state: t2.getState()); // BLOCKED进不了锁 } }此时t2拿不到锁它的状态是BLOCKED而不是WAITING。很多人在t2等待锁的时候误以为它在“等待”但从JVM角度它处于BLOCKED状态因为它是在“争取进入临界区”不是“主动让出执行权”。那什么时候线程会进入WAITING呢经典场景是消费者线程调用lock.wait()释放锁并且永久等待直到别的线程notify()。还有所有join()调用——注意join()的本质是“让当前线程等待目标线程终止”所以t.join()会让当前线程进入WAITING或者TIMED_WAITING如果带了超时参数。状态流转里还有一个很少被注意到的细节从BLOCKED状态释放后线程是直接回到RUNNABLE不会经过WAITING。而sleep()结束是从TIMED_WAITING回到RUNNABLE。这两种“等”的唤醒路径不同调试线程dump时看到的状态含义也不一样。2.3 状态轮询的正确姿势热搜里有个词“状态轮询”指的是在主线程里不断调用getState()去看目标线程当前状态。这种轮询方式在写Demo和调试时很直观Thread worker new Thread(() - { try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); worker.start(); while (worker.getState() ! Thread.State.TERMINATED) { System.out.println(worker state: worker.getState()); Thread.sleep(200); }但我要泼盆冷水生产代码里千万不要用这种方式去等线程结束。轮询本身就是一种忙等待浪费CPU而且在极端情况下还永远等不到比如用户态线程状态切换期间读到了旧值。需要等线程结束后再干活的话用join()是正道需要等一批线程全部完成用CountDownLatch或CyclicBarrier需要等多个任务的结果并继续推进用CompletableFuture。轮询放在哪里放在监控和调试里比如定时打印线程状态到日志、排查死锁时用jstack抓线程快照这些才算合理场景。3. 终止线程的正确姿势stop()为什么是垃圾interrupt()应该怎么理解3.1 被放弃的Thread.stop()和它留下的惨痛教训Java早起版本里有一个Thread.stop()方法可以直接强制终止一个线程。这个方法现在仍然存在于JDK中但已经被标记为废弃deprecated任何时候都不建议使用。为什么废掉它因为它会直接释放线程持有的所有监视器锁包括那些正在执行run()内部的finally块、synchronized块、正在写数据库事务的线程。锁被强制释放的后果是被写了一半的数据永远停留在中间状态其他线程立刻看到这种脏数据整个程序的不可靠程度直接拉满。我给你举个例子你就明白有多可怕了public class BankAccount { private long balance; public synchronized void transfer(long amount) { balance amount; // 假设这里有一行Thread.sleep(1)模拟耗时操作 // 如果此时外部调用stop()balance更新了一半只加进去了但后续的数据库日志还没写 } }如果在transfer()中间调用了stop()锁被强制释放另一个线程进来读到了半更新的balance。这种问题在线上环境里会造成资金对不上账且极难复现。所以现在的官方建议非常明确用中断interrupt机制来协调线程终止而不是强制杀掉线程。3.2 interrupt()到底是什么——它只是“提了个醒”Thread.interrupt()这个方法名极具迷惑性很多人以为调用它就能把线程“打断、终止”。实际上interrupt()做的事情只是把线程的中断标志位interrupt status置为true仅此而已。真正发生了什么取决于线程当时在干什么线程当前所处的状态interrupt()产生的效果运行中RUNNABLE只把中断标志位设为true线程继续正常运行不会中断正在执行的代码处于WAITING/TIMED_WAITING比如wait()、sleep()、join()中立刻抛出InterruptedException同时清除中断标志位置回false处于BLOCKED等待synchronized锁什么也不发生中断标志位保留线程继续阻塞这个表格是理解整个中断机制的核心。说白了interrupt()不是一个“停止开关”而是一个“协作机制”它给目标线程发送一个“有人想让你停下来”的信号至于要不要停、什么时候停、停下来之后怎么收尾全得靠目标线程自己的代码配合。3.3 正确处理InterruptedException的三种姿势既然InterruptedException是协作机制的一部分那捕获到它之后该怎么做我见过最糟糕的处理方式是e.printStackTrace()然后什么都不干这等于把中断信号丢进了垃圾桶。正确姿势有三种第一种恢复中断标志位try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断标志 // 然后根据业务需要决定是否继续执行或退出 }因为JVM抛出InterruptedException时会清除中断标志所以如果你并不打算立刻退出就应该把标志位重新置true让上层的代码还能感知到“这个线程曾经被打断过”。第二种直接让异常向上抛出如果当前方法本身不在Runnable.run()的栈顶可以把InterruptedException继续往上抛方法声明里加上throws InterruptedException由上层调用方来决定如何处理。这是最干净的做法适合那些本身就被设计为可中断的阻塞方法。第三种结合业务逻辑优雅退出public void run() { while (!Thread.currentThread().isInterrupted()) { try { doWork(); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; // 收到中断处理完手头事务后退出循环 } } // 这里做退出前的清理工作比如关闭连接、写日志 }核心思想是线程的退出必须由线程自己决定。外部只能“建议”不能“命令”。这样设计的好处是不管线程执行到哪一步都有机会做资源清理不会留下半截状态。3.4 一个经常被忽略的坑isInterrupted()与Thread.interrupted()这两个方法长得极像但行为有一个关键差别isInterrupted()是实例方法只检查标志位不清除Thread.interrupted()是静态方法检查当前线程的标志位并且清除置回false。public class InterruptStatusDemo { public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { while (true) { boolean status Thread.currentThread().isInterrupted(); if (status) { System.out.println(第一个轮询感知到中断, status status); System.out.println(第二次调用isInterrupted, status Thread.currentThread().isInterrupted()); // 这里如果不手动break线程会继续跑永不退出 break; } } }); t.start(); Thread.sleep(100); t.interrupt(); } }如果改用Thread.interrupted()同一段代码里的第二次调用就会返回false因为它把标志清掉了。很多bug就是这么来的——你在一处调用了Thread.interrupted()但另一处代码还指望着用isInterrupted()判断是否退出结果标志被吞噬线程死活退不出来。所以我的习惯是在真正想“消费”中断信号时才用Thread.interrupted()只是“检查”的话一律用isInterrupted()。4. 开始控制start()的底层逻辑与阶段控制4.1 start()和run()的天壤之别线程开发的第一个大坑就是把start()写成了run()。thread.run()并不会启动一个新线程它只是在当前线程里同步执行run()方法体的代码从行为上是彻彻底底的普通方法调用。只有thread.start()才会创建一个新的执行流由JVM通过底层调用来执行run()。我收到过不止一个“为什么我用了多线程但页面还是卡死”的咨询一看代码果然是把run()当start()调了。诊断方法非常简单在run()方法第一行打印Thread.currentThread().getName()如果输出的不是你的新线程名比如Thread-0而是main那基本就是调用了run()。start()的完整逻辑是这样的它先检查线程状态确保是从NEW状态开始然后调用本地方法start0()由JVM在底层创建一个新的操作系统线程并在新线程上执行run()方法。start()方法本身会立即返回不是等线程跑完才返回新线程与主线程并发执行。4.2 “任务已全部启动”的信号处理开发中经常需要“启动N个线程干活然后等待全部完成再汇总”。很多人第一反应是while循环里判断getState()或者维护一个volatile boolean标记但最直观的其实是join()。ListThread workers new ArrayList(); for (int i 0; i 4; i) { Thread worker new Thread(new Task(i)); worker.start(); workers.add(worker); } for (Thread worker : workers) { worker.join(); } System.out.println(所有任务已完成开始汇总结果);join()的语义是调用线程等待目标线程终止。它有几种重载无参是无限等待join(1000)是等待最多1000毫秒。注意join()本身是会被interrupt()影响的——如果主线程在join()等待期间被中断会抛InterruptedException。所以如果主线程不希望被中断影响汇总结论join()调用也需要包好异常处理。join()看着简单但它背后的状态变化很容易被忽视主线程从RUNNABLE转入WAITING直到目标线程进入TERMINATED后主线程才重新回到RUNNABLE。如果你在调试器里看到主线程停在join()那一行说明它正在等待某个子线程结束。4.3 启动参数与线程工厂控制线程的“出身”线程控制的另一个层面是“线程怎么被创建出来的”。直接new Thread()虽然简单但你会发现线程名字像Thread-0、Thread-1日志里排查起来很痛苦而且默认线程优先级、异常处理器都是默认值。更可控的方式是使用**线程工厂ThreadFactory**配合线程池来“制造”符合要求的线程public class NamedThreadFactory implements ThreadFactory { private final AtomicInteger counter new AtomicInteger(1); private final String prefix; public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, prefix - counter.getAndIncrement()); t.setDaemon(false); // 默认非守护线程 t.setUncaughtExceptionHandler((thread, e) - System.err.println(线程 thread.getName() 抛出未捕获异常: e.getMessage())); return t; } } Executors.newFixedThreadPool(4, new NamedThreadFactory(order-worker));接手过线上服务的人都会有体会线程能否起一个“有辨识度”的名字直接决定日志排障的速度。order-worker-3vsThread-52在崩溃日志里一个一眼就能看清是哪个业务模块的线程另一个得靠ip和进程猜。还有一点要强调的是守护线程daemon thread。守护线程的特点是当进程中只剩下守护线程时JVM会直接退出不管这些守护线程是否执行完。典型的使用场景是后台心跳、日志清理典型误用的是把核心业务任务设成了daemon——结果主线程一结束JVM直接关停任务还没处理完就蒸发了。所以在ThreadFactory里我一般都显式把daemon标志设为false除非我明确知道“这个线程不需要阻塞JVM退出”。4.4 阶段控制从开始到结束的“暂停和恢复”Java的Thread类里曾经有过suspend()、resume()、stop()三件套现在全部废弃了。原因很一致suspend()挂起线程时不会释放锁如果挂起点正好在持锁代码内很容易演变成“线程死锁”——被挂起的线程永远持有锁其他线程永远等锁。所以官方一直推荐用“协作方式”实现线程的暂停/恢复比如通过一个volatile标志来控制public class PausableTask implements Runnable { private volatile boolean running true; private volatile boolean paused false; Override public void run() { while (running) { if (paused) { Thread.yield(); // 暂停期间让出CPU避免忙等 continue; } doWork(); } } public void pause() { paused true; } public void resume() { paused false; } public void shutdown() { running false; } }这种实现配合volatile的内存可见性能做到线程任务的暂停、恢复、终止。注意volatile只能保证“变量修改的可见性”不能保证复杂复合操作的原子性——如果业务里的doWork()有“读取-判断-写入”这种复合逻辑就需要用锁或AtomicXXX来保护了。5. 线程状态与死锁现场怎么用“状态”判断程序有没有出问题5.1 死锁的本质和四种必要条件线程状态除了用来看线程“在干嘛”最大的实战价值是排查死锁。死锁产生的必要条件有四条教科书上写得很明确互斥资源只能被一个线程持有、请求与保持线程持有资源的同时还在请求其他资源、不可剥夺资源不能被外部强行夺取、循环等待存在一个线程间的等待环。四个条件同时满足才会死锁缺一不可。这意味着打破任何一个条件就能解锁——但实际代码里基于synchronized的互斥是不可变的我们通常打破的是“不可剥夺”或“循环等待”。一个经典的死锁代码模型public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(t1 got lockA); try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(t1 got lockB); } } }); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(t2 got lockB); try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(t2 got lockA); } } }); t1.start(); t2.start(); } }这段代码大概率会让两个线程互相等待对方的锁谁也进不去第二步。发生死锁时两个线程都会进入BLOCKED状态而且谁也没法主动醒来。注意死锁里的线程状态绝大多数是BLOCKED在等synchronized锁但也可能是WAITING比如两个线程互相join排查时看到一堆线程卡在同一个状态且长时间不变化就要高度怀疑死锁了。5.2 用“状态”定位死锁的过程定位死锁的标准姿势是用jstack抓线程栈。假设上面代码跑起来了终端执行jstack pid输出里会有一段非常明显的死锁标识Found one Java-level deadlock: Thread-1: waiting to lock monitor 0x0000012345678 (object 0x0000000712345678, java.lang.Object), which is held by Thread-0 Thread-0: waiting to lock monitor 0x0000012345678 (object 0x0000000712345678, java.lang.Object), which is held by Thread-1 Java stack information for the threads listed above: ...jstack甚至能直接给你画出一个“等待环”你不光能看到哪个线程持有哪把锁、正在等哪把锁还能看到堆栈信息。这是排查死锁的最高效路径。如果现场很紧急、不方便抓线程栈我还有个经验看状态有没有“时间维度”。正常阻塞的线程即使拿不到锁也有可能在等待IO或等待锁超时后被唤醒但死锁线程的BLOCKED状态会持续很久从第一次锁竞争到内存溢出都不会变。把jstack在几十秒内抓两份快照对比如果一堆线程的状态和堆栈一模一样、纹丝不动那基本就是死锁了。5.3 避免死锁的实操策略死锁的代码模型看着简单但现实业务里很难一眼看出来。我在实践中常用的三板斧第一锁顺序一致。多个线程加锁时严格按照同一个全局顺序获取锁。比如都先拿lockA再拿lockB就不会出现循环等待。第二使用带超时的锁。ReentrantLock.tryLock(2, TimeUnit.SECONDS)可以在等不到锁的时候主动放弃而不是无限阻塞。第三缩小锁的持有范围。能放锁就放别把多个操作揉在一个Synchronized块里降低“请求与保持”的时长和概率。还有一个小技巧是给锁定义优先级或者分组——把“核心链路需要的锁”和“非核心链路需要的锁”分开核心链路尽量不与非核心链路竞争同一把锁。这更像是架构层面的规避但在复杂的业务系统里非常有效。6. 线程互斥与状态可见性控制线程还要管好内存6.1 为什么状态控制离不开volatile和锁前面提到用volatile标志控制线程的暂停/终止这是建立在“内存可见性”之上的。Java内存模型JMM规定线程对共享变量的修改不会立刻同步到主内存而是可能先存在CPU缓存或线程工作内存中其他线程读到的可能是旧值。volatile关键字做的事情很直接写volatile变量时强制刷新到主内存读volatile变量时强制从主内存读取。这保证了一个线程设置的“暂停标志”能被另一个线程及时看到。但volatile解决不了所有问题。它只保证“单次读-写”的可见性不保证“读-判断-写”这一系列复合操作的原子性。比如if (!isRunning) { // 这里如果不加锁两个线程可能同时通过判断 }如果两个线程同时执行这段代码它们可能都通过了if检查导致本该只执行一次的逻辑执行了两次。这就是线程互斥的用武之地需要保证“临界区同一时间只能有一个线程进去”时用synchronized或ReentrantLock。6.2 用LockSupport做更精细的暂停/恢复除了volatile标志位Java并发包里还有个更底层的工具——LockSupport。它提供了park()和unpark(Thread)和wait/notify很像但使用起来没那么别扭public class LockSupportDemo { private static final Object lock new Object(); public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { System.out.println(工作线程开始先干一部分活); LockSupport.park(); // 挂起等待主线程unpark System.out.println(工作线程恢复干剩下的活); }); t.start(); Thread.sleep(200); System.out.println(主线程决定暂停工作线程); LockSupport.unpark(t); } }park()的实现底层会用到UNSAFE类的park方法它是JVM层面提供的阻塞原语Thread.sleep()、Object.wait()、线程池的worker等底层都与之相关。理解park/unpark的意义在于它和中断机制有交互——如果线程在park()中被interrupt()park()会返回但不清除中断标志。所以用LockSupport做线程控制时处理中断也需要自己负责。6.3 状态互斥监控结合JDK自带工具做线上体检最后分享一个我线上排查时最常用到的“线程状态体检”流程。当怀疑系统里有线程被卡住我会先执行jps -l找到目标进程PID然后jstack pid thread_dump.txt然后看两个东西一是有没有大面积的BLOCKED比如超过20%的线程都处于BLOCKED状态说明有严重的锁竞争二是有没有线程长时间停留在WAITING (on object monitor)这种往往是wait()之后一直没有被notify()唤醒典型就是生产者消费者模型里的消费者线程悬挂。再配合jstat -gc pid看GC情况GC频繁会导致线程反复STW状态看起来像“卡住”和top -Hp pid看CPU占用率如果某个线程CPU占用率一直100%但业务无进展多半是自旋等待或死循环基本就能定位大部分“线程状态异常”的问题。7. 写在最后的实践经验这篇从线程的特点一路讲到了状态迁移、终止控制、开始控制、死锁排查和内存可见性几乎把所有“控制线程”的关键点都覆盖了一遍。这几年的多线程开发经验里我最大的感受是线程不是“越少越安全”也不是“越多越快”它的核心在于状态透明、控制可控。能随时知道每个线程在哪个状态、为什么在这个状态、能不能安全地让它停下来比单纯追求并发性能重要得多。几个可以立刻用起来的小建议写任何多线程代码先画一遍状态流转图标清楚什么事件导致什么状态变化线程必须有个“退出开关”无论是中断位还是volatile标志别想着暴力stop()给每个业务线程起个能看懂的名字线上排障时能节省半小时起步排查卡顿问题时jstack连抓两份快照做对比是定位死锁和锁竞争最高效的办法。把这些变成肌肉记忆并发编程的坑能少踩一大半。
返回列表