Java多线程初阶-基础概念与线程操作

发布于:2025-08-03 ⋅ 阅读:(19) ⋅ 点赞:(0)

Java多线程初阶:从核心概念到线程操作

文章简述
大家好!我们也是开启了JavaEE的学习,在JavaEE中的重要一站,理解和掌握多线程是提升程序性能、构建高响应应用的关键。这篇文章可以作为多线程学习之旅的起点,旨在帮助初学者建立对线程的清晰认识。内容将从“什么是线程”这个基本问题出发,探讨其与进程的区别,并动手编写第一个多线程程序。接着,会深入学习Thread类的常用方法,掌握如何创建、启动、中断和等待一个线程。最后,会全面解析线程的生命周期与各种状态,为后续理解更复杂的并发问题打下坚实的基础。

0. 故事的起点:计算机是如何工作的?

在我们一头扎进多线程的奇妙世界之前,不妨先花几分钟,快速回顾一下我们的老朋友——计算机——是如何思考和工作的。理解了它的基本工作逻辑,我们才能更深刻地体会到,为什么多线程技术在今天如此重要。

0.1 计算机的核心:CPU与内存

我们可以将一台计算机最核心的部分,简化为两个组件:

  • 中央处理器 (CPU):计算机的“大脑”,负责执行各种计算和指令。
  • 内存 (Memory):用于临时存放指令和数据的地方。

CPU 的工作模式非常专注且纯粹,可以总结为一个不断重复的循环,称为 “取指-解码-执行”周期

  1. 取指 (Fetch):CPU 从内存中取出下一条要执行的指令。
  2. 解码 (Decode):CPU 分析这条指令,搞清楚“要做什么”以及“用什么数据做”。
  3. 执行 (Execute):CPU 真正地执行操作。

这个循环周而复始,速度快得令人难以想象(以 GHz 为单位,即每秒几十亿次),计算机就这样不知疲倦地执行着我们编写的程序。

在这里插入图片描述

0.2 伟大的管家:操作系统

如果让每个程序都直接和 CPU、内存打交道,那世界将乱作一团。想象一下,你正在听的音乐播放器可能会不小心覆盖掉你正在编写的代码,这简直是场灾难。

为了解决这个问题,操作系统 (Operating System, OS) 应运而生。它像一个无所不能的总管家,位于我们的应用程序和计算机硬件之间,其核心职责有二:

  1. 管理资源:公平、高效地分配和管理 CPU 时间、内存空间等所有硬件资源。
  2. 提供抽象:向应用程序隐藏底层硬件的复杂细节,提供一套简单、统一的接口。

0.3 进程:运行中的程序

当我们双击一个程序图标(比如 Chrome 浏览器)时,操作系统就会为这个程序创建一个 进程 (Process)

我们可以将进程理解为一个 “运行中的程序实例”。操作系统会为每个进程分配一套独立的资源,尤其是自己专属的内存空间,就好像给了它一个独立的王国。这确保了你的音乐播放器和代码编辑器虽然在同一台电脑上运行,但彼此之间的数据是隔离的,互不干扰。

因此,在操作系统的世界里,进程是资源分配的基本单位

0.4 线程:更轻量的执行者

早期的计算机,一个进程里只有一个执行任务的“工人”,如果这个工人被某个耗时的任务(比如等待网络数据)卡住了,整个进程就都得停下来,用户界面也会跟着卡死。

为了提升效率和程序的响应能力,线程 (Thread) 的概念被引入了。

我们可以把线程看作是进程这个“工厂”里的 “工人”。一个工厂(进程)可以雇佣一个或多个工人(线程)。这些工人共享工厂里的所有资源(比如同一块内存空间、同一批文件),但每个工人手里都拿着自己独立的任务清单(独立的执行序列)。

在这里插入图片描述

这样一来,分工就变得更加明确:

  • 进程:是操作系统进行 资源分配 的基本单位(工厂)。
  • 线程:是 CPU 进行 调度和执行 的基本单位(工人)。

有了这个基础,我们就可以更好地理解,为什么说线程是实现并发、提升程序性能的关键。现在,让我们正式开启 Java 多线程的学习之旅吧!


1. 初识线程(Thread)

1.1 核心概念

1.1.1 什么是线程?

我们可以将一个线程理解为一个独立的“执行流”。在这个执行流中,代码会按照既定的顺序一步步执行。当程序中存在多个线程时,就意味着有多份代码在“同时”运行,形成了并发执行的场面。

为了更好地理解这个概念,不妨再用银行办理业务的场景来打个比方。如果说单个线程就像一个人去银行办理自己的业务,那么当一家公司需要同时处理多项业务——比如财务转账、福利发放到、缴纳社保时,情况就变得复杂了。

如果只派一名会计(比如“张三”)去处理所有业务,他将不得不逐个排队,耗时漫长。为了提升效率,公司可以派出三名会计(“张三”、“李四”、“王五”),每人负责一项业务,各自取号排队。这样,就形成了三个并行的“执行流”,共同为这家公司的目标服务。

在这个场景中,多个执行流协同工作的模式就是多线程。一个大任务被分解成多个小任务,交由不同的执行流去处理。通常,最初发起任务的“张三”被称为主线程(Main Thread)

1.1.2 为什么需要线程?

在项目开发中,并发编程已成为一项“刚需”,而线程是实现并发的核心技术。其必要性主要体现在以下几点:

  • 硬件发展的驱动:随着单核 CPU 的性能提升遭遇物理瓶颈,多核 CPU 已成为计算机的标配。为了充分压榨多核处理器的计算能力,就需要采用并发编程模型,让多个任务在不同核心上并行执行。

  • 应对 I/O 阻塞:在许多应用场景中,程序需要等待外部数据(如读取文件、网络请求),这被称为 I/O 等待。在等待期间,CPU 处于空闲状态,造成资源浪费。通过并发编程,我们可以在等待的间隙让 CPU 去执行其他任务,从而显著提升程序的整体效率。

  • 线程是更轻量的选择:尽管多进程也能实现并发,但与进程相比,线程更加“轻量”。这意味着创建、销毁和调度线程的系统开销都远小于进程,使其成为实现并发的更高效、更主流的选择。

  • 追求极致效率的演进:为了进一步降低线程调度的开销,业界又发展出了“线程池(ThreadPool)”和“协程(Coroutine)”等技术,它们在特定场景下能提供更高的性能。

  • 更低的创建与切换开销:创建一个新线程的代价远比创建一个新进程小,同时线程间的上下文切换也比进程切换快得多,这使得用较低的成本实现并发成为可能。

  • 更少的资源占用:线程作为轻量级的执行单位,占用的系统资源(如内存)比进程少很多。

  • 充分利用多核CPU:对于计算密集型应用,可以将复杂的计算任务分解到多个线程中,让它们在不同的CPU核心上并行执行,最大化硬件利用率。

  • 提升I/O密集型应用性能:对于需要频繁等待慢速I/O(如磁盘读写、网络请求)的应用,可以让多个线程同时等待不同的I/O操作,实现I/O操作的重叠,避免CPU空闲,从而提升程序的响应速度和吞吐量。

小分享: 关于线程池和协程是并发编程中非常重要的高阶概念,会在后续文章中深入探讨。

1.1.3 进程与线程的核心区别(高频面试题)

这是面试中几乎必考的知识点,可以从多个维度来清晰地理解它们之间的差异:

  • 从属关系:进程是包含线程的容器。一个进程可以包含一个或多个线程,但每个线程必须属于一个进程。
  • 资源分配:进程是操作系统进行资源分配(如内存、文件句柄)的最小单位。说白了,它就像一个独立运营的公司,有自己的地盘、资金和设备。 而线程是CPU进行调度和执行的最小单位,它本身不拥有独立的资源,而是共享其所属进程的资源。
  • 内存空间:进程与进程之间拥有独立的内存地址空间,相互隔离,保证了安全性。而同一个进程内的所有线程则共享该进程的内存空间(如代码段、数据段、堆内存)。
    • 共享与独享:虽然线程共享大部分进程资源,但为了独立运行,每个线程也拥有自己独享的资源,主要包括:程序计数器(PC)、一组寄存器和栈(Stack)。这保证了每个线程有独立的执行序列和局部变量空间。
  • 通信方式:由于共享内存,同一进程内的线程间通信(数据交换)非常高效,可以直接读写共享变量。而进程间通信(IPC)则需要借助操作系统提供的机制(如管道、消息队列、共享内存等),相对复杂且开销更大。
  • 系统开销:线程的创建、切换和销毁的开销远小于进程。因此,在需要高并发的场景下,使用多线程是比多进程更经济、更高效的选择。

一个类比: 我们可以这样类比:一个工厂(进程)拥有土地、设备和原材料(资源)。工厂里的多条生产线(线程)共享这些资源,但每条生产线有自己独立的任务指令(程序计数器)和临时工具架(栈)。

在这里插入图片描述

小分享:线程间的相互影响

  • 影响性:由于共享内存,一个进程内的多个线程会相互影响。一个线程的异常崩溃可能会导致整个进程的终结。
  • 非线性提效:虽然增加线程数量通常能提升效率,但这种提升并非是线性的。当线程数量达到一定规模后,操作系统调度线程本身带来的开销也会变得非常可观,甚至可能抵消并发带来的性能优势。
1.1.4 Java 线程与操作系统线程的关系

线程本质上是操作系统层面的一个概念。操作系统内核负责实现线程机制,并向用户层提供一套 API(例如 Linux 中的 pthread 库)来进行操作。

Java 中使用的 java.lang.Thread 类,可以看作是 JVM 对这些底层操作系统 API 的一层优雅封装和抽象,它屏蔽了不同操作系统的实现差异,让开发者能以一种跨平台、面向对象的方式来使用线程。

1.2 编写第一个多线程程序

为了直观地感受多线程程序与普通单线程程序的区别,这里通过一个简单的例子来展示:

  • 每个线程都是一个独立的执行流。
  • 多个线程之间是“并发”执行的。
import java.util.Random;

public class ThreadDemo {
    private static class MyThread extends Thread {
        @Override
        public void run() {
            Random random = new Random();
            while (true) {
                // 打印当前线程的名称
                System.out.println(Thread.currentThread().getName());
                try {
                    // 随机休眠 0-9 毫秒,模拟工作负载
                    // 启动后可使用 jconsole 命令观察线程状态
                    Thread.sleep(random.nextInt(10));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        // 创建三个子线程
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();
        
        // 启动线程
        t1.start();
        t2.start();
        t3.start();
        
        // 主线程也执行同样的逻辑
        Random random = new Random();
        while (true) {
            System.out.println(Thread.currentThread().getName());
            try {
                Thread.sleep(random.nextInt(10));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

小思考:关于异常处理
在实际的软件开发中,对于 catch 到的异常,通常有几种处理策略:

  1. 记录日志:将异常信息作为日志记录下来,便于开发人员后续排查问题。程序可以继续执行,不会因此中断。
  2. 执行重试:某些异常(尤其是网络通信中)可能是偶发性的。在这种情况下,可以设计重试机制,在捕获异常后尝试重新执行操作。
  3. 立即告警:对于可能导致严重后果的异常,必须立即处理。此时可以在异常处理逻辑中触发告警机制(如发送邮件、短信),通知相关人员紧急介入。

运行结果示例:

Thread-0
Thread-0
Thread-2
Thread-1
Thread-2
main
Thread-1
Thread-0
...

小分享:关于线程调度的说明
从运行结果中可以观察到,各个线程(包括 main 主线程)的输出顺序是完全随机的,这体现了现代操作系统线程调度的核心特性——抢占式执行通俗点说,就是多个线程抢着用CPU,谁能抢到、能用多久,都由操作系统说了算,充满了不确定性。

尽管可以通过 setPriority() 方法为线程设置优先级,但这仅仅是给操作系统的一个“建议”,系统并不会严格按照优先级来分配执行时间,因此不能依赖它来精确控制线程的执行顺序

可以使用 JDK 自带的 jconsole 工具来图形化地观察 Java 进程中的线程状态。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

每个线程都有自己独立的调用栈:

在这里插入图片描述

在 IDEA 中进行多线程调试时,可以切换不同的线程上下文来观察各自的状态:

在这里插入图片描述

1.3 创建线程的几种方式

1.3.1 方法一: 继承 Thread 类

这是最直观的方式,但存在一些局限性。

  1. 自定义一个类,继承自 java.lang.Thread
  2. 重写 run() 方法,在其中定义线程需要执行的任务逻辑。
  3. 创建该子类的实例,并调用其 start() 方法来启动线程。
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("这里是线程运行的代码");
    }
}

// 使用
MyThread t = new MyThread();
t.start();  // 操作系统创建新线程,并执行 run() 方法
1.3.2 方法二: 实现 Runnable 接口

这是更被推崇、更灵活的方式。

  1. 自定义一个类,实现 java.lang.Runnable 接口。
  2. 实现 run() 方法。
  3. 创建 Thread 类的实例,并在构造时将 Runnable 对象作为参数传入。
  4. 调用 Thread 实例的 start() 方法。
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("这里是线程运行的代码");
    }
}

// 使用方式一
Thread t = new Thread(new MyRunnable());
t.start();

// 使用方式二(逻辑更清晰)
Runnable task = new MyRunnable(); // 定义一个任务
Thread thread = new Thread(task); // 创建一个线程来执行这个任务
thread.start();

小思考:实现 Runnable 接口为何是更优的选择?

对比这两种方法,实现 Runnable 接口的方式通常是更优的选择。这背后体现了软件设计中一个重要的原则:解耦合

  • 高内聚,低耦合:这是一个优秀软件设计的标志。“高内聚”指一个模块内部的各个元素联系紧密,共同完成一个功能。“低耦合”则指模块与模块之间的依赖关系要尽可能弱。
  • 任务与线程分离:通过实现 Runnable,我们将“要执行的任务(run 方法中的逻辑)”与“执行任务的载体(Thread 对象)”分离开来。Runnable 对象仅仅是一个任务,它本身可以被线程执行,也可以被其他方式调用。这种分离使得代码更加灵活,可维护性更高。如果未来决定不再通过线程来执行这个任务,修改起来会非常简单。

两种方式的对比:

  • 继承 Thread: 优点是简单直观,在 run 方法中可以直接使用 this 关键字获取当前线程对象。缺点是 Java 不支持多重继承,如果我们的类已经继承了其他类,就无法再继承 Thread
  • 实现 Runnable 接口: 优点是解耦合,更符合面向对象的设计思想,并且可以避免单继承的限制。缺点是在 run 方法中,this 指代的是 Runnable 对象本身,需要通过 Thread.currentThread() 来获取当前线程对象。
1.3.3 其他简化写法

在实际开发中,如果一个线程或任务的逻辑非常简单,且只使用一次,可以用更简洁的语法来创建。

  • 使用匿名内部类
// 1. 匿名内部类创建 Thread 子类对象
Thread t1 = new Thread() {
    @Override
    public void run() {
        System.out.println("使用匿名类创建 Thread 子类对象");
    }
};
t1.start();

// 2. 匿名内部类创建 Runnable 子类对象
Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("使用匿名类创建 Runnable 子类对象");
    }
};
Thread t2 = new Thread(task);
t2.start();
  • 使用 Lambda 表达式 (推荐)
    由于 Runnable 是一个函数式接口(只有一个抽象方法),可以使用 Lambda 表达式来极大地简化代码。
// 使用 lambda 表达式创建 Runnable 子类对象
Thread t3 = new Thread(() -> System.out.println("使用 lambda 表达式创建 Runnable 子类对象"));
t3.start();

// 如果逻辑较多,可以使用代码块
Thread t4 = new Thread(() -> {
    System.out.println("这是 lambda 写法的第一行");
    System.out.println("这是 lambda 写法的第二行");
});
t4.start();

1.4 多线程的优势:提升运算速度

在很多计算密集型的场景下,合理地运用多线程确实可以显著提升程序的整体运行效率。下面的例子将对比串行执行和并发执行在完成相同计算量时的耗时差异。

说明

  • 本示例仅为演示多核 CPU 环境下并发计算的优势。
  • 示例使用 System.nanoTime() 来获取纳秒级的时间戳,以进行更精确的耗时统计。
  • serial 方法代表串行计算,所有任务在主线程中依次完成。
  • concurrency 方法代表并发计算,将任务拆分给主线程和另一个子线程同时进行。
public class ThreadAdvantage {
    // 注意:多线程并不一定总能提高速度。当计算量(count)很小时,
    // 线程创建和调度的开销可能会超过并发执行节省的时间。
    private static final long COUNT = 10_0000_0000;

    public static void main(String[] args) throws InterruptedException {
        // 使用并发方式
        concurrency();
        // 使用串行方式
        serial();
    }

    private static void concurrency() throws InterruptedException {
        long begin = System.nanoTime();
        
        // 创建一个子线程,负责计算 a 的值
        Thread thread = new Thread(() -> {
            int a = 0;
            for (long i = 0; i < COUNT; i++) {
                a--;
            }
        });
        thread.start();
        
        // 主线程同时计算 b 的值
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        
        // 等待子线程运行结束,以确保两个计算都已完成
        thread.join();
        
        // 统计总耗时
        long end = System.nanoTime();
        double ms = (end - begin) / 1_000_000.0;
        System.out.printf("并发模式耗时: %f 毫秒%n", ms);
    }

    private static void serial() {
        long begin = System.nanoTime();
        
        // 在主线程内依次计算 a 和 b 的值
        int a = 0;
        for (long i = 0; i < COUNT; i++) {
            a--;
        }
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        
        long end = System.nanoTime();
        double ms = (end - begin) / 1_000_000.0;
        System.out.printf("串行模式耗时: %f 毫秒%n", ms);
    }
}

1.5 初探线程安全问题

通过上面的例子,我们看到了多线程在计算密集型任务上的优势。但这种优势有一个重要的前提:多个线程操作的是互不相干的数据

一旦多个线程需要对同一个共享变量进行修改,情况就变得复杂起来,可能会出现意想不到的结果。这就是多线程编程中必须面对的核心挑战——线程安全问题。

代码示例:一个线程不安全的计数器

下面的代码创建了两个线程,每个线程都对一个共享的静态变量 counter 进行 1 亿次自增操作。

public class ThreadUnsafeExample {
    private static long counter = 0;
    private static final long COUNT = 1_0000_0000;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (long i = 0; i < COUNT; i++) {
                counter++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < COUNT; i++) {
                counter++;
            }
        });

        t1.start();
        t2.start();

        // 等待两个线程都执行完毕
        t1.join();
        t2.join();

        System.out.println("预期结果: " + (2 * COUNT));
        System.out.println("实际结果: " + counter);
    }
}

可能的运行结果:

预期结果: 200000000
实际结果: 138128833

可以发现,实际结果远小于预期结果,大量的自增操作“丢失”了。

思路分析:为何更新会丢失?

问题的根源在于 counter++ 这个操作并非原子性的。在CPU层面,它至少被分解为三个独立的指令:

  1. 读 (Load): 从内存中读取 counter 的当前值到CPU的寄存器。
  2. 改 (Add): 在寄存器中,将这个值加 1。
  3. 写 (Store): 将寄存器中计算出的新值,写回内存。

正如您所分析的,这背后的原因,正是操作系统底层的抢占式调度逻辑。线程间的切换具有随机性,可以在任何一条CPU指令执行后发生。这就为数据错乱埋下了伏笔。一个典型的“更新丢失”场景可以这样描述:

  1. 假设当前 counter 在内存中的值为 0
  2. 线程t1 执行了“读”和“改”两步操作,在自己的工作内存(寄存器)中计算出了新值 1
  3. 发生线程切换! 此时 t1 还没来得及执行“写”操作,所以主内存中的 counter仍然是 0
  4. 线程t2 开始执行,它完整地执行了“读-改-写”三部曲。它从主内存读到 0,计算后得到 1,并成功将 1 写回主内存。此时主内存中的 counter 值为 1
  5. 再次发生线程切换! 轮到 t1 继续执行。
  6. 线程t1 从上次中断的地方继续,执行它最后一步“写”操作,将自己之前计算出的 1 也写回主内存。

最终,虽然两个线程都执行了自增,但 counter 的值却变成了 1,而不是预期的 2t2 线程的更新被 t1 后来的写入操作给覆盖了,导致一次更新凭空消失。当这种情况发生亿万次时,最终结果的巨大差距也就不难理解了。

这个现象,就是典型的线程安全问题,其本质是操作的非原子性与线程调度的随机性共同作用的结果。如何保证这类操作的原子性,正是后续学习并发控制(如 synchronized 关键字)的关键所在。

2. Thread 类及常见方法

Thread 类是 JVM 用来管理和操作线程的核心。我们可以这样理解,程序中的每一个线程,都有一个唯一的 Thread 对象与之对应。

这个 Thread 对象就如同每个“执行流”的一份档案,JVM 正是通过管理这些 Thread 对象,来实现对所有线程的调度和控制。

在这里插入图片描述

2.1 Thread 的常见构造方法

方法签名 说明
Thread() 创建一个空的线程对象,通常需要配合继承 Thread 类并重写 run 方法来使用。
Thread(Runnable target) 使用一个 Runnable 对象(代表任务)来创建线程,这是更被推崇的方式。
Thread(String name) 创建线程并为其指定一个名字,这对于调试和日志分析非常有帮助。
Thread(Runnable target, String name) 创建线程,同时指定要执行的任务和线程的名称。
【了解】Thread(ThreadGroup group, Runnable target) 将线程归入一个线程组进行管理,这是一个相对较少使用的功能。
// 示例
Thread t1 = new Thread();
Thread t2 = new Thread(() -> System.out.println("Hello"));
Thread t3 = new Thread("My-Awesome-Thread");
Thread t4 = new Thread(() -> System.out.println("Hello"), "My-Awesome-Thread-2");

在这里插入图片描述

在这里插入图片描述

2.2 Thread 的常见属性与方法

属性 获取方法 说明
ID getId() 线程的唯一标识符。它是一个正整数,在单个 JVM 进程中,不同线程的 ID 绝不重复。
名称 getName() 获取线程的名称,一个有意义的名称对于调试和问题排查至关重要。
状态 getState() 获取线程当前的生命周期状态,例如 RUNNABLE, BLOCKED 等。
优先级 getPriority() 获取线程的优先级(范围 1-10),默认值为 5。
是否为后台线程 isDaemon() 判断一个线程是否为后台线程(也称守护线程)。
是否存活 isAlive() 判断线程是否已经启动且尚未终止。
是否被中断 isInterrupted() 判断线程的中断标志位是否已被设置。
  • ID: 线程的唯一标识,由 JVM 内部自增生成,确保了其唯一性。
  • 名称: 如果在创建线程时没有显式指定,JVM 会为其分配一个默认名称,如 Thread-0, Thread-1 等。
  • 状态: 线程在其生命周期中会经历不同的阶段,后续章节会详细探讨这些状态。
  • 优先级: 理论上,优先级越高的线程越容易被操作系统调度执行,但这仅仅是一个“建议”,不能依赖它来保证线程的执行顺序。
  • 后台线程 (Daemon Thread): 也常被称为“守护线程”。它有一个非常特殊的性质:当一个 Java 进程中只剩下后台线程在运行时,JVM 会自动退出。一个典型的例子就是 Java 的垃圾回收(GC)线程,它就是在后台默默守护着内存的健康。

小分享:主线程结束不等于进程结束

这是多线程编程中一个非常关键的概念。在单线程程序中,main 方法的结束标志着进程的终结。然而,在多线程的世界里,规则发生了变化。main 方法的结束仅仅意味着主线程走到了终点,但只要程序中还存在任何一个非后台线程(non-daemon thread)在运行,JVM 进程就不会退出。

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (true) {
            System.out.println("hello T1, 我是后台线程");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 后台线程中,这类异常通常可以直接打印或记录日志,因为它的生命周期由其他线程决定
                e.printStackTrace();
            }
        }
    });
    // 关键操作:必须在 start() 方法调用之前设置
    // 将 t1 设置为后台线程后,它的存在将无法阻止整个进程的结束
    t1.setDaemon(true);
    t1.start();
    
    // 主线程(这是一个非后台线程)只工作 3 秒
    for (int i = 0; i < 3; i++) {
        System.out.println("hello main");
        Thread.sleep(1000);
    }
    System.out.println("main 线程结束");
    // 当 main 线程结束后,进程中已无非后台线程,JVM 随即退出,t1 线程也随之终止。
}
  • 是否存活 (isAlive): 这个方法可以用来判断一个线程当前是否处于活动状态。简单来说,只要线程已经启动(start() 已被调用)且其 run 方法还没有执行完毕,isAlive() 就会返回 true

小思考:Thread 对象生命周期 vs 系统线程生命周期

这里有一个值得注意的细节:Java 中的 Thread 对象与操作系统底层的内核线程是一一对应的,但它们的生命周期并不同步。当一个线程的 run 方法执行完毕后,操作系统会销毁对应的内核级线程,但堆内存中的那个 Thread 对象可能仍然存在(只要还有引用指向它)。

因此,isAlive() 返回 false 准确地告诉我们底层的系统线程已经结束了,但 Thread 对象本身可能还未被垃圾回收器回收。

public static void main(String[] args) throws InterruptedException {
    Thread thread = new Thread(() -> {
        for (int i = 0; i < 3; i++) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // run 方法即将执行完毕,底层的系统线程也即将被销毁
    });
    
    System.out.println("线程启动前, isAlive: " + thread.isAlive()); // false
    thread.start();
    System.out.println("线程启动后, isAlive: " + thread.isAlive()); // true
    
    // 等待 thread 线程执行完毕
    thread.join();
    System.out.println("线程结束后, isAlive: " + thread.isAlive()); // false
}
  • 线程的中断: 这是一种用于优雅地请求线程停止的关键机制,下文会进行详细的说明。

下面的示例将集中演示上述这些属性和方法的用法:

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ": 我还活着...");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + ": 我即将结束。");
        }, "演示线程");

        System.out.println("--- 线程启动前 ---");
        System.out.println(thread.getName() + " | ID: " + thread.getId());
        System.out.println(thread.getName() + " | 状态: " + thread.getState());
        System.out.println(thread.getName() + " | 优先级: " + thread.getPriority());
        System.out.println(thread.getName() + " | 是否为后台线程: " + thread.isDaemon());
        System.out.println(thread.getName() + " | 是否存活: " + thread.isAlive());
        System.out.println(thread.getName() + " | 是否被中断: " + thread.isInterrupted());
        
        thread.start();
        
        System.out.println("\n--- 线程运行中 ---");
        // 通过循环等待的方式,观察线程在运行过程中的状态
        while (thread.isAlive()) {
            System.out.println(thread.getName() + " | 状态: " + thread.getState());
            Thread.sleep(500); // 缩短等待时间,更频繁地检查状态
        }
        
        System.out.println("\n--- 线程结束后 ---");
        System.out.println(thread.getName() + " | 状态: " + thread.getState());
        System.out.println(thread.getName() + " | 是否存活: " + thread.isAlive());
    }
}

2.3 启动一个线程:start() 方法

我们已经知道,通过重写 run 方法可以定义线程需要执行的任务。但这仅仅是创建了一个包含“行动指南”的 Thread 对象,线程本身并不会立即运行。

我们可以用一个生动的比喻来理解这个过程:

  • 重写 run 方法,相当于为员工“李四”准备好了一份详细的工作说明书
  • new Thread(),相当于把“李四”这位员工招了过来。
  • 调用 start() 方法,才相当于对“李四”下达指令:“开始工作吧!”。只有在这一刻,操作系统才会真正创建一个新的线程,并让它开始独立执行 run 方法中定义的任务。

核心要点

  • 调用 start() 方法,才是在操作系统层面真正创建出了一个线程。这是一个从用户态到内核态的调用过程。
  • 一个 Thread 对象只能调用一次 start() 方法。如果尝试对一个已经启动或者已经结束的线程再次调用 start(),程序将会抛出 IllegalThreadStateException 异常。

在这里插入图片描述

在这里插入图片描述

小思考:start()run() 的核心区别(高频面试题)

这是一个初学者极易混淆,同时也是面试中频繁出现的问题。调用 start() 和直接调用 run() 会产生截然不同的结果。

  • t.start(): 这是启动一个新线程的正确方式。它的核心作用是:

    1. 向操作系统申请创建一个新的线程。
    2. 新线程创建成功后,它会自动调用 run() 方法,使其在新线程的上下文中独立执行。
    3. start() 方法本身会立即返回,不会等待 run() 方法执行完毕。
      结果:主线程和新线程并发执行,实现了多线程。
  • t.run(): 这仅仅是调用了一个普通的方法。它的核心作用是:

    1. 不会创建任何新线程。
    2. 直接在当前线程(调用 t.run() 的那个线程)中,像执行一个普通函数一样,执行 run() 方法体内的代码。
    3. run() 方法执行完毕后,调用才会返回。
      结果:程序依然是单线程的,所有代码按顺序串行执行。

代码示例:

public class StartVsRun {
    public static void main(String[] args) {
        // --- 使用 start() ---
        Thread t1 = new Thread(() -> {
            System.out.println("start() -> run() 在 " + Thread.currentThread().getName() + " 线程中执行");
        }, "子线程-T1");
        
        System.out.println("main 线程中,调用 t1.start()");
        t1.start(); // 异步执行

        // --- 直接调用 run() ---
        Thread t2 = new Thread(() -> {
            System.out.println("run() 在 " + Thread.currentThread().getName() + " 线程中执行");
        }, "子线程-T2");

        System.out.println("main 线程中,调用 t2.run()");
        t2.run(); // 同步执行

        System.out.println("main 线程结束");
    }
}

可能的输出:

main 线程中,调用 t1.start()
main 线程中,调用 t2.run()
run() 在 main 线程中执行
main 线程结束
start() -> run() 在 子线程-T1 线程中执行

思路分析 从输出可以看到,t2.run() 是在 main 线程中同步执行的,它的输出严格位于两条打印语句之间。而 t1.start() 启动的子线程是并发执行的,它的输出时机不确定,但可以肯定它是在 子线程-T1 中执行的。

总结: 只有调用 start() 方法,才能真正达到多线程的目的。直接调用 run(),无异于缘木求鱼。

2.4 中断一个线程

一旦线程开始执行,它就会像一个勤恳的员工,按照 run 方法的指令清单一直工作下去,直到任务完成。但在现实世界的应用中,常常需要一种机制来优雅地通知一个正在运行的线程“是时候停下来了”。

沿用之前的比喻:如果员工“李四”正在专心处理一笔转账业务,老板突然来电说对方账户涉嫌欺诈,必须立即停止交易。那么,作为主线程的“张三”,该如何有效地通知“李四”中断当前任务呢?这就是接下来要深入探讨的线程中断机制。

目前,实现线程优雅中断的主流方式主要有两种:

  1. 通过一个共享的、被 volatile 修饰的布尔标记位来进行沟通。
  2. 调用目标线程的 interrupt() 方法来发送一个标准的中断信号。

示例:通过自定义标志位中断线程

public class InterruptDemo1 {
    // 使用 volatile 关键字确保标志位在多线程之间的可见性,后续会详细解释其原理
    private static volatile boolean isFinished = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (!isFinished) {
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // 在这种模式下,如果线程在 sleep 时被中断,isFinished 标志位可能还未被设置为 true
                    // 此时循环可能不会如预期般退出,因此这不是最稳妥的中断方式
                    e.printStackTrace();
                }
            }
            System.out.println("thread 正常结束");
        });
        t.start();

        // 让子线程先运行 3 秒
        Thread.sleep(3000);

        // 在主线程中修改标志位,以此来通知子线程结束运行
        System.out.println("准备终止子线程...");
        isFinished = true;
    }
}

小思考:为什么不能使用局部变量作为中断标志?

这里可能会有一个疑问:能否用一个定义在 main 方法中的局部变量 isFinished 来控制线程呢?答案是不行的,这背后涉及到 Java 中 Lambda 表达式的“变量捕获”(Variable Capture)机制。

  1. 什么是变量捕获? 当我们在 Lambda 表达式内部访问其外部作用域的局部变量时,就发生了“变量捕获”。由于 Lambda 表达式(可以看作一个回调函数)的执行时机是不确定的,它很可能在外部方法执行完毕、局部变量已经被销毁之后才被执行。
  2. Java 的解决方案:值拷贝 为了解决这个潜在问题,Java 规定,当 Lambda 表达式捕获一个局部变量时,它实际上是把这个变量的值拷贝了一份给自己使用。这意味着 Lambda 内部操作的是一个副本,而不是外部的原始变量。
  3. 为什么不能修改? 正因为 Lambda 内部持有的是一个副本,如果在其中修改它,也无法影响到外部的原始变量,这会让代码的行为变得混乱且难以预测。因此,Java 编译器强制规定,被 Lambda 捕获的局部变量必须是 final 或者“事实上的 final”(Effectively Final,即初始化后从未被修改过)。这个规则从根本上杜绝了使用局部变量在线程间进行通信的可能性。

在这里插入图片描述

如何解决?
正确的做法是,将 isFinished 声明为外部类的成员变量(例如上例中的 static 静态成员变量)。这样,Lambda 表达式(其本质是一个内部类)就可以直接访问外部类的成员。此时,所有线程访问和修改的都是同一个共享变量,也就不存在变量捕获和值拷贝的问题了。

小贴士:如何获取当前线程的引用?

run 方法内部,如何才能获取到当前正在执行代码的这个线程对象本身的引用呢?一个常见的误区是尝试在 Runnable 对象上直接调用 Thread 类的方法,这是行不通的。

正确的方式是使用静态方法 Thread.currentThread()。这个方法非常特殊,无论在何处被调用,它总能准确地返回当前正在执行代码的那个线程的 Thread 对象引用。

public static void main(String[] args) {
    Thread t = new Thread(()->{
        // 这段代码将在 t 线程中执行,所以 currentThread() 返回的是 t 的引用
        System.out.println("t 线程中: " + Thread.currentThread().getName());
    }, "MyThread-0");
    t.start();
    
    // 这段代码在 main 线程中执行,所以 currentThread() 返回的是 main 线程的引用
    System.out.println("main 线程中: " + Thread.currentThread().getName());
}
// 可能的输出:
// main 线程中: main
// t 线程中: MyThread-0

小分享:interrupt() 如何与 sleep 交互?

t.interrupt() 方法的作用并不仅仅是简单地设置一个布尔标志位。它还有一个更强大的功能:它可以唤醒那些因为调用 sleepwaitjoin 等方法而进入阻塞状态的线程。

当一个正在 sleep 的线程被其他线程调用了 interrupt() 方法时,它会立即被唤醒,同时 sleep 方法会抛出一个 InterruptedException 异常。值得注意的是,在抛出这个异常的同时,JVM 会自动清除该线程的中断标志位(将其重置为 false)。

观察下面的代码:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        // isInterrupted() 用于检查中断标志位,但不会清除它
        while (!Thread.currentThread().isInterrupted()) {
            System.out.println("hello thread");
            try {
                // 线程在此处休眠,等待被中断
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 当 main 线程调用 t.interrupt() 时,sleep 会被唤醒并立即进入 catch 块
                System.out.println("收到了中断信号,准备退出...");
                // 由于中断标志位在抛出异常时被清除了,
                // 如果想让外层的 while 循环能够正确判断并退出,就需要在这里进行处理。
                // 方案一:直接使用 break 退出循环
                break;
                // 方案二:重新设置中断标志位,让 while 条件来判断
                // Thread.currentThread().interrupt();
            }
        }
        System.out.println("线程结束");
    });
    t.start();
    Thread.sleep(3000);
    System.out.println("main 线程尝试终止 t 线程");
    t.interrupt();
}

在这个例子中,t.interrupt() 不仅设置了中断状态,更重要的是它提前结束了 Thread.sleep(1000) 的等待,使得线程能够立即响应中断请求。因此,在 catch 块中正确地处理 InterruptedException,是实现优雅中断的关键所在。

在这里插入图片描述

示例-1: 使用自定义的变量来作为标志位

public class ThreadDemo {
    private static class MyRunnable implements Runnable {
        // 使用 volatile 确保 isQuit 变量在多线程间的可见性
        public volatile boolean isQuit = false;
        @Override
        public void run() {
            while (!isQuit) {
                System.out.println(Thread.currentThread().getName()
                        + ": 别管我,我忙着转账呢!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName()
                    + ": 啊!险些误了大事");
        }
    }
    public static void main(String[] args) throws InterruptedException {
        MyRunnable target = new MyRunnable();
        Thread thread = new Thread(target, "李四");
        System.out.println(Thread.currentThread().getName()
                + ": 让李四开始转账。");
        thread.start();
        Thread.sleep(5 * 1000);
        System.out.println(Thread.currentThread().getName()
                + ": 老板来电话了,得赶紧通知李四对方是个骗子!");
        target.isQuit = true;
    }
}

示例-2: 使用 Thread.interrupted()Thread.currentThread().isInterrupted() 代替自定义标志位

Thread 类内部已经为我们提供了一个 boolean 类型的变量,作为线程是否被中断的标记。可以通过一系列标准方法来操作和检查这个标记。

方法签名 说明
public void interrupt() 中断此线程。如果线程正在因 sleep, wait, join 等方法而阻塞,则会抛出 InterruptedException 并清除中断状态;否则,仅仅是设置中断状态。
public static boolean interrupted() 检查并清除当前线程的中断状态。这是一个静态方法,它会返回当前线程的中断状态,然后将其重置为 false
public boolean isInterrupted() 仅检查此线程的中断状态,但不清除。这是一个实例方法,用于查询指定线程对象的中断标志位。

当调用 thread.interrupt() 方法来通知线程结束时,目标线程 thread 收到通知的方式主要有两种:

  1. 如果线程当前正因为调用 wait(), join(), sleep() 等方法而阻塞挂起,那么它会立即被唤醒,并抛出一个 InterruptedException 异常。此时,线程的中断标志会被自动清除。在 catch 块中,需要根据业务逻辑决定是忽略异常、立即退出循环,还是重新设置中断状态。
public class ThreadDemo {
    private static class MyRunnable implements Runnable {
        @Override
        public void run() {
            // 检查当前线程的中断状态
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println(Thread.currentThread().getName()
                                + ": 别管我,我忙着转账呢!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // 打印异常信息,这在调试中很有用
                    e.printStackTrace();
                    System.out.println(Thread.currentThread().getName()
                                    + ": 有内鬼,终止交易!");
                    // 因为 sleep 抛出异常后会清除中断状态,
                    // 为了让外层 while 循环能够正确退出,需要在这里进行处理。
                    // 方案一:重新设置中断状态,让循环条件来判断
                    Thread.currentThread().interrupt(); 
                    // 方案二:直接使用 break 退出循环
                    // break;
                }
            }
            System.out.println(Thread.currentThread().getName()
                    + ": 啊!险些误了大事");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new MyRunnable(), "李四");
        System.out.println(Thread.currentThread().getName()
                + ": 让李四开始转账。");
        thread.start();
        Thread.sleep(5 * 1000);
        System.out.println(Thread.currentThread().getName()
                + ": 老板来电话了,得赶紧通知李四对方是个骗子!");
        thread.interrupt();
    }
}
  1. 如果线程当前处于正常的运行状态(没有被阻塞),那么调用 interrupt() 只会将其内部的中断标志位设置为 true。线程可以周期性地通过以下方法来检查这个标志:
    • Thread.interrupted(): 检查当前线程的中断状态,并立即清除该状态(重置为 false)。
    • Thread.currentThread().isInterrupted(): 检查指定线程的中断状态,但不会改变该状态
      这种方式使得中断信号可以被更及时地捕获,即使线程正在执行密集的计算任务,也能在循环的下一次迭代中响应中断。

示例-3: 观察中断标志位是否被清除

我们可以把中断标志位想象成一个带复位功能的开关:

  • Thread.interrupted() 相当于按下一个开关来检查状态,检查完后开关会自动弹起来(复位)。这个过程就称为 “清除标志位”。
  • Thread.currentThread().isInterrupted() 则相当于按下一个开关来检查状态,但检查后开关保持原样,不会自动弹起。这个过程就是 “不清除标志位”。

使用 Thread.interrupted(),中断标志位在检查后会被清除。

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                // Thread.interrupted() 检查的是当前线程(也就是这个 lambda 所在的线程)的中断状态
                // 第一次检查时为 true,之后因为标志位被清除了,所以后续循环中都为 false
                System.out.println(Thread.currentThread().getName() + ": " + Thread.interrupted());
            }
        }, "李四");
        thread.start();
        // 确保子线程已启动
        Thread.sleep(10); 
        thread.interrupt(); // 中断的是 thread (李四) 线程
    }
}

输出:

李四: true
李四: false
李四: false
李四: false
李四: false

使用 Thread.currentThread().isInterrupted(),中断标志位在检查后不会被清除。

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                // isInterrupted() 只检查不清除,所以每次检查结果都一样
                System.out.println(Thread.currentThread().getName() + ": " + Thread.currentThread().isInterrupted());
            }
        }, "李四");
        thread.start();
        Thread.sleep(10);
        thread.interrupt();
    }
}

输出:

李四: true
李四: true
李四: true
李四: true
李四: true

2.5 等待一个线程:join()

在某些场景下,一个线程需要等待另一个线程完成其工作后,才能继续执行自己的后续任务。例如,主线程需要等待一个计算子线程得出结果后,才能利用这个结果进行下一步处理。join() 方法正是为了满足这种线程间的同步需求而设计的。

方法签名 说明
public final void join() 无限期地等待,直到该线程执行完毕。
public final void join(long millis) 等待该线程结束,但最多只等待 millis 毫秒。如果超时,将不再等待。
public final void join(long millis, int nanos) 功能同上,但允许更精确地指定纳秒级的等待时间。
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 3; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() 
                                        + ": 我还在工作!");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + ": 我结束了!");
        };
        Thread thread1 = new Thread(task, "李四");
        Thread thread2 = new Thread(task, "王五");
        
        System.out.println("先让李四开始工作");
        thread1.start();
        // main 线程在此处阻塞,等待 thread1 执行结束
        thread1.join();
        
        System.out.println("李四工作结束了,现在让王五开始工作");
        thread2.start();
        // main 线程再次阻塞,等待 thread2 执行结束
        thread2.join();
        
        System.out.println("王五也工作结束了,主线程任务完成");
    }
}

小分享:如果将代码中的两个 join() 调用注释掉,程序的执行流程和输出结果会发生怎样的变化呢?

思路分析:如果去掉 join(),主线程将不会等待 thread1thread2。它会立即打印 “先让李四开始工作”,启动 thread1,然后不等 thread1 结束就立刻打印 “李四工作结束了…”,再启动 thread2,最后打印 “王五也工作结束了…”。与此同时,thread1thread2 会在后台并发执行。最终的输出顺序将是交织在一起的,且主线程的语句会最先执行完毕。

附录: 关于 join 方法背后的一些实现细节,会在后续的文章中进一步探讨。

2.6 获取当前线程引用

这个静态方法是获取当前执行上下文线程对象的标准方式。

方法签名 说明
public static Thread currentThread() 返回当前正在执行代码的线程对象的引用。
public class ThreadDemo {
    public static void main(String[] args) {
        // 在 main 方法中调用,获取到的是主线程的引用
        Thread thread = Thread.currentThread();
        System.out.println("当前线程名: " + thread.getName()); // 输出 "main"
    }
}

2.7 休眠当前线程

sleep 方法可以让当前线程暂停执行一段指定的时间。需要注意的是,由于线程调度存在不确定性,该方法只能保证实际的休眠时间大于或等于参数所设定的休眠时间。

方法签名 说明
public static void sleep(long millis) throws InterruptedException 使当前线程休眠 millis 毫秒。
public static void sleep(long millis, int nanos) throws InterruptedException 功能同上,但允许更高精度的纳秒级休眠。

关于 sleep,在讨论线程状态时还会补充更多的知识点。

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("当前时间戳: " + System.currentTimeMillis());
        Thread.sleep(3 * 1000); // 休眠 3 秒
        System.out.println("3秒后时间戳: " + System.currentTimeMillis());
    }
}

3. 线程的状态

3.1 线程生命周期概览

在 Java 中,一个线程从创建到消亡,会经历一系列不同的状态。这些状态被清晰地定义在枚举类型 Thread.State 中。理解这些状态是诊断多线程问题、进行性能调优的基础。

  • NEW (新建): 线程对象已被创建,但尚未调用 start() 方法。此时它只是一个普通的 Java 对象,操作系统还未为其分配真正的线程资源。
  • RUNNABLE (可运行): 这是线程生命周期中最核心的状态。它又可细分为两种子状态:
    • Ready (就绪): 线程已经准备好运行,正在等待 CPU 分配执行时间片。可以想象成在就诊室外排队等待叫号的病人。
    • Running (运行中): 线程已经获得了 CPU 时间片,正在执行 run() 方法中的代码。相当于病人正在被医生诊治。
  • BLOCKED (阻塞): 线程正在等待获取一个“监视器锁”(通常是进入一个 synchronized 同步块或方法),但该锁目前被其他线程持有。
  • WAITING (无限期等待): 线程正在无限期地等待另一个线程执行某个特定操作(如 notify()notifyAll())。例如,调用了 Object.wait()Thread.join() 且未设置超时。
  • TIMED_WAITING (限时等待): 与 WAITING 类似,但线程的等待有时间限制。例如,调用了 Thread.sleep(long) 或设置了超时的 wait(long)join(long)
  • TERMINATED (终止): 线程的 run() 方法已经执行完毕,线程的生命周期走到了尽头。
public class ThreadState {
    public static void main(String[] args) {
        // 遍历并打印出所有的线程状态
        System.out.println("Java 中定义的线程状态:");
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
        }
    }
}

3.2 线程状态转移图解析

在这里插入图片描述

初看这张状态图可能会觉得有些复杂,不必一次性记住所有细节,核心是理解每个状态的含义以及触发状态变化的关键操作。

再次回到银行办业务的例子:

在这里插入图片描述

  • NEW: 为员工“李四”分配好了任务,但他还没出发。此刻,他处于新建状态。
  • RUNNABLE: “李四”调用 start() 方法后出发,到达银行开始排队取号。无论他是正在排队(Ready)还是正在柜台办理业务(Running),他都处于可运行状态,因为他随时可以被 CPU(银行职员)调度。
  • BLOCKED / WAITING / TIMED_WAITING: 在办理业务时,“李四”可能需要等待。
    • 如果他要进入的 synchronized 柜台有人,他就在外面排队等待锁,这时是 BLOCKED
    • 如果他需要等待另一个同事送材料过来(等待 notify),他就会在等待区无限期等待,这是 WAITING
    • 如果他只是需要填写一张表格,决定先“摸鱼”5分钟(sleep(300000)),这就是 TIMED_WAITING
  • TERMINATED: “李四”办完了所有业务,run() 方法执行结束,他进入了终止状态。

基于此,之前学过的 isAlive() 方法就更容易理解了:只要一个线程的状态不是 NEWTERMINATED,它就被认为是“存活”的。

3.3 观察线程状态的动态变化

在这里插入图片描述

场景一:观察 NEW -> RUNNABLE -> TERMINATED 的转换

通过 getState() 方法可以直接观察线程从创建、运行到终止的整个过程。

public class ThreadStateTransfer {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            // 执行一个密集的空循环来模拟工作负载,使其在 RUNNABLE 状态停留足够长的时间
            for (long i = 0; i < 1000_0000_000L; i++) {
            }
        }, "李四");
        
        // 在 start() 调用前,线程处于 NEW 状态
        System.out.println(t.getName() + " (启动前): " + t.getState()); // NEW
        
        t.start();
        
        // 线程启动后,只要它还存活,其状态就在 RUNNABLE 和其他等待状态间切换
        while (t.isAlive()) {
            // 在密集的计算中,大概率会观察到 RUNNABLE 状态
            System.out.println(t.getName() + " (运行中): " + t.getState()); // RUNNABLE
            Thread.sleep(200); // 短暂休眠,避免打印过多信息
        }
        
        // 线程结束后,状态变为 TERMINATED
        System.out.println(t.getName() + " (结束后): " + t.getState()); // TERMINATED
    }
}

场景二:观察 BLOCKED, WAITING, TIMED_WAITING 状态

可以借助 jconsole 或其他 JVM 诊断工具来更清晰地观察这几种等待状态,也可以通过代码来打印状态。

下面的代码中,t1 线程获取 object 锁后,通过 sleep 方法进入 TIMED_WAITING 状态。而 t2 线程因为无法获取到同一把锁,将进入 BLOCKED 状态。

public class ThreadStateTransfer2 {
    public static void main(String[] args) throws InterruptedException {
        final Object object = new Object();
        
        // t1 线程:获取锁后限时等待
        Thread t1 = new Thread(() -> {
            synchronized (object) {
                System.out.println("t1 获取到锁,进入 TIMED_WAITING 状态");
                try {
                    // sleep 不会释放锁
                    Thread.sleep(1000 * 60 * 60);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();

        // t2 线程:因无法获取锁而阻塞
        Thread t2 = new Thread(() -> {
            System.out.println("t2 准备获取锁...");
            synchronized (object) {
                // 这行代码在 t1 释放锁之前不会被执行
                System.out.println("t2 终于获取到锁了");
            }
        }, "t2");
        t2.start();

        // 主线程等待一小段时间,确保 t1 和 t2 都已启动并进入相应状态
        Thread.sleep(100); 
        System.out.println("t1 state: " + t1.getState()); // TIMED_WAITING
        System.out.println("t2 state: " + t2.getState()); // BLOCKED
    }
}

现在,将 t1 中的 sleep 换成 waitwait 方法会释放锁,因此 t1 将进入 WAITING 状态,而 t2 则能顺利获取锁并执行完毕。

public class ThreadStateTransfer3 {
    public static void main(String[] args) throws InterruptedException {
        final Object object = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (object) {
                try {
                    System.out.println("t1 获取到锁,即将进入 WAITING 状态并释放锁");
                    // [校正笔记:原文此处代码有误,若t2不执行notify,t1将无法退出。为演示状态已修改]
                    object.wait(); 
                    System.out.println("t1 被唤醒");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();
        
        Thread.sleep(100); // 确保 t1 先拿到锁并进入 wait
        
        Thread t2 = new Thread(() -> {
            synchronized(object) {
                System.out.println("t2 获取到锁,准备唤醒 t1");
                object.notify();
                System.out.println("t2 唤醒 t1 完毕,即将释放锁");
            }
        }, "t2");
        t2.start();
    }
}

结论小结:

  • BLOCKED: 明确表示线程正在等待进入一个 synchronized 区域,即等待获取锁
  • WAITING / TIMED_WAITING: 表示线程正在等待某个条件发生,通常是等待其他线程发来通知notify/notifyAll)。TIMED_WAITING 额外设置了等待的超时时间,而 WAITING 则是无限期等待。

场景三:yield() 方法——无私地让出 CPU

yield() 是一个静态方法,它向线程调度器发出一个“建议”:当前线程愿意让出 CPU 的使用权,给其他同优先级的线程一个执行的机会。

public class YieldExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            int count = 0;
            while (count < 100) {
                System.out.println("张三");
                count++;
                // 取消或保留此行注释,观察执行结果的变化
                Thread.yield();
            }
        }, "张三");
        t1.start();

        Thread t2 = new Thread(() -> {
            int count = 0;
            while (count < 100) {
                System.out.println("李四");
                count++;
            }
        }, "李四");
        t2.start();
    }
}

实验现象:

  1. 不使用 yield() 时,“张三” 和 “李四” 的输出频率大致相当,呈现出随机交替执行的特点。
  2. 当 “张三” 线程调用 yield() 后,会发现 “张三” 的输出频率可能会明显低于 “李四”,因为它每次打印后都主动请求重新调度。

结论小结:
yield() 方法不会改变线程的状态(线程仍然处于 RUNNABLE 状态),它只是将当前线程从“运行中”状态切换回“就绪”状态,让它重新回到线程调度队列中去排队。这是一种“高风亮节”的行为,但它仅仅是一个提示,调度器可以选择忽略它。
结论小结:

  • BLOCKED: 明确表示线程正在等待进入一个 synchronized 区域,即等待获取锁
  • WAITING / TIMED_WAITING: 表示线程正在等待某个条件发生,通常是等待其他线程发来通知notify/notifyAll)。TIMED_WAITING 额外设置了等待的超时时间,而 WAITING 则是无限期等待。

场景三:yield() 方法——无私地让出 CPU

yield() 是一个静态方法,它向线程调度器发出一个“建议”:当前线程愿意让出 CPU 的使用权,给其他同优先级的线程一个执行的机会。

public class YieldExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            int count = 0;
            while (count < 100) {
                System.out.println("张三");
                count++;
                // 取消或保留此行注释,观察执行结果的变化
                Thread.yield();
            }
        }, "张三");
        t1.start();

        Thread t2 = new Thread(() -> {
            int count = 0;
            while (count < 100) {
                System.out.println("李四");
                count++;
            }
        }, "李四");
        t2.start();
    }
}

实验现象:

  1. 不使用 yield() 时,“张三” 和 “李四” 的输出频率大致相当,呈现出随机交替执行的特点。
  2. 当 “张三” 线程调用 yield() 后,会发现 “张三” 的输出频率可能会明显低于 “李四”,因为它每次打印后都主动请求重新调度。

结论小结:
yield() 方法不会改变线程的状态(线程仍然处于 RUNNABLE 状态),它只是将当前线程从“运行中”状态切换回“就绪”状态,让它重新回到线程调度队列中去排队。这是一种“高风亮节”的行为,但它仅仅是一个提示,调度器可以选择忽略它。