跳到内容

第 1 卷 / 第 14 章 / 第 1 课

14.1 线程创建与生命周期 ​

学过方法、对象、Lambda 和异常后,用约 50 分钟跑完本篇 Java 17 平台线程实验。你会启动、等待和协作取消线程,也能解释六种 Thread.State 各自等待的是什么。

一个人处理两个窗口 ​

这里适合把两项工作表示为独立任务。先别急着讨论共享库存;本页只回答:任务怎样交给线程,调用者怎样知道线程完成,以及程序怎样请求线程停止。

1. Runnable 是任务,Thread 是执行者 ​

java
Runnable printReceipt = () ->
        System.out.println("打印回执:" + Thread.currentThread().getName());

Thread worker = new Thread(printReceipt, "receipt-worker");
worker.start();

Runnable 的单一抽象方法是 void run()。它只描述工作,不拥有线程。

还可以继承 Thread 并覆盖 run():

java
final class CatalogThread extends Thread {
    CatalogThread() {
        super("catalog-worker");
    }

    @Override
    public void run() {
        System.out.println("查询目录");
    }
}

这种写法能运行,但把任务和线程生命周期绑定在一起,也占用了 Java 的单继承位置。多数业务代码更适合将 Runnable 交给 Thread;需要 Callable 返回结果时,使用下一专题的执行器。

2. run() 与 start() 是两件事 ​

调用 run() 只是普通方法调用,仍由当前线程执行。调用 start() 才请求 JVM 建立新的执行路径,并由新线程调用 run()。

下面是完整、输出稳定的实验:

java
public class ThreadLifecycleDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(
                () -> System.out.println("任务线程:" + Thread.currentThread().getName()),
                "library-worker");
        worker.run();
        worker.start();
        worker.join();

        if (worker.isAlive() || worker.getState() != Thread.State.TERMINATED) {
            throw new AssertionError("join 返回后线程应已终止");
        }
        System.out.println("线程检查通过");
    }
}

运行:

bash
chapter14_thread_root=$(mktemp -d)
mkdir -p "$chapter14_thread_root/out"
# 将上面的代码保存为 "$chapter14_thread_root/ThreadLifecycleDemo.java"
javac --release 17 -Xlint:all -Werror \
  -d "$chapter14_thread_root/out" \
  "$chapter14_thread_root/ThreadLifecycleDemo.java"
java -ea -cp "$chapter14_thread_root/out" ThreadLifecycleDemo
rm -rf -- "$chapter14_thread_root"

输出:

text
任务线程:main
任务线程:library-worker
线程检查通过

实验先直接调用 worker.run(),再启动工作线程,最后用 join() 等待它结束,所以三行顺序是确定的。多个已经启动的线程之间通常没有这种顺序保证。

3. 一个 Thread 只能启动一次 ​

线程从未启动状态走到终止状态后,不能倒回去:

java
Thread worker = new Thread(() -> { });
worker.start();
worker.join();
worker.start(); // IllegalThreadStateException

异常不是因为第一次任务失败,而是因为线程对象的生命周期是一次性的。重复工作应该创建新线程,或复用线程池中的工作线程,而不是复用 Thread 对象。

4. 六种 Java 线程状态 ​

text
NEW --start()--> RUNNABLE -----------------------> TERMINATED
                    |  ^
                    |  | 获得监视器/条件满足/超时
                    v  |
             BLOCKED / WAITING / TIMED_WAITING
状态典型原因关键区别
NEWnew Thread(...) 后、start() 前尚未启动
RUNNABLE可运行或正在执行Java 不另设 RUNNING;等待某些操作系统资源时也可能显示此状态
BLOCKED等待进入 synchronized 区域等的是监视器锁
WAITINGwait()、join()、park() 无超时等待等另一个动作,无截止时间
TIMED_WAITINGsleep()、带超时的 wait()/join()有截止时间
TERMINATEDrun() 返回或因未捕获异常退出不能再次启动

状态快照不能用来协调 ​

java
Thread.State observed = worker.getState();

这行读到的是瞬时状态。下一行执行前,线程可能已经切换状态。因此,不要写“等到 getState() == WAITING 再修改数据”之类协议,也不要用固定毫秒数的 sleep() 猜另一个线程已经走到哪一步。

需要确定先后关系时,使用 join()、CountDownLatch、BlockingQueue、Phaser 或其他明确的协作工具。状态快照适合日志、监控和线程转储分析。

5. join() 建立完成关系 ​

java
final int[] result = new int[1];
Thread worker = new Thread(() -> result[0] = 42);
worker.start();
worker.join();
System.out.println(result[0]);

这里先启动线程,再用无超时的 join() 确认终止;工作线程的动作 happens-before 调用者随后执行的动作,所以这里能看到 42。如果去掉 join(),主线程既不知道赋值何时发生,也不应直接依赖该结果。

对尚未启动的线程调用 join() 会立即返回,不能替代启动后的等待。join() 自身可被中断。调用者若不想无限等,可以使用 join(timeout),但超时返回后必须再检查 worker.isAlive();超时不等于线程已经结束。

6. 命名、守护线程与未捕获异常 ​

给线程命名能直接改善日志和线程转储:

java
Thread worker = new Thread(task, "library-catalog-1");
worker.setUncaughtExceptionHandler((thread, error) ->
        System.err.println(thread.getName() + " 失败:" + error));

线程分为用户线程和守护线程。JVM 中只剩守护线程时,可以退出,不会等待守护线程完成。

java
worker.setDaemon(true); // 必须在 start() 前调用

守护线程不是可靠的资源清理机制:JVM 退出时可能来不及刷新缓冲区或完成写入。必须完成的数据应由显式生命周期、关闭信号和 join()/执行器关闭来管理。

未捕获异常会终止当前线程。普通 Thread 可设置 UncaughtExceptionHandler;在线程池里,execute() 与 submit() 的异常路径不同,详见线程池专题。

7. 中断:请尽快停下 ​

Java 17 已弃用 Thread.stop():强行终止可能破坏对象不变量。本篇使用中断表达协作取消。

java
worker.interrupt();

这只是发出请求。任务要在合适位置检查:

java
while (!Thread.currentThread().isInterrupted()) {
    processNextBook();
}

如果线程正阻塞在 sleep()、wait()、join() 或许多并发工具的可中断方法中,它们会抛 InterruptedException。抛出时中断状态会被清除。

实验:恢复中断状态 ​

java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;

public class InterruptionDemo {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch ready = new CountDownLatch(1);
        AtomicBoolean restored = new AtomicBoolean();
        CountDownLatch neverReleased = new CountDownLatch(1);

        Thread worker = new Thread(() -> {
            try {
                ready.countDown();
                neverReleased.await();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                restored.set(Thread.currentThread().isInterrupted());
            }
        }, "interruptible-worker");

        worker.start();
        ready.await();
        worker.interrupt();
        worker.join();

        System.out.println("中断已恢复:" + restored.get());
        if (!restored.get()) {
            throw new AssertionError("中断状态没有恢复");
        }
        System.out.println("中断检查通过");
    }
}

输出:

text
中断已恢复:true
中断检查通过

ready 让主线程知道任务已准备好;neverReleased 永不倒数,只能被中断解除等待。即使中断早于 await() 到达,等待也会抛出异常,因此不用猜调度时间。捕获块无法向上抛受检异常,于是恢复中断状态,让更外层的任务框架仍能观察到取消。

如果你的方法签名允许,直接传播更清楚:

java
void awaitReceipt(Thread worker) throws InterruptedException {
    worker.join();
}

8. 线程安全从哪里开始 ​

线程本身并不自动制造问题。危险来自可变共享状态:

  • 局部变量本身属于当前调用栈,但它引用的对象仍可能被多个线程共享;
  • 创建后不变的对象更容易安全共享;
  • 多个线程读同一份不可变数据通常无需加锁;
  • 多个线程读写同一库存、余额或集合时,必须定义同步规则。

线程启动也有一条发布保证:调用线程在 start() 前的动作 happens-before 新线程中的动作。因此,在启动前完整构造好任务所需数据,比启动后再无同步地修改字段更容易推理。

9. 常见故障 ​

现象原因与处理
调 run() 后线程名仍是 mainrun() 是普通调用;使用 start()
第二次 start() 失败Thread 一次性;创建新对象或使用执行器
用 sleep() 后测试仍偶发失败时间延迟不是完成关系;改用明确协作工具
捕获中断后服务关不掉中断被吞;传播或恢复状态
JVM 在主方法结束后仍不退出仍有用户线程存活或线程池未关闭
守护线程的文件只写了一半守护线程不保证收尾;显式停止并等待

10. 练习 ​

  1. 创建名为 catalog-worker 和 receipt-worker 的两个线程。不要假定它们的输出顺序;用两个 join() 等待全部结束。
  2. 运行“双重 start()”失败示例,记录异常类型。解释为什么调用两次 run() 不会触发同一异常。
  3. 给一个每次处理 100 本书的循环增加中断检查。比较“传播 InterruptedException”与“捕获并恢复状态”的适用位置。
  4. 用 CountDownLatch 代替 sleep(),确认两个线程准备好后统一放行;调度器仍决定各自何时实际执行。

离开两个窗口前,再跑四次协作 ​

先用线程名证明直接调用 run() 仍在 main 中,而 start() 会进入新的执行路径。等待两个工作线程时分别调用 join(),只断言它们都已完成,不断言彼此的输出顺序。

随后让同一个 Thread 启动两次,确认第二次抛 IllegalThreadStateException;再用 CountDownLatch 代替固定休眠,让主线程确定工作线程已经准备好后才发送中断。捕获 InterruptedException 时,要么继续传播,要么恢复中断并尽快结束当前任务。

线程状态只用于观察。若一段业务代码需要反复读取 getState() 才知道下一步做什么,通常意味着缺少真正的完成、消息或条件协议。

查阅 Java 17 的正式契约 ​

下一步:保护共同规则 ​

线程是一段一次性的生命周期:start() 启动另一条执行路径,join() 建立完成关系,中断提出取消请求。下一篇进入真正困难的部分,多个线程同时修改最后一本书的库存时,怎样让业务规则始终成立。

继续学习同步、通信与死锁

Built with VitePress | Software Systems Atlas