Java线程生命周期全解析:从状态迁移到中断与异常恢复实践
P31 这个编号第一次出现在我笔记里时,还不是一个完整项目,而是一条"从启动线程到线程结束"的追踪记录。如果把一条 Java 线程从创建、运行、阻塞、等待、中断、异常到最终终止的全部过程展开,它确实像一场漫长的归途:大多数时候线程都在老老实实地执行任务,但旅途上会有锁竞争、等待通知、被外部打断、运行期异常这些突发状况。搞清楚线程在这条途中的每一步状态变化,以及如何让线程在被中断、被抛出异常之后安全收尾,是排查线上线程问题、写好并发代码的基础。
这篇文章会把 P31 当作一个最小实验项目来拆解。主线是 Java 线程生命周期:先看清状态机,再用 JDK 自带的工具观察状态迁移,然后重点处理中断恢复和异常恢复两类关键场景,最后把任务放到线程池和异步编排中,看更大的"归途"如何设计。整篇代码都可以在本地 JDK 8 及以上环境直接复现,不需要额外依赖。
1. 线程生命周期就是一条可以追踪的单程路线
1.1 Thread.State 里的六个状态分别代表什么
Java 的 Thread.State 枚举定义了线程的六个状态,这是 JVM 层面的抽象,不是操作系统线程状态的全部映射,但已经足够支撑大多数排查场景。
| 状态 | 含义 | 进入方式 | 离开方式 |
|---|---|---|---|
| NEW | 线程对象已创建,但还没有调用 start() | new Thread(...) |
调用 start() |
| RUNNABLE | 线程正在运行,或准备就绪等待 CPU 调度 | start() 后,或从阻塞/等待中恢复 |
被调度执行、进入同步块、调用等待方法 |
| BLOCKED | 线程在等待监视器锁,进入同步代码块或同步方法时锁被其他线程持有 | 竞争 synchronized 锁失败 |
获得锁 |
| WAITING | 线程无限期等待其他线程唤醒 | Object.wait()、Thread.join()、LockSupport.park() |
被 notify()/notifyAll()、目标线程结束、unpark() |
| TIMED_WAITING | 线程在指定时间内等待 | Thread.sleep(ms)、Object.wait(ms)、Thread.join(ms)、LockSupport.parkNanos() |
超时、被唤醒、被中断 |
| TERMINATED | 线程执行完毕或因异常退出 | run() 方法正常返回或抛出未捕获异常 |
不存在 |
这个状态列表并不是线程对象的完整生命周期档案,它更多是"当前这一刻线程正在做什么"的快照。线程从 NEW 到 TERMINATED,是严格单向的,不能从 TERMINATED 回到任何其他状态。
1.2 为什么说这是一趟"归途"而不是"一条直线"
很多初学并发的人会以为线程启动后就会一路运行到结束。实际不是。线程的旅程里有非常多暂停点:
- 调用
Thread.sleep()会让线程主动让出 CPU,进入 TIMED_WAITING。 - 进入
synchronized代码块时如果锁被别的线程持有,会进入 BLOCKED。 - 调用
Object.wait()后会释放锁并进入 WAITING,等待其他线程通知。 - 循环里如果没有可阻塞调用,状态会一直停留在 RUNNABLE,但可能长时间占用 CPU。
把状态迁移看作一条路径,排查问题时会更有方向感。看到 BLOCKED,先怀疑锁竞争;看到 WAITING,先怀疑缺少唤醒;看到大量 RUNNABLE,先怀疑是不是出现了空转。P31 这个实验对象,正是为了把这些状态变化逐个暴露到观察工具面前。
1.3 状态快照、线程栈与锁信息要配合看
Thread.State 只能告诉你线程处于什么状态,不能告诉你为什么。真正能说明问题的是线程栈。使用 JDK 自带的 jstack 可以看到线程调用栈、当前持有的锁、正在等待的锁和行号。后续实验里,我们会把状态和栈顶方法结合起来看。
2. 环境准备:一个 JDK 和三个命令就能开始观察
2.1 确认 JDK 版本与工具路径
P31 不依赖第三方框架,核心是 JDK 自带的 jps、jstack、javac。先确认本机 JDK 可用:
如果 jps 能找到 Java 进程,说明 JDK 的 bin 目录在 PATH 中。推荐使用 JDK 8 以上版本,不同小版本在线程状态细节上差异不大,但 jstack 的输出格式会略有不同。
2.2 准备最小项目结构
先建一个什么都不依赖的目录:
学习阶段可以不引入 Maven,直接用 javac 编译。生产项目再考虑用构建工具管理生命周期。
2.3 观察线程状态的三个基础命令
jstack 输出的关键部分是 java.lang.Thread.State 行和它下面的栈帧。例如:
注意 WAITING (on object monitor) 这种状态描述,说明线程在 Object.wait() 上等待。实践中最常用的排查入口就是这里。
3. 用一段代码模拟线程旅途中的状态迁移
3.1 实验场景设计
为了同时观察六种状态,设计 6 个线程:
- 线程 A:创建后不启动,保持 NEW。
- 线程 B:启动后执行空循环,保持 RUNNABLE。
- 线程 C:竞争一把已被持久的锁,进入 BLOCKED。
- 线程 D:调用
wait(),进入 WAITING。 - 线程 E:调用
sleep(),进入 TIMED_WAITING。 - 线程 F:执行完成后结束,状态为 TERMINATED。
这些线程启动后主线程不断输出它们的状态,好让我们观察整个转换过程。注意 BLOCKED 与 WAITING 的表现很接近,需要结合栈帧区分。
3.2 完整示例代码
这段代码的关键点有四个:
- 主线程持有了
LOCK,因此blockedThread在尝试进入同步块时只能等待锁释放。 waitingThread也先拿到锁再调用wait(),调用后它会释放锁,让blockedThread有机会竞争。但因为主线程仍然持有锁,blockedThread仍处于 BLOCKED。runnableThread的空循环会让它一直处于 RUNNABLE。terminatedThread的run()立即结束,所以最终状态是 TERMINATED。
3.3 编译运行并对比预期输出
预期输出类似:
程序运行后会在 Thread.sleep(2_000) 后输出状态,然后中断 waitingThread。由于 runnableThread 仍在无限循环,程序不会自动退出。可以在另一个终端用 jps -l 找到进程,再用 jstack 捕获现场。
3.4 使用 jstack 与线程状态对照
打开 thread-dump.txt,重点找 thread-blocked 和 thread-waiting 两段:
这里出现了差异:BLOCKED 状态的栈里会出现 waiting to lock,而 WAITING 状态的栈里会出现 Object.wait。排查时不要只看 Thread.State 一行,要顺着栈帧找具体调用点。
注意:
runnableThread的空循环是一个高 CPU 消耗的占位线程,实验结束后要立刻停止进程。生产环境里如果出现大量 RUNNABLE 空转线程,同样要当心 CPU 被打满。
4. 中断恢复:旅程被打断后如何安全回到终点
4.1 中断机制并不是强制停止
Thread.interrupt() 不是强制杀死线程,它只是把线程的中断标志位设为 true,并在线程处于可中断阻塞状态时抛出 InterruptedException。线程收到中断信号后,如何处理完全由代码决定。
| 线程状态 | interrupt() 的效果 |
|---|---|
| RUNNABLE | 仅设置中断标志位,不影响正在执行的代码 |
| WAITING/TIMED_WAITING 中的可中断方法 | 抛出 InterruptedException,并清除中断标志位 |
| BLOCKED | 设置中断标志位,但不会释放等待的锁 |
| NEW/TERMINATED | 设置中断标志位,无其他影响,实际上通常无意义 |
最常见的错误是 catch 住 InterruptedException 后什么都不写:
这样做的结果很危险:中断标志位被清除,上层代码无法知道线程被中断过,任务可能继续执行很长时间。
4.2 处理 InterruptedException 的推荐策略
策略有两种,二选一,但不能直接吞掉:
- 如果当前方法不准备处理中断,就把
InterruptedException声明到方法签名上,向上层抛出。 - 如果当前方法需要继续执行清理逻辑,就在 catch 之后调用
Thread.currentThread().interrupt(),把中断标志位恢复回去。
4.3 在循环里处理中断状态
如果线程在循环中既不调用 sleep() 也不调用 wait(),只是不断执行普通代码,interrupt() 不会自动触发异常。此时需要在循环条件里主动检查中断标志:
这种写法能让中断尽快生效。注意不要使用 Thread.interrupted() 作为循环条件,因为它会清除中断标志,可能在多判断几次后丢失信号。
4.4 中断语义速查表
| 方法 | 是否清除中断标志 | 说明 |
|---|---|---|
Thread.interrupt() |
否 | 设置中断标志 |
Thread.isInterrupted() |
否 | 查询中断标志 |
Thread.interrupted() |
是 | 查询并清除中断标志,静态方法 |
InterruptedException 抛出后 |
是 | 需要自己决定是否恢复标志 |
5. 异常恢复:线程抛出异常不是结束,而是链路需要兜底
5.1 为什么主线程捕获不到子线程异常
线程是独立的执行单元。Thread.run() 方法有自己的调用栈,它内部抛出的异常只能在当前线程内部处理,不会像普通方法调用那样抛给调用方。结果就是:
- 如果
run()方法没有捕获异常,线程会进入 TERMINATED。 - 调用线程只能通过
join()等待线程结束,但拿不到异常对象。 - 线程池场景下,
Runnable任务抛出的异常会由线程池的工作线程捕获,具体表现取决于提交方式。
5.2 使用 UncaughtExceptionHandler 统一兜底
可以用 DefaultUncaughtExceptionHandler 或线程实例的 setUncaughtExceptionHandler 来捕获线程内未处理异常:
这个机制适合处理"最后一道防线"。业务逻辑里仍然应该先自行捕获业务异常,UncaughtExceptionHandler 只负责兜底和记录。
5.3 线程池任务的异常处理差异
线程池提交任务有两种方式,异常表现完全不同:
| 提交方式 | 异常表现 | 获取异常方式 |
|---|---|---|
execute(Runnable) |
异常直接打印到工作线程,调用方无感知 | 使用 UncaughtExceptionHandler |
submit(Callable) |
异常被封装进返回的 Future |
调用 future.get() 时抛出 ExecutionException |
真实项目里推荐优先使用 submit 加 Future 的方式,这样可以把异常作为任务结果的一部分,交给调用方处理。
6. 从线程到异步任务:让"归途"更可控
6.1 不要裸用线程,线程池是更合适的归途容器
裸用 new Thread() 创建线程本身没有问题,问题在于线程生命周期不受控制:无法复用、无法统一管理数量、无法优雅关闭。生产环境更常见的方式是使用线程池。
线程池的核心参数需要理解而不是死记:
| 参数 | 含义 | 设置影响 |
|---|---|---|
| corePoolSize | 核心线程数 | 平时保留的线程数,过大会浪费资源 |
| maximumPoolSize | 最大线程数 | 高峰期可扩展到的上限 |
| keepAliveTime | 非核心线程空闲存活时间 | 过短会导致频繁创建销毁线程 |
| workQueue | 任务队列 | 决定排队还是立即拒绝 |
| threadFactory | 线程工厂 | 可以自定义线程名,方便排查 |
| rejectedExecutionHandler | 拒绝策略 | 队列满和线程满时的处理方式 |
6.2 用 Future 获取线程执行结果和异常
线程池与 Future 配合,可以把一次异步旅程的终点变成一个可等待的结果对象:
这里有两个关键点:
future.get()会阻塞等待任务完成,可以设置超时时间,避免无限等待。pool.shutdown()只负责让线程池不再接收新任务,已提交任务仍会执行。需要等待所有任务结束时应使用awaitTermination。
6.3 用 CompletableFuture 编排更复杂的"归途"
当一条任务链上有多步操作,而且每一步都可能失败、需要补偿时,CompletableFuture 比手工管理线程更合适。
下面这个例子模拟一次交易处理:先调用远程服务,远程服务失败后自动回退到本地缓存,再继续后续流程。
exceptionally 的作用是在上游阶段出现异常时,提供一个恢复值。这样任务链不会因为一个环节失败而整条断开,这正是 P31 里最典型的"恢复路径"。
7. 常见坑与排查路径
7.1 线程卡在 WAITING 或 BLOCKED,如何快速定位
现象:接口响应变慢,或者线程池中的线程长期不释放。
排查顺序:
如果两次快照中,同一线程都停在同一个栈帧,基本可以断定它被卡住了。然后看栈顶:
| 出现内容 | 排查方向 |
|---|---|
waiting to lock <对象> |
锁竞争,查找锁持有者 |
in Object.wait() |
缺少 notify,或条件不满足导致一直等待 |
parking to wait for <...> |
可能是线程池队列或锁相关 |
RUNNABLE 且 CPU 高 |
空循环或忙等待 |
这一步要结合业务代码确认。常见原因包括连接池打满、数据库锁、外部接口无响应、wait() 之后没有 notify 分支。
7.2 中断标志位被错误清除
现象:线程明明被 interrupt() 过,但业务代码依然运行了很久。
代码层面通常是这个写法:
这段代码看起来正确,其实 break 之后线程直接退出,业务中同样可能遇到"不想退出循环,但需要感知中断"的场景。更稳健的写法是:
7.3 线程池优雅关闭,避免任务丢失
现象:应用重启后,部分提交到线程池的任务没有执行完。
原因往往是直接把 shutdownNow() 当成万能关闭方法。shutdownNow() 会尝试中断正在执行的任务,并返回尚未开始执行的任务列表。如果业务不感知中断,可能造成结果不完整。
推荐做法:
先停止接收新任务,再给已有任务一个缓冲时间,超时后再强制中断。
7.4 P31 线程旅程排查清单
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| JDK 工具可用 | java -version、jps -l |
能输出版本和进程 ID |
| 线程状态可观察 | jstack <pid> |
能看到状态和栈帧 |
| 中断标志处理正确 | 检查所有 InterruptedException catch 块 |
要么恢复标志,要么上抛 |
| 线程异常有兜底 | 检查 UncaughtExceptionHandler 或 Future.get |
异常不会被吞掉 |
| 线程池正确关闭 | 检查 shutdown 与 awaitTermination |
应用退出时任务处理完成 |
| 线程命名规范 | 检查 ThreadFactory |
日志里能识别业务线程 |
8. 生产环境最佳实践与扩展方向
8.1 学习环境与生产环境的差异要提前拉开
P31 示例只解决"看得见"的问题,生产环境还需要考虑更完整的保障:
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 线程命名 | 不关心 | 必须自定义线程名,便于日志定位 |
| 异常记录 | 打印到控制台 | 写入日志系统并关联 traceId |
| 线程数量 | 固定几个 | 根据 CPU 核数、IO 等待比计算 |
| 监控 | 手动 jstack | 接监控平台,定期采集线程池指标 |
| 回滚 | 无 | 任务消息可重投或状态可重置 |
8.2 线程命名与日志贯穿
线程名看起来小,排查问题时作用很大。自定义 ThreadFactory 是实现线程命名最常见的方式:
配合日志输出当前线程名,可以把一次请求的链路追踪到具体线程:
8.3 从线程生命周期延伸出去的学习路径
写完 P31,下一步值得深入的方向大致是这条线:
synchronized与ReentrantLock的锁语义差异,重点看 BLOCKED 与 WAITING 的区别。JMM与volatile,理解线程之间可见性如何影响中断标志。- 线程池参数调优,重点看核心线程数、队列容量与拒绝策略如何配合。
CompletableFuture的异步编排,以及exceptionally、handle、whenComplete的取舍。- 虚拟线程或协程模型,理解线程作为操作系统资源在更高层抽象下如何被重新设计。
学习时不建议直接跳到高级特性。先把状态迁移、中断恢复和异常兜底练熟,再逐步引入锁和异步框架,排查问题时才会知道每一层抽象都在解决什么。
P31 的最小实验虽然用了一个空转线程模拟 RUNNABLE,但它给排查真实问题提供了一套可复用的方法:先用 jps 找到进程,再用 jstack 拿线程栈,然后根据状态和栈帧判断是锁竞争、等待唤醒还是中断丢失。这套方法在任何 Java 服务上都适用。遇到线程问题时,先不要盲改代码,先抓一次线程栈,看看线程到底走到了哪一步。