承接的问题
前两节解决了「线程是什么」和「任务怎么交给线程」。这一节处理剩下的一组状态问题:一个线程从 new 到结束,中间会经过哪些状态;为什么线上转储里有的线程是 BLOCKED,有的是 WAITING;setPriority(10) 能不能让重要任务先跑;把线程设成 daemon 之后,为什么 finally 里的收尾代码有时不执行。
这些问题在写业务代码时很少遇到,但在排障时每天都会遇到。线程转储里最权威的一行就是 java.lang.Thread.State,读不懂它,后面的栈帧分析就没有方向。
六个状态与迁移
Thread.State 是一个枚举,只有六个值:
| 状态 | 含义 | 进入方式 | 离开方式 |
|---|---|---|---|
NEW | 已创建,未启动 | new Thread(...) 或 unstarted() | start() |
RUNNABLE | 可运行 | start() 之后 | 抢锁、等待、睡眠、结束 |
BLOCKED | 等待监视器锁 | 进入 synchronized 时锁被占用 | 拿到锁 |
WAITING | 无限期等待 | wait()、join()、park() | notify/notifyAll、目标线程结束、unpark、中断 |
TIMED_WAITING | 限时等待 | sleep(n)、wait(n)、join(n)、parkNanos | 超时、通知、中断 |
TERMINATED | 已结束 | run() 正常返回或抛出未捕获异常 | 不可再迁移 |
迁移关系可以用一张表固定下来:
| 当前状态 | 触发动作 | 下一个状态 |
|---|---|---|
NEW | start() | RUNNABLE |
RUNNABLE | 进入被占用的 synchronized | BLOCKED |
BLOCKED | 锁释放并被调度到 | RUNNABLE |
RUNNABLE | wait() / join() / LockSupport.park() | WAITING |
RUNNABLE | sleep(n) / wait(n) / join(n) / parkNanos(n) | TIMED_WAITING |
WAITING | 被通知、被中断、目标线程结束 | RUNNABLE 或 BLOCKED |
TIMED_WAITING | 超时、被通知、被中断 | RUNNABLE 或 BLOCKED |
RUNNABLE | run() 返回或抛出异常 | TERMINATED |
两个容易记错的点:
第一,RUNNABLE 不等于「正在使用 CPU」。Java 把「已经启动、等待 OS 调度」和「正在执行」都算作 RUNNABLE。线程阻塞在 socket 读、文件 IO 上时,JVM 通常也只能标成 RUNNABLE,因为底层等待发生在 native 层,JVM 不知道。所以看到大量 RUNNABLE 不要立刻判断是 CPU 打满,要结合 cpu= 时间和系统工具看。
第二,ReentrantLock 等不到锁时不是 BLOCKED。BLOCKED 专指等待 synchronized 的监视器。ReentrantLock.lock() 内部走 LockSupport.park(),状态是 WAITING。这个区别在排查「到底是锁竞争还是条件等待」时非常关键,第 9 章会展开。
用 latch 和监视器把六态跑出来
光看表记不住。下面这个类在同一时刻构造出五种状态,join 之后验证第六种。它可以直接编译运行:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
public final class LifecycleProbe {
private static final Object MONITOR = new Object();
public static void main(String[] args) throws Exception {
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread waiter = Thread.ofPlatform()
.name("conc-wait")
.unstarted(() -> {
entered.countDown();
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("waiter.beforeStart=" + waiter.getState());
waiter.start();
entered.await();
Thread sleeper = Thread.ofPlatform()
.name("conc-sleep")
.start(() -> {
try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
AtomicBoolean spinning = new AtomicBoolean(true);
Thread spinner = Thread.ofPlatform()
.name("conc-spin")
.start(() -> {
while (spinning.get()) {
// busy wait,故意占着 CPU
}
});
CountDownLatch holderIn = new CountDownLatch(1);
Thread holder = Thread.ofPlatform()
.name("conc-holder")
.start(() -> {
synchronized (MONITOR) {
holderIn.countDown();
try {
Thread.sleep(3_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
holderIn.await();
CountDownLatch blockedIn = new CountDownLatch(1);
Thread blocked = Thread.ofPlatform()
.name("conc-blocked")
.start(() -> {
blockedIn.countDown();
synchronized (MONITOR) {
System.out.println("blocked.acquired=true");
}
});
blockedIn.await();
Thread.sleep(300);
System.out.println("waiter=" + waiter.getState());
System.out.println("sleeper=" + sleeper.getState());
System.out.println("spinner=" + spinner.getState());
System.out.println("holder=" + holder.getState());
System.out.println("blocked=" + blocked.getState());
release.countDown();
spinning.set(false);
waiter.join();
sleeper.join();
spinner.join();
holder.join();
blocked.join();
System.out.println("waiter.afterJoin=" + waiter.getState());
System.out.println("blocked.afterJoin=" + blocked.getState());
}
}
本机输出:
waiter.beforeStart=NEW
waiter=WAITING
sleeper=TIMED_WAITING
spinner=RUNNABLE
holder=TIMED_WAITING
blocked=BLOCKED
blocked.acquired=true
waiter.afterJoin=TERMINATED
blocked.afterJoin=TERMINATED
对着输出解释每一行:
waiter.beforeStart=NEW:unstarted()创建了线程对象,但start()还没调用。此时它不占 OS 线程,只是一个 Java 对象。waiter=WAITING:线程卡在CountDownLatch.await()上。await()内部用 AQS 的park(),属于无限期等待。sleeper=TIMED_WAITING:Thread.sleep(5_000)是限时等待。spinner=RUNNABLE:忙循环,始终可运行。holder=TIMED_WAITING:它持有MONITOR,但正在sleep。注意这是最容易误判的一行——持有锁的线程在睡觉,它的状态是TIMED_WAITING,不是BLOCKED。blocked=BLOCKED:它在synchronized入口等MONITOR,所以是BLOCKED。blocked.acquired=true出现在状态打印之后:release和 holder 的 3 秒 sleep 结束后,blocked 才拿到锁。afterJoin=TERMINATED:join()返回时线程已经结束,状态不会再变化。
这段实验值钱的地方在于它同时展示了「持锁并睡眠」和「等锁」两种现场。只看名字很容易以为「等锁」和「持有锁」都是 BLOCKED,实际只有等锁才是。
BLOCKED 与 WAITING 的区别
这是线程转储里最常被混淆的一对状态。区别可以列成表:
| 维度 | BLOCKED | WAITING |
|---|---|---|
| 等待对象 | synchronized 监视器 | wait()、join()、park() 等条件 |
| 是否持有锁 | 不持有目标监视器 | 可能持有别的锁(wait() 会释放目标监视器) |
| 能否被中断 | 不能,中断只设置标志 | wait/join 会抛 InterruptedException;park 会返回 |
| 转储提示 | waiting to lock <0x...> | waiting on condition 或 in Object.wait() |
| 常见原因 | 锁被长时间持有、锁粒度过大 | 条件未满足、通知丢失、下游慢 |
| 处理方向 | 缩小临界区、换锁、查死锁 | 查通知逻辑、加超时、查依赖 |
排障时的判据:
- 多个线程停在同一个
BLOCKED,且栈顶都是同一行synchronized,说明锁竞争严重。继续看持有锁的线程在干什么:如果它在做 IO 或睡眠,就是典型的「锁里做慢操作」。 - 多个线程停在同一个对象的
WAITING,先区分是空闲还是卡死。线程池的工作线程停在getTask()的park上是健康的;业务线程停在wait()上且没人notify,才是问题。 BLOCKED不会响应中断。想取消一个正在等锁的线程,interrupt()只能设置标志,它仍要拿到锁才能继续,然后在下一个检查点看到标志。这也是ReentrantLock.lockInterruptibly()存在的理由,第 9 章会对比。
还有一条实践原则:不要用 getState() 做线程间协调。状态是瞬时快照,你读到 RUNNABLE 的下一纳秒它就可能变成 TERMINATED。协调要用 CountDownLatch、Future、join 这些有明确 happens-before 关系的工具。
优先级:一个不能依赖的提示
Thread.setPriority(int) 接受 1–10,默认 5。很多旧代码用它来「让重要任务先跑」,这是错的。
Java 优先级只是给操作系统调度器的一个提示,映射关系由平台决定:
| 平台 | 映射行为 |
|---|---|
| Linux | 映射到 nice 值,普通用户只能调低不能调高,效果有限 |
| macOS | 调度器对 Java 优先级的响应很弱,基本可视为忽略 |
| Windows | 有映射,但与其他进程的优先级共同竞争 |
更根本的问题是:优先级不解决排队。高优先级线程不会「插队」到已经持有锁的线程前面,也不能让一个阻塞在 IO 上的任务提前返回。用它来保证业务顺序,得到的只是「大多数时候看起来有效」的假象。
优先级唯一合理的用途是给后台维护线程设一个较低的值,减少它与业务线程争抢 CPU 的意愿。即便如此,也不要把正确性建立在它上面。需要顺序,用队列;需要限流,用信号量;需要及时性,用更小的任务粒度和足够的核数。
setPriority 还有一个约束:它可以在线程启动后调用,但效果依赖平台;setDaemon 则必须在 start() 之前调用,否则抛 IllegalThreadStateException。
守护线程:不阻止 JVM 退出
JVM 退出的条件是:所有非守护线程都结束,或者有人调用了 System.exit()。守护线程不参与这个判断。
Thread sweeper = Thread.ofPlatform()
.name("loan-expiry-sweeper")
.daemon(true)
.start(() -> {
try {
while (true) {
Thread.sleep(1_000);
// 清理过期预约
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("sweeper finally"); // 不保证执行
}
});
当最后一个非守护线程结束时,JVM 直接开始关闭流程,守护线程被「突然」终止。这里的「突然」意味着:
finally块不保证执行。- 方法栈不会正常展开,
catch也不会捕获到任何「退出异常」。 - 正在写的文件、正在提交的事务可能停在半路。
所以守护线程不能承担必须完成的收尾工作。要保证退出时刷缓冲、关连接、提交偏移量,用 Runtime.addShutdownHook(第 6.3 节)或者显式调用 close(),不要指望 daemon 线程的 finally。
守护线程适合什么?适合「丢了也无所谓」的辅助工作:本地缓存清理、监控心跳、调试用的采样。判断标准很简单:如果 JVM 退出时这项工作没做完,会不会造成数据不一致或资源泄漏?会,就不能用 daemon。
还有一个继承规则:新线程的 daemon 属性默认继承创建它的线程。主线程是非守护的,所以 new Thread(...) 默认也是非守护。在线程池里创建的线程继承的是池里工作线程的属性,这会让「某个任务偷偷创建 daemon 线程」的行为变得难以预测。统一在 ThreadFactory 里显式设置,是更稳的做法。
在 libraryd-conc 里落地
借阅服务需要一个后台清理器,定期扫过期预约。它适合做成守护线程,因为清理延迟一秒不会造成数据错误;但必须提供显式的 close(),让测试和优雅停机可以主动收尾:
package com.plume.conc.core;
public final class ExpirySweeper implements AutoCloseable {
private final Thread worker;
private volatile boolean running = true;
public ExpirySweeper() {
this.worker = Thread.ofPlatform()
.name("loan-expiry-sweeper")
.daemon(true)
.start(this::loop);
}
private void loop() {
while (running) {
try {
Thread.sleep(1_000);
sweepExpiredReservations();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
private void sweepExpiredReservations() {
// 真实实现会查 LoanRepository,这里只保留调用边界
if (Thread.currentThread().isInterrupted()) {
return;
}
}
@Override
public void close() {
running = false;
worker.interrupt();
try {
worker.join(2_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("sweeper state=" + worker.getState());
}
}
三个设计点:
running用volatile,因为它在主线程写、清理线程读。第 4 章会解释为什么这里不加volatile可能永远停不下来。close()先改标志再interrupt(),让睡眠中的线程立刻醒来;只改标志的话,最坏要等一个完整的 1 秒周期。join(2_000)给收尾设上限,避免关闭流程被一个卡住的清理任务拖死。
注意 close() 里的 System.out.println 只是演示。生产代码应该把状态交给日志和指标,而不是标准输出;也不要在 close() 里做可能阻塞的远程调用。
常见错误与边界
| 错误 | 后果 | 改法 |
|---|---|---|
轮询 getState() 等线程结束 | 忙等、读到瞬时值、可能永远等不到 | 用 join() 或 CountDownLatch |
用 BLOCKED 判断 ReentrantLock 竞争 | 实际是 WAITING,方向判断错 | 看栈顶是 park 还是监视器入口 |
在 daemon 线程的 finally 里刷盘 | JVM 退出时 finally 不执行,数据丢失 | 用 shutdown hook 或显式 close() |
start() 之后再 setDaemon(true) | 抛 IllegalThreadStateException | 在 start() 前设置,或用 builder |
用 setPriority(10) 保证关键任务 | 平台可能忽略,且不能插队 | 用队列、限流、独立线程池隔离 |
认为 RUNNABLE 就是 CPU 满载 | IO 阻塞也可能显示 RUNNABLE | 结合 cpu= 时间与系统工具判断 |
什么时候不要用守护线程:任何涉及数据写入、事务提交、远程确认、锁释放的收尾工作。daemon 的语义是「可以随时被抛弃」,不是「优先级低的线程」。如果一项工作必须完成,它就应该有非守护线程或 shutdown hook 兜底。
小结
- 六种状态里,
BLOCKED只对应synchronized等锁;ReentrantLock等锁是WAITING。 - 持有锁并
sleep的线程是TIMED_WAITING,不是BLOCKED;这是转储里最常见的一处误判。 RUNNABLE包含「等待 CPU」和部分 native 阻塞,不能直接等同于 CPU 繁忙。- 优先级只是平台相关的提示,不能用来保证顺序或及时性。
- 守护线程不阻止 JVM 退出,
finally不保证执行;必须完成的收尾要放在 shutdown hook 或显式close()里。
阅读导航:上一节 1.2 Thread、Runnable、Callable 与任务抽象 | 下一节 2.1 竞态的三种形态
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。