ARTICLE DETAIL

资讯详情

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

Java多线程核心:Thread状态、interrupt与join原理详解

Java多线程核心:Thread状态、interrupt与join原理详解 从接触Java多线程开始很多人最先学会的就是new Thread(...).start()但接着就会陷入各种困惑线程到底有哪几种状态interrupt()和isInterrupted()有什么区别join()到底是干嘛的、为什么有时候等不到结果这些问题如果只靠翻API文档看完还是一头雾水。这篇就围绕Thread的常见属性和状态、interrupt()和join()两个高频方法把原理和实操一起讲透适合刚学完线程基础、正准备写真实并发代码的Java开发者。我用一句话先概括核心要点线程状态是一个状态机interrupt()是协作式的中断通知join()本质是等待线程结束的同步工具。搞清楚这三件事线程代码的很多“玄学报错”就能一眼看穿。1. Thread常见属性先弄清每个字段是干什么的1.1 name线程名字排查问题的第一步每次new一个Thread却不传名字线程都会自动生成一个类似Thread-0、Thread-1的名字。本地调试时没感觉一旦到了线上日志里一坨Thread-XX根本分不清是哪个业务起的线程。强烈建议创建线程时总是显式命名哪怕是简单的new Thread(runnable, order-sync-worker)。命名不只是为了好看它对排查问题有直接价值线程转储thread dump里会显示线程名没有名字的线程几乎无法定位是谁拉起的。我见过不少线上故障最后靠线程名才找到是哪个线程池的哪个任务卡住了。命名建议用“业务模块-线程类型-序号”这种组合比如trade-execute-1、image-upload-3一眼就能看出用途。1.2 priority优先级别被“优先级执行顺序”骗了Thread类里priority是1到10的整数默认是5Thread.NORM_PRIORITY。很多人以为设置优先级就能控制执行顺序实际上不是这样。优先级只是给操作系统调度器的一个“建议”JVM在不同平台上映射规则也不同而且高优先级线程只意味着获得CPU时间片的概率更高绝不等于“先执行完”。更麻烦的是如果设置不当反而可能造成低优先级线程饥饿。我的建议是业务代码里不要依赖优先级做任何逻辑判断保持默认5就好。真正需要控制执行顺序应该用join()、CountDownLatch、CompletableFuture这些明确同步语义的工具。优先级这个属性更多是在测试环境做调度实验或某些特定实时场景下才会碰。1.3 daemon守护线程与用户线程的分工setDaemon(true)可以把线程标记为守护线程。两者的核心区别在于JVM在所有非守护线程都结束后会直接退出不管守护线程是否还在运行。换句话说守护线程是“后勤部队”用户线程是“主力部队”主力撤了后勤也跟着散。实际开发中后台心跳、日志刷盘、监控上报这类线程适合设置为daemon避免因为它们拖住JVM无法退出。但要注意守护线程里别放重要状态数据因为JVM退出时不会等待守护线程优雅关闭数据可能丢一半。我之前踩过一个坑用守护线程做本地缓存定时刷新测试环境一切正常上线后频繁出现缓存数据不完整后来才发现是JVM在某个路径下快速退出守护线程的刷盘逻辑根本没跑完。后来改成了用户线程加显式关闭钩子shutdown hook问题才消失。1.4 其他常用属性id、threadGroup与uncaughtExceptionHandlergetId()线程的唯一标识从1开始递增不会重复但也不保证连续。getThreadGroup()线程所属线程组线程组的一大用处是统一管理一组线程比如批量中断。不过现在线程池普及后直接操作ThreadGroup的场景已经少了很多。setUncaughtExceptionHandler()线程运行时如果抛出了未捕获的异常会交给这个处理器。默认行为是打印堆栈后线程终止。手动设置处理器可以把异常统一记录到自定义日志系统或者做告警通知。这里有一点值得注意run()方法内部自己try-catch到的异常不会触发uncaughtExceptionHandler只有从run()里冒出去的异常才会走到它那里。所以如果你在run方法里全包了try-catch外面的全局异常处理器是收不到的。下面是这些属性的一个快速对照表属性获取/设置方式用途注意事项namegetName/setName日志与排查定位显式命名优于默认名prioritygetPriority/setPriority调度建议不要依赖它保证顺序daemonisDaemon/setDaemon后台任务守护线程不保证优雅退出idgetId唯一标识只读、不连续threadGroupgetThreadGroup线程分组线程池场景使用渐少uncaughtExceptionHandlersetUncaughtExceptionHandler兜底异常处理仅捕获run()外抛异常2. 线程状态机六种状态和它们的转换路径2.1 Java线程六种状态速览Thread.State枚举定义了六种状态很多新手把“操作系统线程状态”和“Java线程状态”混在一起结果越学越乱。这里先给出Java层面的状态定义状态含义进入方式退出方式NEW已创建但未启动new Thread()start()RUNNABLE可运行或正在运行start()后、从阻塞中恢复时间片耗尽、让出CPUBLOCKED等待监视器锁尝试进入synchronized块未获锁获取到锁WAITING无限期等待wait()、join()、LockSupport.park()notify/notifyAll、目标线程结束、unparkTIMED_WAITING有限期等待sleep(ms)、wait(ms)、join(ms)、parkNanos超时、唤醒TERMINATED已结束run()执行完或抛异常无RUNNABLE是理解上最需要小心的一层。Java把“是否获得CPU执行权”这件事完全交给了操作系统所以RUNNABLE状态同时涵盖了两个场景正在CPU上跑以及随时可以跑但暂时没分到时间片。常见误区是认为RUNNABLE就是“正在运行”实际上它只是“可运行”真正是否在运行要深入操作系统线程状态才能确定。2.2 状态转换的完整路径一张状态转换图如果是纯文字描述就容易枯燥我用最直白的语言走一遍关键路径从NEW只能到RUNNABLE且只能通过start()重复start()会抛IllegalThreadStateException。RUNNABLE遇到synchronized锁未获到变成BLOCKED获到锁之后回到RUNNABLE。RUNNABLE调用Object.wait()进入WAITING被notify()唤醒后先尝试重新获取锁拿不到就先BLOCKED拿到了回RUNNABLE。RUNNABLE调用Thread.sleep()或wait(ms)进入TIMED_WAITING超时后自动回RUNNABLE。RUNNABLE调用join()进入WAITING等目标线程终止后自动回RUNNABLE。任何状态下的线程只要run()正常返回或抛出没有捕获的异常最终都进入TERMINATED且不可逆。很多线上问题的排查逻辑都是建立在这个状态机上的一个线程长期停在WAITING怀疑是wait()后没有人notify()长期停在BLOCKED基本就是锁竞争激烈长期停在TIMED_WAITING可能是sleep()太长或者join(ms)一直等不到目标线程结束。2.3 演示用代码观察线程状态写一个小示例直接观察状态变化理解会深刻很多public class StateDemo { public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { synchronized (StateDemo.class) { try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, state-demo-thread); System.out.println(刚创建: t.getState()); // NEW t.start(); System.out.println(启动后: t.getState()); // RUNNABLE Thread.sleep(100); Thread blocker new Thread(() - { synchronized (StateDemo.class) { System.out.println(blokcer 获取到锁); } }, blocker-thread); blocker.start(); Thread.sleep(100); System.out.println(竞争锁: blocker.getState()); // BLOCKED t.join(); System.out.println(结束后: t.getState()); // TERMINATED } }注意Thread.sleep(100)这些等待时间不能太短否则可能看不到目标状态。实际调试时我更推荐直接用jstack配合日志比在代码里打印状态要准确得多因为代码打印本身存在竞态你打印时线程可能已经切换状态了。2.4 Java状态与操作系统状态的关系顺着前面RUNNABLE的话题展开说一句JVM的线程状态和操作系统线程状态不是一一对应的。Java的BLOCKED、WAITING、TIMED_WAITING在操作系统层面通常会被映射成SLEEPING或WAIT状态Java的RUNNABLE在OS层面可能对应着RUNNING也可能对应着READY。所以做性能分析时不能只看Java线程状态要结合操作系统工具看CPU和上下文切换指标。这也是为什么很多老手排查问题时会同时用jstack和top -Hp一个看Java状态一个看OS层耗CPU的线程。3. interrupt()方法协作式中断的正确打开方式3.1 先纠正一个普遍误解很多初学者以为interrupt()是“强制杀死线程”就像调用stop()一样。完全不是这样。interrupt()只做一件事设置线程的中断标志位相当于给线程递了一张“你该停了”的通知纸条。线程要不要响应、什么时候响应完全由线程自己的代码决定。这种设计叫协作式中断目的是避免强制终止带来的资源不释放、数据不一致等问题。stop()这种强行终止的方式早已被标记为废弃它会直接释放线程持有的锁可能导致数据处于中间状态。所以现代Java里优雅停止线程的唯一通用途径就是“协作式中断”。3.2 三个方法三个容易混淆的点Thread类里有三个与中断相关的入口我刚学时经常搞混这里一次说清楚// 方法1给目标线程发中断通知 thread.interrupt(); // 方法2查询目标线程的中断标志位不改变标志位 targetThread.isInterrupted(); // 方法3查询当前线程的中断标志位并清除标志位静态方法 Thread.interrupted();关键区别在于isInterrupted()只读不清除Thread.interrupted()读完之后会清除。这意味着连续调用两次Thread.interrupted()第二次一定返回false。在循环里使用这两个方法时一个不留神就会造成标志位丢失后面再判断就再也不成立了。我还见过一种隐蔽的坑在线程的run()里先调用Thread.interrupted()判断是否中断然后调用Thread.sleep()。由于Thread.interrupted()已经把标志位清掉了sleep()不会抛InterruptedException线程继续睡中断就“凉”了。3.3 响应中断的三种典型场景第一类线程处于sleep()、wait()、join()这类可中断阻塞状态。这时收到中断会立刻抛出InterruptedException并且中断状态会被JVM清掉。所以捕获异常后标志位是false。第二类线程在正常执行CPU计算没有处于任何阻塞调用中。此时中断只是把标志位置为true线程继续跑自己的。代码需要通过isInterrupted()或Thread.interrupted()主动轮询标志位才能感知中断。第三类线程在LockSupport.park()等待许可。interrupt()会让park()立即返回而且不会抛异常标志位保留。这三类的处理策略是完全不同的写代码前必须先想清楚当前线程的运行路径是哪一类。3.4 处理InterruptedException的标准姿势捕获InterruptedException后最忌讳的就是“吃掉异常”。一个常见的坏写法try { Thread.sleep(1000); } catch (InterruptedException e) { // 什么都不做 }这样写等于把中断通知给吞了。上层调用者完全不知道线程已经被中断协作中断机制就形同虚设。所以有个约定俗成的两句话规则如果你当前方法不能处理中断捕获InterruptedException后应该立即调用Thread.currentThread().interrupt()恢复中断标志位把中断状态交给上层代码做决定。用代码说就是这样public void doWork() throws InterruptedException { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw e; // 或者根据业务决定是否继续抛 } }如果不打算往外抛异常、要在当前方法里继续干点收尾工作那更要恢复标志位否则后续的循环判断、资源清理都可能漏掉中断状态。3.5 轮询标志位的循环写法如果线程是一个长期运行的循环任务通常配合isInterrupted()做退出判断Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { doSingleTask(); } // 退出前可以做一些清理 }); worker.start(); // 主线程决定停止 worker.interrupt();这段代码有个细节如果doSingleTask()内部有sleep()或wait()那sleep()抛出InterruptedException后会清除标志位回到循环顶部时isInterrupted()返回false线程可能继续跑。稳妥的做法是让doSingleTask把中断异常往外抛或者在catch里恢复中断标志位让循环条件能感知到。最规范的做法是使用Thread.currentThread().isInterrupted()配合“恢复标志位”的组合形成一个可以被中断的循环。3.6 interrupt常见误用排查误以为interrupt()能中断IO操作InputStream.read()这类阻塞IO默认不响应中断。想中断IO通常要手动关闭流。误以为sleep()后isInterrupted()仍为true实际是false因为异常抛出时标志位被清了。对已经TERMINATED的线程调用interrupt()不会报错但什么也不会发生标志位设置无效。4. join()方法线程协作的同步利器4.1 join()到底在等什么join()的作用是让当前线程等待目标线程执行完毕。比如主线程要等子线程把计算结果准备好再继续就可以对子线程调用join()。这个“等待”不是空转循环底层是Object.wait()机制等待期间当前线程会让出CPU进入WAITING状态。JDK源码里join()的实现原理大致是这样的public final void join() throws InterruptedException { while (isAlive()) { wait(0); // 等待目标线程终止 } }重点在于wait(0)表示无限期等待而等待的对象不是当前线程而是目标线程实例。当目标线程终止时JVM会调用该线程对象上的notifyAll()唤醒所有因join()而等待的线程。这也是为什么join()必须抛出InterruptedException——它本质上就是一个带可中断语义的等待。4.2 三个重载方法超时机制的陷阱join()无限期等待直到目标线程终止。如果目标线程永不终止当前线程就永远卡在WAITING。join(long millis)等待最多millis毫秒。超时后即使目标线程没结束也会返回。join(long millis, int nanos)毫秒加纳秒级别的精度补充实际开发中用得极少。join(long)超时返回后需要通过isAlive()判断目标线程是否真的结束了。最常见的坑是设置join(1000)后直接读取线程输出结果此时线程未必执行完结果可能是半成品甚至null。正确姿势是超时后先检查isAlive()再做后续逻辑。thread.join(1000); if (thread.isAlive()) { System.out.println(线程还没结束结果不可用); // 可以先做降级处理而不是直接读结果 } else { System.out.println(线程已结束结果可用); }4.3 join()与sleep()的关键区别很多人会把join(ms)当成“固定等待ms毫秒”的sleep实际上完全不是。join(ms)的语义是“最多等ms毫秒”如果线程提前结束join会立即返回剩余时间不再等待。sleep则是“铁定睡满ms毫秒”。举例线程执行用了200msjoin(1000)会在第200ms就返回总共只花了200ms而sleep(1000)无论线程跑完没跑完都会睡满1000ms。这个区别在控制任务总耗时上非常关键。如果你只想“给子线程固定1秒的执行窗口到期不管结没结束都继续”那是sleep配合isAlive()检查如果你想“等子线程结束但最多不超过1秒”那是join。4.4 用join控制线程执行顺序日常开发里我经常见到用join()实现“线程A先跑完线程B再启动”的简单顺序控制public class JoinOrderDemo { public static void main(String[] args) throws InterruptedException { long start System.currentTimeMillis(); Thread t1 new Thread(() - { sleepQuietly(500); System.out.println(t1 完成); }, t1); Thread t2 new Thread(() - { sleepQuietly(300); System.out.println(t2 完成); }, t2); t1.start(); t2.start(); t1.join(); // 等t1 t2.join(); // 等t2 System.out.println(总耗时: (System.currentTimeMillis() - start) ms); // 两个线程并行执行总耗时会明显小于800ms } private static void sleepQuietly(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这个例子说明join()不会破坏并行性t1和t2是并行的join()只是让主线程等待两者都结束。这是理解join的关键——它控制的是“调用方”的阻塞等待而不会限制目标线程之间的并行关系。4.5 join误用的典型场景在目标线程内部调用自己的join()会死锁线程在等待自己结束永远结束不了。在持有锁的情况下调用join()如果目标线程也需要这把锁就会形成死锁。等待期间锁也不会释放。用join()做固定时间间隔定时任务上面已经说过语义不同容易踩时间不准的坑。join()和interrupt()配合时如果等待线程被中断join()会抛异常需要恢复中断标志位。5. 常见问题与排查技巧实录5.1 启动线程却报IllegalThreadStateException原因几乎只有一个对同一个线程调用了两次start()。线程启动后状态就从NEW变成了RUNNABLE而start()只允许在NEW状态调用。我见过很多人在循环里误用写了个new Thread(runnable).start()本身没错但把Thread对象提取出来反复start()第二个线程没建还白扔一个异常。正确的循环写法是每次在循环体内重新new线程。5.2 线程状态卡在WAITING到底是谁在等谁线上排查时遇到线程长时间处于WAITING最常见的两个方向一个是Object.wait()无人唤醒另一个是join()目标线程永不结束。第一步用jstack打印线程栈找到WAITING线程的堆栈信息里等待的对象。如果是java.lang.Thread.join(...)再看堆栈里目标线程在哪一步阻塞。如果目标线程本身在等另一把锁就要顺藤摸瓜往上查锁关系。5.3 interrupt后线程就是不退出怎么排查线程跑在sleep()这类可中断方法中时interrupt()是有效的会抛异常。线程如果一直退不出最可能是循环条件里根本没有检查中断标志位或者检查了但标志位被提前清除了。写长期运行的线程时建议在循环里明确打印中断标志位的状态方便上线后确认逻辑。还有一种情况是线程被join()等待另一个永远无法结束的线程interrupt()只能中断当前等待目标线程本身不结束的话整个任务链条还是卡着。5.4 关于常见报错的定位思路有时代码里抛出的异常信息里会看到Exception in thread main java.lang.NoSuchMethodError之类看起来像是多线程问题其实多数和线程并发无关而是类路径或依赖版本问题。多线程代码报错时先分清两个层面是编译期/类加载层面的错误还是运行期线程并发导致的错误。前者去查依赖后者才去看锁、状态、中断标志位。用这个思路能少走很多弯路。下面把高频问题整理成速查表现象可能原因排查方向线程停在BLOCKED竞争synchronized锁jstack找锁持有者线程停在WAITINGwait()无唤醒、join目标未结束检查notify链路、目标线程状态interrupt后不退出循环未检查标志位或标志位被清除确认中断轮询代码join超时结果不准未检查isAlive()就读取超时后先判断isAliveInterruptedException被吞catch后未恢复标志位调用interrupt()恢复线程重复start对同一Thread调两次start每次循环重新new6. 一点个人实操体会6.1 我自己踩过的坑刚工作那会儿写一个批量任务处理模块主线程开了一堆子线程然后挨个join()。结果某个上游服务异常子线程里一个wait()永远等不到通知整个主线程就卡在join()上任务积压了一堆。后来改成join(3000)等不到就标记超时主流程先走降级逻辑问题才算解决。这让我养成了一个习惯凡是等待外部不确定因素的地方一律设置超时时间不要让线程陷入无限期等待。还有一次线上告警有个线程长期RUNNABLE但不干活CPU还很高。查了半天才发现业务代码在while循环里不断自旋忘了检查中断标志位。interrupt()虽然叫了但线程压根没看纸条。后来在循环顶部加了一句if (Thread.currentThread().isInterrupted()) break;告警消失。这两个例子放在一起正好说明join()要防无限等待interrupt()要主动去查。6.2 推荐一套简单可复用的写法日常写线程协作我通常会按这个模板来public void runTask() throws InterruptedException { Thread worker new Thread(this::doHeavyWork, heavy-worker); worker.setDaemon(false); worker.start(); worker.join(5000); if (worker.isAlive()) { // 超时处理记录日志、降级、尝试中断 worker.interrupt(); } }这个模板里包含了命名、超时、中断恢复三层保护。虽然简单但能覆盖绝大多数线程等待场景的坑。中断那块在join(5000)超时后主动interrupt()也很重要至少让线程有机会响应中断退出避免成为僵尸线程。6.3 后续还能怎么扩展这篇只覆盖了Thread本身的方法但实际项目中直接操作线程的机会其实不多更多是用线程池配合Future、CountDownLatch、CompletableFuture等高级并发工具。我建议学完本文后接着做三个练习第一个是用join()统计多个线程各自耗时第二个是写一个能通过interrupt()优雅停机的轮询任务第三个是把sleep里的InterruptedException处理写成不吞异常的标准版。这三个练习做扎实了再去看线程池和锁思路会顺畅很多。根据我的经验多线程代码最大的问题从来不是API不会用而是“不知道线程现在到底在想什么”。Thread的状态和中断机制恰恰是让你“站在线程角度思考”的入口。把本文里的状态转换和中断语义吃透后续看任何并发框架都会轻松一大截。
返回列表