1. 线程方法全景图:先搞懂线程的状态流转,再谈 API
先问一句:你写 Java 多线程的时候,有没有想过一个问题——Thread.sleep(1000)到底让线程经历了什么?join和interrupt之间又有什么关系?
很多实习生一上来就背 API,sleep是睡觉,yield是让位,join是等待,interrupt是中断。背得倒是挺顺溜,真到了写代码或者面试官追问细节的时候,就露馅了。我记得带过不少新人,最常见的问题是把sleep和wait混为一谈,或者以为调用了interrupt()就能立刻把线程杀掉——这些都是对线程机制理解不到位导致的。
要把这些方法讲透,绕不开一个基础概念:线程状态。Java 的Thread.State枚举里定义了好几个状态,NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。你调用sleep、join、wait这些方法时,线程的状态会发生对应的迁移。
NEW:线程对象创建了,但还没调用start()。RUNNABLE:调用了start(),线程就绪或正在运行。注意,Java 里RUNNABLE合并了操作系统层面的就绪和运行两个状态,这点和操作系统教材里讲的“五态模型”不一样。BLOCKED:等待进入 synchronized 同步块或方法时,被锁挡住。WAITING:调用了无参的wait()或join()后,无限期等待。TIMED_WAITING:调用了带超时参数的sleep(millis)、wait(timeout)、join(millis)后,有期限地等待。TERMINATED:线程执行完毕,正常结束或被异常终止。
调用关系: start() -> RUNNABLE sleep(millis) -> TIMED_WAITING join() -> WAITING(无限期) join(millis) -> TIMED_WAITING interrupt() -> 不直接改变状态,但会唤醒 WAITING/TIMED_WAITING 的线程这里有一个关键点很多人容易忽略:interrupt()并不会直接改变线程状态,它是通过设置中断标志位,配合sleep、join、wait这些方法的内部逻辑,间接让线程退出等待状态。所以你要想真正理解interrupt,前提是先理解sleep、join这些方法在等待时对中断信号的处理方式。它们其实是一张网,互相咬合着。
咱们这篇文章就沿着这条线往下走,把sleep、yield、join、interrupt挨个扒开看,最后再串起来讲讲实际项目中怎么配合用。
2. sleep:暂停的是线程,别搞成暂停整个程序
Thread.sleep(long millis)应该是大家最早接触的线程方法之一,作用很简单:让当前正在执行的线程暂停指定毫秒数,进入TIMED_WAITING状态。不过越简单的方法,用起来越容易出问题。
2.1 sleep 的本质:让出 CPU 但不释放锁
这里要敲黑板了:sleep方法在调用时,不会释放当前线程持有的锁。
什么意思?比如你在一个synchronized代码块里调用了Thread.sleep(5000),那这 5 秒内,其他线程想进入这个同步块,就得一直等着。因为sleep只是让当前线程暂停执行,对象监视器(Monitor)上锁的持有权还在它手里。
这和Object.wait()有本质区别。wait()一旦调用,会立刻释放锁,然后线程进入WAITING状态,等其他线程调用notify()或notifyAll()来唤醒它。
| 对比项 | sleep | wait |
|---|---|---|
| 所属类 | Thread | Object |
| 是否释放锁 | 不释放 | 释放 |
| 进入状态 | TIMED_WAITING | WAITING |
| 唤醒方式 | 时间到自动醒 | notify/notifyAll |
| 必须持有锁 | 否 | 是(否则 IllegalMonitorStateException) |
我见过不少实习生写的代码,在同步块里用sleep模拟“处理耗时”,结果整个系统吞吐量啪一下就掉下去了。这就是没搞清楚sleep不释放锁导致的。如果你需要在等待期间让其他线程能获得锁,请用wait,而不是sleep。
2.2 实战经验:sleep 怎么用才对
实际开发里,sleep最常见的三个场景:
- 限流/间隔执行:比如轮询数据库、拉取消息队列里的消息,为了控制频率,处理完一批后
sleep一下。 - 模拟耗时操作:写 demo、测试并发时模拟网络延迟、IO 耗时。
- 配合超时重试:比如调用外部接口失败后,
sleep(1000)再重试。
简单说两个要注意的细节。
第一个,sleep的精度问题。sleep(1000)说的是至少暂停 1000 毫秒,不是精确 1000 毫秒后立刻恢复。它受操作系统调度、JVM 实现、系统负载影响,实际唤醒时间可能比设定的更长。做高精度定时任务时,别指望它。
第二个,sleep可被中断。当线程处于TIMED_WAITING状态的sleep期间,如果其他线程调用interrupt(),当前线程会抛出InterruptedException,然后被唤醒。关于这点,下一节讲interrupt的时候再展开。
2.3 一个常见误区:sleep 放在循环里要注意什么
有人写轮询代码,喜欢这么写:
while (true) { boolean result = doSomething(); if (result) { break; } Thread.sleep(1000); }这种模式在功能上没问题,但要注意两点。一是如果doSomething()执行很快,循环加sleep能有效降低 CPU 空转率;二是如果sleep在 try-catch 里被中断了,循环会继续跑而不是退出。正确的做法是用中断标志位来控制循环退出:
while (!Thread.currentThread().isInterrupted()) { try { boolean result = doSomething(); if (result) { break; } Thread.sleep(1000); } catch (InterruptedException e) { // 重新设置中断标志,让上层逻辑感知到中断 Thread.currentThread().interrupt(); break; } }注意:
InterruptedException被抛出后,线程的中断标志位会被清掉,也就是变回 false。如果你希望上层代码仍然能感知到线程被中断过,需要在 catch 块里再次调用interrupt()来恢复标志位。这是个很容易被忽略的细节,但面试和实际开发都爱考。
3. yield:线程的“礼貌性让位”,真的有用吗?
说完了sleep,再来看yield。这个方法在生产代码里出现的频率远不如sleep、join,但在面试里被问到的概率却不低。它的作用是:提示线程调度器,当前线程愿意让出 CPU 执行权,但调度器可以不理会这个提示。
3.1 yield 的执行语义
当线程调用yield()时,它从RUNNING状态回到RUNNABLE状态(Java 视角下还是RUNNABLE),然后重新和其他同优先级线程竞争 CPU。这里记住两个关键点:
yield不会让线程进入等待状态,它只是从“正在运行”变成“就绪”,有机会再次被调度到。yield不释放锁。这一点和sleep一样。yield是否真正让出 CPU,取决于 JVM 和操作系统的调度策略。
你可以在一个死循环里调用yield()却什么也不干,线程依旧可能占满整个 CPU 核心。这和很多人想象的“让位”不一样——它不是强制机制,更多像一个建议。
3.2 yield 的实际价值
那yield到底有什么用?说句实在话,日常业务代码里用得非常少。它主要用在一些特殊的并发场景里:
- 自旋等待的优化:某些无锁算法的自旋循环中,为了避免长时间占用 CPU,会周期性调用
yield()来让其他线程有机会执行。 - 线程优先级协同:在优先级分明的场景下,低优先级任务里可以做
yield来减少对高优先级任务的干扰。 - 调试和学习:用
yield观察线程调度的不确定性。
举个例子,一个简单的线程协作,线程 A 大量消费 CPU,线程 B 希望尽快执行,可以这样配合:
Thread a = new Thread(() -> { long start = System.currentTimeMillis(); while (System.currentTimeMillis() - start < 2000) { // 模拟计算 Math.pow(Math.random(), 2); Thread.yield(); // 主动让出 CPU,给 B 机会 } }); Thread b = new Thread(() -> System.out.println("B 执行了")); a.start(); b.start();yield在这里不保证 B 一定比 A 更快执行,但会提高 B 被调度的概率。
在实际项目中,我不会刻意依赖
yield来保证线程执行顺序。因为 JVM 规范明确说了,yield()的行为“可能不做任何事”。把它当作一个尽力而为的调度提示就好,千万别靠它做严格的并发控制。
4. join:让主线程“等一等”,这是线程协作的基石
join应该是这几个方法里最容易被误解的一个。很多新手以为join是“合并线程”,实际上不是。join的语义是:当前线程等待被调用的线程执行完毕后再继续。
4.1 三种重载形式
Thread类提供了三个join重载:
| 方法 | 说明 |
|---|---|
join() | 等待目标线程终止,无限期等待 |
join(long millis) | 等待目标线程终止,最多等 millis 毫秒 |
join(long millis, int nanos) | 等待最多 millis 毫秒 + nanos 纳秒,实际精度取决于系统 |
这里要特别强调:join()是让调用方线程进入WAITING状态等待,而不是被调用方进入等待状态。
看个最基础的用法:
public class JoinExample { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { try { Thread.sleep(2000); System.out.println("worker 线程执行完毕"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); worker.start(); System.out.println("主线程继续执行自己的逻辑"); worker.join(); // 主线程在这里等待 worker 执行完 System.out.println("worker 结束后,主线程继续往下走"); } }输出顺序是:
主线程继续执行自己的逻辑 (2 秒后) worker 线程执行完毕 worker 结束后,主线程继续往下走注意,main线程调用worker.join()后,main线程进入的是WAITING状态(不带超时参数时)。等worker线程执行到TERMINATED状态后,main线程被自动唤醒,继续执行后续代码。
4.2 join 的底层原理
join为什么能实现“等待线程结束”?看 JDK 源码,join的实现其实相当朴素:
public final synchronized void join(long millis) throws InterruptedException { long base = System.currentTimeMillis(); long now = 0; if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (millis == 0) { while (isAlive()) { wait(0); } } else { while (isAlive()) { long delay = millis - now; if (delay <= 0) { break; } wait(delay); now = System.currentTimeMillis() - base; } } }看到没有?join底层用的是wait(0),wait释放锁,然后线程进入等待。等目标线程执行完毕,JVM 会自动调用notifyAll来唤醒等待在这个线程对象上的所有线程。这就是为什么join是synchronized方法——它需要持有线程对象的监视器锁,才能调用wait。
这个底层机制也解释了一个现象:join(0)表示无限期等待,直到目标线程结束;join(1000)最多等 1 秒,如果 1 秒后目标线程还没结束,join就会返回,当前线程恢复正常执行。
4.3 join 的实战场景
join最常见的场景是:主线程需要等子线程的结果都回来再汇总。比如一个并行计算任务,把一个大任务拆成几个子任务分别跑,最后汇总结果:
public class ParallelSum { private static int[] results = new int[3]; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> results[0] = sum(1, 100)); Thread t2 = new Thread(() -> results[1] = sum(101, 200)); Thread t3 = new Thread(() -> results[2] = sum(201, 300)); t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join(); int total = results[0] + results[1] + results[2]; System.out.println("总结果: " + total); } private static int sum(int start, int end) { int s = 0; for (int i = start; i <= end; i++) { s += i; } return s; } }这里三个子线程并行计算,主线程通过join等待它们都执行完,再汇总结果。注意,join在这里保证了线程间计算结果的内存可见性——线程执行完join返回后,该线程中的所有操作对调用join的线程是可见的,这背后依赖的是 happens-before 原则。
4.4 join 和 CountDownLatch 的取舍
面试的时候,面试官可能会问:“join和CountDownLatch实现线程等待有什么区别?”
简单说几点:
join是Thread类自带的方法,粒度是线程;CountDownLatch是 JUC 包下的工具类,粒度是计数器。join只能等线程结束,如果子线程要等某个条件满足再继续(比如等网络请求完成),join做不到,CountDownLatch可以。join会让出锁(底层是wait),CountDownLatch.await()也会让出 CPU 进入等待,但不会涉及 synchronized 锁的问题。CountDownLatch更灵活,支持多个线程各countDown()一次,计数器归零时所有等待线程一并唤醒。
实际项目中,如果只是简单等待几个线程跑完,用join就行了,代码少且直观;如果涉及复杂的任务协同、资源释放、多个线程共享计数,用CountDownLatch或CyclicBarrier更合适。
5. interrupt:线程中断不是“杀死线程”,而是“协作式打断”
interrupt是这四个方法里最容易引发困惑的。很多新手以为interrupt()就像操作系统的kill命令一样,能把线程直接干掉。大错特错。Java 的线程中断是一种协作机制:调用了interrupt()只是给目标线程打一个“中断标志”,至于目标线程怎么处理这个标志,完全由它自己决定。
5.1 中断标志与三个相关方法
Thread类里有这么几个与中断相关的核心方法:
| 方法 | 说明 |
|---|---|
void interrupt() | 设置目标线程的中断标志为 true |
boolean isInterrupted() | 判断目标线程的中断标志是否为 true,不改变标志 |
static boolean interrupted() | 判断当前线程的中断标志是否为 true,并清除标志(设为 false) |
关键点在于:interrupt()方法本身不会强制终止线程,它只是设置状态。如果目标线程正处在可中断的阻塞状态(如sleep、join、wait),那么它会被唤醒并抛出InterruptedException(此时标志会被清除);如果目标线程正在执行普通代码,那么它只会看到自己的中断标志变成了 true,不会被打断执行。
5.2 InterruptedException 的“出现时机”
所有会抛出InterruptedException的方法,本质上都有一个共同点:它们会让线程进入等待状态,而在等待过程中,线程可以被打断。比如:
Thread.sleep(millis)Thread.join()Object.wait()BlockingQueue.put/take(JUC 里的阻塞队列)CountDownLatch.await()CyclicBarrier.await()Future.get()
当这些方法抛出InterruptedException时,意味着你有两种选择:要么继续向上抛出,要么在 catch 块里恢复中断状态。这两种选择对应两种不同的业务场景。
如果是你负责的方法本身就是任务的一部分,可以向上抛;如果是你自己处理线程的入口,那最好捕获后恢复中断标志,然后执行清理逻辑并结束线程,这样中断才能被上游感知。
// 错误示范:吞掉中断异常 try { Thread.sleep(1000); } catch (InterruptedException e) { // 什么都不做,直接忽略 —— 标志被清掉了,上层无感知 } // 正确示范:恢复中断标志 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 处理后续逻辑,或返回 }在实际项目里,吞掉
InterruptedException是我代码评审时最常看到的问题之一,尤其是在 RPC 调用超时、消息队列消费等场景。线程中断标志位的作用是让整个系统有机会优雅地停下来,你把它吃了,线程就永远等不到一个“该结束了”的通知。
5.3 interrupt 与阻塞等待的组合用法
理解了上面的规则,我们就知道为什么join和interrupt经常被放在一起说了。join()本身是响应中断的——假如主线程在worker.join()等待期间,有第三方线程调用mainThread.interrupt(),那么join()会抛出InterruptedException,主线程得以从等待中醒来,再决定是否要继续等待。
来看一个带超时控制的组合用法:
public class InterruptJoinExample { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // 模拟持续工作 try { Thread.sleep(500); System.out.println("worker 工作中..."); } catch (InterruptedException e) { System.out.println("worker 收到中断,准备结束"); Thread.currentThread().interrupt(); } } }); worker.start(); Thread.sleep(3000); // 主线程请求中断 worker worker.interrupt(); worker.join(1000); System.out.println("主线程继续执行"); } }这个例子展示了中断标志配合循环退出、join带超时等待的标准姿势。worker.interrupt()执行后,worker当前正处在sleep的TIMED_WAITING状态,所以立刻抛出InterruptedException,进入 catch 块,重新置位中断标志,然后循环判断isInterrupted()为 true,退出循环。
5.4 中断不是抢占,是一种请求
这里我要多说一句。Java 设计者把中断设计成协作式是有道理的。如果线程正在持有锁、正在写文件、正在操作数据库,强行打断可能造成数据不一致或者资源泄漏。协作式中断让线程在自己认为安全的时机响应中断,完成必要的清理工作,这是更稳妥的设计。
所以你在代码里看到Thread.currentThread().isInterrupted()这种判断时,本质上是在说:“我每隔一段处理逻辑,检查一下有没有人叫我停,有的话,我就优雅地收拾一下再退场。”
6. 常见问题与命令速查:一张表看穿四个方法的边界
聊到这,我把这几个方法放一起做个对比总结,顺便把面试中常见的一些追问点列出来,方便你做知识盘点。
6.1 四个方法速查表
| 维度 | sleep | yield | join | interrupt |
|---|---|---|---|---|
| 谁调用 | 当前线程 | 当前线程 | 其他线程调用 | 其他线程调用 |
| 核心作用 | 暂停当前线程 millis 毫秒 | 提示调度器让出 CPU | 等待目标线程结束 | 设置目标线程的中断标志 |
| 进入状态 | TIMED_WAITING | RUNNABLE(重新竞争) | WAITING/TIMED_WAITING | 不改变状态 |
| 是否释放锁 | 否 | 否 | 是(底层 wait 释放锁) | 不涉及 |
| 是否响应中断 | 是 | 否 | 是 | 是(自己就是中断动作) |
| 使用场景 | 限流、模拟耗时、超时等待 | 自旋优化、并发调度辅助 | 子线程汇总、并行任务 | 优雅停止线程、协作式任务取消 |
6.2 几个常见的坑和误区
坑一:sleep 和 wait 混用。
wait必须在 synchronized 块里调用,否则抛IllegalMonitorStateException;sleep不需要。wait释放锁,sleep不释放。这两个是面试必问的,你要能脱口而出区别,最好还能举个谁适用谁不适用的例子。
坑二:join 的调用顺序导致死等。
如果先worker.start(),然后再worker.join(),这是正确的顺序。如果你把join放到start之前,那join会检查isAlive(),此时线程还没启动,isAlive()返回 false,join直接返回,完全没起到等待作用。join必须在start之后调用。
坑三:interrupt 状态在异常中被清除。
前面说过,InterruptedException抛出后,中断标志位会被清除。如果你要保留中断状态用于上层判断,必须 catch 后再次调用interrupt()。很多人不看源码只在文档层面理解,很容易漏掉这个细节。
坑四:死循环里不使用中断标志退出,而是用 break 硬跳。
项目里我见过不少人在while循环里写一个标志变量,通过volatile boolean stop来控制退出。这样不是不行,但如果你同时用了sleep或join这样的阻塞方法,stop标志无法打断它们。更稳妥的方案是结合中断机制,用Thread.currentThread().isInterrupted()作为循环条件,或者在 catch 到InterruptedException时用中断标志位来退出。
6.3 面试官最爱的追问
面试官问sleep、join、interrupt的时候,通常不会只让你背定义,而是会层层追问。我把常见的追问链整理一下:
sleep(0)有什么用?不睡觉,但会让出 CPU 还是不会?sleep(0)不保证让出 CPU,它只是让当前线程从运行态重新进入就绪队列参与调度,是否有实际效果取决于 JVM 实现。实际用途有限,不推荐依赖。
- 如果线程 A 调用了
b.join(),那 A 是处于什么状态?- A 处于
WAITING状态(无参join)或TIMED_WAITING(带超时参数)。
- A 处于
- 线程
interrupt()之后,如果线程在运行普通代码,会立即停止吗?- 不会,中断标志会被设置,但线程继续执行普通代码,直到它自己检查标志或者进入可中断的阻塞方法。
- 在
main里调用了worker.join(),main会释放锁吗?- 会。
join底层是wait,会释放线程对象上的锁。但是注意,这里释放的是worker线程对象的锁,不是main持有的其他业务锁。
- 会。
Thread.interrupted()和isInterrupted()有什么区别?interrupted()是静态方法,作用于当前线程,并且会清除中断标志位;isInterrupted()是实例方法,作用于指定线程,不改变标志位。
7. 把它们组合起来:从简单 API 到真实业务场景
单看每个方法都很简单,但真正的工作里,很少单独用一个方法。更常见的情况是:一个线程要处理多个阻塞操作,中途要响应中断,最后还要让其他线程等它完成。这就需要把sleep、join、interrupt组合起来。
我给你写一个接近真实业务的 demo:模拟一个批量任务执行器,主线程启动多个工作线程,如果某个工作线程超时未完成,主线程就中断它,并且等待所有线程退出后再继续。
public class BatchTaskRunner { public static void main(String[] args) throws InterruptedException { // 模拟两个工作线程 Thread task1 = new Thread(() -> doWork("任务1", 800L), "task-1"); Thread task2 = new Thread(() -> doWork("任务2", 1500L), "task-2"); task1.start(); task2.start(); // 给每个任务设定最大等待时间 task1.join(1000); task2.join(1000); // 检查哪些任务还没结束,向未完成的任务发起中断 if (task1.isAlive()) { System.out.println("任务1 超时,发起中断"); task1.interrupt(); } if (task2.isAlive()) { System.out.println("任务2 超时,发起中断"); task2.interrupt(); } // 再次等待所有任务处理完中断逻辑后退出 task1.join(); task2.join(); System.out.println("所有任务已结束,主线程继续"); } private static void doWork(String name, long time) { try { // 模拟耗时任务,分多段执行,中途检查中断状态 long start = System.currentTimeMillis(); while (System.currentTimeMillis() - start < time) { Thread.sleep(200); if (Thread.currentThread().isInterrupted()) { System.out.println(name + " 检测到中断,提前退出"); return; } } System.out.println(name + " 正常执行完成"); } catch (InterruptedException e) { System.out.println(name + " 被打断,处理收尾"); Thread.currentThread().interrupt(); } } }这个例子虽然简单,但覆盖了很典型的模式:
- 并行启动:多个任务同时跑。
- 超市等待:用带超时的
join控制最长等待时间。 - 超时中断:用
isAlive()判断哪个任务超时,发起interrupt()。 - 优雅收尾:再次
join,等待任务处理完中断响应,完成清理。
如果面试官问你会不会写“超时控制的任务取消”,这个代码就是可以直接口述出来的答案。
8. 聊点实操心得:这几个方法背后,藏着 Java 并发设计的哲学
文章写到这,原理也讲了、代码也给了,最后说点我在实际项目里总结的体会。
我以前带过一个实习生,做完一个并发下载的模块,代码跑起来倒是正常,但他不知道自己在用join等线程时,其实背后整个状态流转是怎么发生的。后来他在排查线上问题的时候,发现某个线程卡住不动了,用jstack一看,线程停在WAITING状态上等着一个join,而那个被等待的线程因为某个数据库连接没释放,一直卡在BLOCKED状态。他当时有点懵,我让他把线程状态和这几个方法对应起来,他才意识到:join等待的那个线程如果被阻塞了,整个等待链就僵住了。
我想说的是,学这些常用方法,不要停留在 API 层面,要能跟线程状态、锁、阻塞这些底层机制连起来理解。你写worker.join()的时候,脑中要浮现出主线程进入WAITING、worker线程在跑的场景;你调interrupt()的时候,要知道它只是一次礼貌的请求,线程会不会响应取决于它正在干什么。这种“脑内状态机”建立起来以后,再去看 JUC 里那些高级工具,比如CountDownLatch、Semaphore、FutureTask,你会觉得它们其实都是这些基础方法在不同维度上的封装和扩展。
最后再分享一个小技巧:调试并发问题的时候,最高效的工具不是 IDE 的断点,而是jstack。把进程的线程转储打出来,看看每个线程处于什么状态、栈顶在哪个方法、锁竞争在哪里,基本上就能定位大部分由sleep、join、wait引起的状态问题。你先把线程状态读懂了,再去改代码,效率会高很多。
把这些方法吃透,你就掌握了 Java 并发最底层的控制权柄。往后的路,不管是学习 JUC 包里的并发工具,还是去看 Netty、Tomcat 这种高性能框架的线程模型,都会顺畅很多。