线程生命周期与状态转换

候选人小李在面试美团 P6 时,面试官看了一眼简历上的"多线程开发经验",问道:

"Java 线程有哪些状态?它们之间怎么转换的?"

小李想了想:"有创建、运行、阻塞...还有等待?"面试官追问:"那 WAITING 和 TIMED_WAITING 有什么区别?BLOCKED 和 WAITING 呢?"

小李犹豫了:"它们都是...不运行的状态?"面试官继续追问:"一个线程在 synchronized 锁竞争失败后是什么状态?调用 Lock.lock() 失败后呢?"

小李彻底卡住了...

一、核心问题:线程的六种状态 🔴

1.1 问题拆解

这道题考察的是候选人对 Java 并发模型的系统理解,而不是背诵 Thread.State 枚举。面试官的追问链通常如下:

第一层:枚举背诵(怎么用?)
  "Thread.State 枚举有哪几个值?"
  考察点:基本 API、是否能正确列举

第二层:状态转换(底层实现)
  "BLOCKED 和 WAITING 状态有什么区别?"
  考察点:JVM 层面两种阻塞的本质差异

第三层:源码验证(源码细节)
  "一个线程调用 Lock.lock() 失败后是什么状态?调用 wait() 呢?"
  考察点:是否看过 JDK 源码,对底层实现有理解

第四层:生产场景(工程实践)
  "线程池中的线程是什么状态?为什么不会直接 TERMINATED?"
  考察点:线程池与线程生命周期的关系

1.2 ❌ 错误示范

候选人原话 A:"线程有 NEW、RUNNING、BLOCKED、WAITING、TERMINATED 五种状态。"

问题诊断:RUNNING 不是 Java 线程状态。Java 中的可运行状态是 RUNNABLE,它涵盖了 Ready(就绪)和 Running(运行)两个操作系统层面的状态。这是最常见的错误——混淆了 JVM 层和 OS 层的线程状态。

候选人原话 B:"调用 sleep() 会释放锁,调用 wait() 也会释放锁。"

问题诊断:这是典型的错误认知。wait() 会释放锁,但 sleep() 不会。两者在是否释放 CPU 时间片上类似,但在锁语义上完全不同。面试官追问"那调用 sleep() 时持有的是哪个锁",答不上来说明没踩过坑。

候选人原话 C:"BLOCKED 就是等待锁,WAITING 就是等待其他线程。"

问题诊断:这个理解太粗糙。BLOCKED 是 synchronized 锁竞争失败后的被动等待,WAITING 是线程主动调用 Object.wait()LockSupport.park() 等方法后的状态。两者的唤醒机制和超时支持完全不同。

1.3 标准回答

P5 级别:准确列举六种状态

Java 线程有六种状态,定义在 java.lang.Thread.State 枚举中:

  1. NEW:线程被创建但尚未启动(未调用 start() 方法)
  2. RUNNABLE:可运行状态,包括 OS 层面的 Ready(就绪)和 Running(运行)
  3. BLOCKED:阻塞状态,等待获取 synchronized 排他锁
  4. WAITING:等待状态,线程主动调用 wait()join()LockSupport.park() 等方法
  5. TIMED_WAITING:超时等待,与 WAITING 类似但有超时参数
  6. TERMINATED:终止状态,线程执行完毕

一个关键点:Java 的 RUNNABLE 状态不等于"正在 CPU 上执行"。当线程调用阻塞 I/O 操作(如 ServerSocket.accept())时,在 OS 层面是阻塞的,但在 JVM 层面仍然是 RUNNABLE——因为 JVM 认为线程还在"等待 I/O 完成"这个事件,这个等待对 JVM 是不可见的。

这个回答展示了准确的概念和关键细节,已经比大多数候选人强了。

P6 级别:掌握完整状态转换图

线程状态的完整转换如下:

graph TD
    A[NEW] -->|start()| B[RUNNABLE]
    B -->|synchronized 获锁失败| C[BLOCKED]
    C -->|获锁成功| B
    B -->|wait()| D[WAITING]
    D -->|notify()/notifyAll()/interrupt()| B
    B -->|sleep(n)| E[TIMED_WAITING]
    B -->|join()| E
    B -->|LockSupport.parkNanos()| E
    E -->|超时/notify| B
    B -->|run() 结束| F[TERMINATED]
    D -->|interrupt()| B

BLOCKED vs WAITING 的核心区别

对比维度BLOCKEDWAITING
触发条件synchronized 锁竞争失败主动调用 wait()/park()
唤醒方式锁释放后自动唤醒必须 notify()/unpark()
超时支持TIMED_WAITING 支持
等待位置Entry Set(锁的等待队列)Wait Set(对象的等待队列)

这个回答能画出完整状态图并解释转换条件,已经达到 P6 要求。

P7 级别:深入源码与 JVM 实现

如果你想在面试中拉开差距,需要理解 JVM 层面是如何实现这些状态的。

NEW → RUNNABLE:调用 start() 后,线程被加入 JVM 的调度队列,实际何时上 CPU 由 OS 调度器决定。

RUNNABLE → BLOCKEDsynchronized 锁由对象头中的 Mark Word 记录持有线程。当线程尝试获取已被占用的锁时,JVM 会将线程挂到该锁的 Entry Set 中,线程进入 BLOCKED 状态。锁释放时:JVM 从 Entry Set 中唤醒一个线程(通常是等待时间最长的),该线程从 BLOCKED 变为 RUNNABLE。

RUNNABLE → WAITING:调用 wait() 后,线程从 Entry Set 移动到该对象的 Wait Set(等待队列),并释放已持有的锁。Wait Set 由 ObjectMonitor 管理,底层是双向链表。

BLOCKED vs WAITING 的唤醒机制差异:BLOCKED 线程在锁释放后立即被唤醒(无需额外信号),而 WAITING 线程必须收到 notify()notifyAll() 才会被移动到 Entry Set(此时仍需竞争锁)。

一个生产中的高频陷阱Lock.lock() 失败后线程是什么状态?答案是 WAITINGLockSupport.park()),不是 BLOCKED。因为 Lock 使用 AQS 的 AbstractQueuedSynchronizer,线程失败后被 park,状态为 WAITING/TIMED_WAITING。这与 synchronized 的 BLOCKED 完全不同——这也是为什么 jstack 中能看到线程处于 WAITING on <monitor>WAITING on java.util.concurrent.locks 两种截然不同的等待。

【面试官心理】 我问他线程状态,其实是在探测他对 JVM 和 OS 交互的理解程度。90% 的候选人知道六种状态,但说不清 BLOCKED 和 WAITING 在 JVM 层面的实现差异。能说出 ObjectMonitor、Entry Set、Wait Set 的,说明他看过 JVM 源码,这是 P6/P7 的分界线。

1.4 追问升级

追问 1:sleep(0) 和 yield() 有什么区别?

Thread.sleep(0) 表示放弃本次时间片剩余部分,重新进入就绪队列等待调度。Thread.yield() 同样是建议调度器让出 CPU,但具体行为取决于 OS 实现。两者都不释放锁。

实战场景sleep(0) 可用于忙轮询中的"让出"——在自旋等待某个条件时,用 sleep(0) 避免 CPU 占用 100%,但这个做法通常不如 LockSupport.parkNanos() 优雅。

追问 2:为什么 wait() 必须在 synchronized 中调用?

这是 Java 设计层面的约束。wait() 依赖对象头的 monitor 锁机制,必须在持有 monitor 的情况下调用,否则抛出 IllegalMonitorStateException。这背后的设计哲学是:等待一个条件之前必须先持有锁,否则唤醒和等待之间会产生竞态条件。

追问 3:线程池中的线程是什么状态?

线程池中的线程在 getTask() 阻塞等待任务时,通常处于 WAITINGTIMED_WAITING 状态(取决于队列是否有超时配置)。关键:线程池中的线程不会直接变成 TERMINATED,而是会循环利用——这是线程池的核心价值:复用线程,避免频繁创建/销毁的开销。

二、BLOCKED 与 Monitor 的关系 🟡

2.1 synchronized 锁的底层实现

在 JDK 8 中,synchronized 的底层实现依赖 ObjectMonitor:

// HotSpot ObjectMonitor 关键结构
ObjectMonitor() {
    _header = NULL;
    _count = 0;          // 等待线程计数
    _waiters = 0;        // 等待线程数(精确值)
    _owner = NULL;       // 持有锁的线程
    _WaitSet = NULL;     // Wait Set(调用 wait() 的线程)
    _EntryList = NULL;   // Entry Set(等待获锁的线程)
    _recursions = 0;     // 重入次数
}

线程竞争 synchronized 锁时:

  1. 通过 CAS 尝试将 _owner 从 NULL 设为当前线程(偏向锁/轻量锁路径)
  2. 失败后进入 _EntryList,状态变为 BLOCKED
  3. 锁释放时唤醒 _EntryList 中的一个线程

2.2 Lock 的底层实现差异

ReentrantLock 使用 AQS,不依赖对象头的 monitor。AQS 内部有 volatile int state 和一个双向链表作为等待队列。

lock() 失败后,线程被封装为 Node 加入等待队列,并调用 LockSupport.park() 挂起,状态为 WAITING(精确说是 Node.SHAREDNode.EXCLUSIVE)。

这就解释了为什么 jstack 输出中:synchronized 锁等待显示 BLOCKED,而 ReentrantLock 锁等待显示 WAITING on java.util.concurrent.locks...

三、生产避坑

3.1 线程假死问题

场景:线上服务突然无响应,jstack 发现大量线程处于 WAITING 状态,但 notify() 早已被调用,线程却始终不唤醒。

根因:使用了 notify() 而不是 notifyAll()。由于等待同一条件的线程可能被放入不同的等待队列(取决于 JVM 实现细节),notify() 只唤醒其中一个,导致其他线程永远等待。

正确做法:除非你 100% 确定只有一条线程在等待,否则使用 notifyAll()。代价是惊群效应(多个线程同时被唤醒竞争锁),但正确性优先于性能。

3.2 线程池饥饿死锁

场景:使用 Executors.newFixedThreadPool(1) 单线程池,在任务中调用 submit() 提交另一个任务等待同一线程池执行。由于池中唯一线程正在等待自己,产生线程池内部死锁。

ExecutorService pool = Executors.newSingleThreadExecutor();
Future<?> future = pool.submit(() -> {
    // 同一个 pool 中没有可用线程了
    pool.submit(() -> doSomething()).get();
});

排查jstack 中看到两条线程互相 WAITING on java.util.concurrent.FutureTask,形成循环依赖。

💡

面试加分点:能说出"Java 16 引入了虚线程(Virtual Threads),虚线程在 LockSupport.park() 时不会阻塞底层 OS 线程,而是将虚线程挂起到堆中的 Continuation 对象上,彻底解决了'百万线程'的内存问题"。这说明你有 JDK 最新特性的跟进。

⚠️

面试陷阱:被问到"线程状态和线程优先级"时,很多人会说"BLOCKED 线程优先级更高"。这是错误的。线程优先级只是对 OS 调度器的建议,不保证任何顺序。BLOCKED 和 WAITING 的唤醒顺序取决于具体的同步机制(synchronized 的 Entry Set 是 FIFO?AQS 的等待队列是 FIFO?——实际上 JDK 8 的 synchronized 不保证 FIFO,但 AQS 是 CLH 队列严格 FIFO)。