Java并发编程——线程

本文最后更新于:4 年前

引言

在现代软件开发中,如何充分利用多核 CPU 并行执行能力并确保高并发下的程序正确性,是开发者必须掌握的核心技能。Java 语言自诞生之初便在语言层面提供了线程支持,并不断完善其并发工具库。本文将带领读者从进程与线程的基本概念出发,逐步探讨并发与并行的区别以及 Java 中多线程的创建方式;随后介绍 JVM 线程结构、常用的并发 API 和线程状态转换;最后结合竞态条件、临界区等概念,为读者理解和应用并发编程奠定坚实基础。

进程与线程

进程(Process)

进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。

  • 进程拥有独立的地址空间,一个进程崩溃后,在保护模式下不会影响到其他进程。
  • 进程间通信(IPC)需要依靠操作系统提供的机制(如管道、信号、套接字等)来实现。
  • 比线程拥有更高的创建和管理开销。

线程(Thread)

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。在 Java 中,线程由 Thread 类及其实现的 Runnable 接口来表示和控制。

  • 线程共享其父进程的数据段(变量和堆空间)使得线程之间的通信更容易。
  • 每个线程拥有自己的程序计数器(PC)、栈和局部变量。
  • 线程切换的开销比进程小得多。

进程与线程

对比

进程 线程
内存和资源 各自独立的内存地址空间,资源不共享,安全性更高但通信更复杂。 共享同一进程中的内存和资源,通信更为方便。
开销 创建和销毁进程的开销较大,上下文切换比线程慢。 创建和销毁线程的开销较小,上下文切换速度快。
安全性 一个进程崩溃不会直接影响到其他进程。 一个线程崩溃可能影响同一进程内的其他线程。
适用场景 适合执行相对独立、需要隔离的任务。 适合执行相互间需要频繁通信或共享大量数据的任务。

在 Java 程序设计中,线程的使用远比进程常见,因为 Java 自身并不直接支持进程的创建,而是通过操作系统的功能(如通过 RuntimeProcessBuilder 类)间接处理。

并发与并行

并发(Concurrency)

并发是指系统能够处理多个任务的能力,这些任务可能不会实际上同时执行,但从宏观上看,它们似乎是同时进行的。在并发模型中,一个处理器在同一时间点只执行一个任务,但由于任务之间频繁切换,给用户留下了多个任务同时进行的印象。

  • 并发通常用于单核或少核心的处理器。
  • 任务可以共享相同的资源,如 CPU 或内存。
  • 更多地侧重于任务切换。

并发运行

并行(Parallelism)

并行是指多个任务在多个处理器上实际同时执行。在并行模型中,每个核心在同一时间处理不同的任务,从而实现真正的同时处理。

  • 并行需要多核处理器才能实现。
  • 每个任务运行在自己的处理器上,可以减少资源竞争。
  • 更多地侧重于真正的同时执行。

并行运行

对比

并发 并行
执行方式 单核执行多任务,通过任务切换实现多任务处理。 多核同时执行多任务,每个核心处理不同任务。
资源需求 可以在资源有限的情况下实现,因为它不依赖于物理核心数。 需要足够的处理器核心,适用于资源充足的情况。
效率 效率受限于任务切换和单核处理能力。 能够显著提高处理效率,尤其是在大规模数据处理和计算密集型任务中。
使用场景 适用于响应多任务的服务器或应用,如 Web 服务器处理多个请求。 适用于大规模科学计算、图像处理、大数据分析等需要高度计算资源的场景。

在软件开发和系统设计中,选择并行或并发取决于应用需求、硬件资源和预期的性能目标。在多核心处理器普及的今天,开发者经常会将并发和并行结合使用,以获得最佳的性能优势。

创建线程

继承 Thread 类

通过继承 Thread 类来创建线程是实现多线程的一种基本方式。

步骤

  1. 继承 Thread 类:定义一个类继承自 Thread 类。Thread 类本身实现 Runnable接口,因此继承 Thread 类意味着该子类也是一个 Thread
  2. 重写 run() 方法:线程的任务是在 run() 方法中指定的。当线程启动时,它将执行这个方法中的代码。因此,需要在类中重写 run() 方法,将任务逻辑放在这里。
  3. 创建和启动线程:创建 Thread 子类的线程对象,调用线程对象的 start() 方法来启动线程,这将导致线程的 run() 方法被执行。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
public class ThreadTestClient extends Thread {

public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
MyThread t = new MyThread();
t.start();
}
}

public static class MyThread extends Thread {
@Override
public void run() {
log.info("Thread name: {}", Thread.currentThread().getName());
}
}

}

特点

优点

  • 简单直观:直接继承 Thread 类,代码易于理解。
  • 控制直接:因为直接操作线程对象,对线程类的方法和行为控制更为直接。

缺点

  • 灵活性较差:Java 不支持多重继承,如果类已经继承了另一个类,就不能再继承 Thread 类。
  • 耦合性高:线程与任务逻辑耦合,设计不灵活。

实现 Runnable 接口

通过实现 Runnable 接口来创建线程是一种非常常见且灵活的方式。这种方法使得线程的任务可以与线程的控制分离开来,提高了代码的复用性和灵活性。

步骤

  1. 实现 Runnable 接口:定义一个类实现 Runnable 接口。该接口只有一个方法 run(),需要在类中实现这个方法。run() 方法将包含线程执行时需要完成的任务。
  2. 定义 run() 方法:在类中重写 run() 方法,并在此方法中定义具体的任务。当线程启动时,它会执行这个方法中的代码。
  3. 创建 Thread 对象Runnable 是一个接口,不能直接创建线程,因此需要创建一个 Thread 类的实例,并将 Runnable 类的实例作为参数传给 Thread 的构造器。
  4. 启动线程:使用 Thread 类的实例调用 start() 方法来启动线程。这会创建一个新的线程,并在这个新线程中调用 Runnable 对象的 run() 方法。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j
public class RunnableTestClient {

public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
}

public static class MyRunnable implements Runnable {
@Override
public void run() {
log.info("Thread name: {}", Thread.currentThread().getName());
}
}
}

特点

优点

  • 灵活性高:因为 Runnable 可以被多个线程共享,所以多个线程可以执行相同的任务。
  • 避免 Java 单继承限制:实现 Runnable 接口的类还可以继承其他类,更符合面向对象设计原则。
  • 适合资源共享:多个线程可以操作相同的 Runnable 实例上的资源,便于管理共享数据。

缺点

  • 直接控制线程能力较弱:因为 Runnable 接口只定义了 run() 方法,对线程本身的控制能力不如直接继承 Thread 类。
  • 资源消耗更多:启动线程需要额外创建 Thread 对象。

实现Callable接口

通过实现 Callable 接口并结合使用 Future,可以创建有返回结果并能抛出异常的任务。这种方式相比于使用 Runnable 接口,提供了更大的灵活性和功能,特别是在需要任务执行后有返回值的场景中。

步骤

  1. 实现 Callable 接口:首先,定义一个类实现 Callable 接口。
  2. 定义 call() 方法:类中实现 call() 方法,在此方法中定义具体的任务。call() 方法是有返回值的且可以抛出异常,其类型由 Callable 接口的泛型参数指定。
  3. 创建 FutureTask 对象:以自定义类的实例对象为参数,构造 FutureTask 对象。
  4. 创建 Thread 对象:创建一个 Thread 类的实例,并将 FutureTask 类的实例作为参数传给 Thread 的构造器(FutureTask 继承自 Runnable 接口)。
  5. 启动线程:使用 Thread 类的实例调用 start() 方法来启动线程。
  6. 获取结果:通过调用 FutureTaskget() 方法来获取执行结果,需要注意的是这个方法会导致线程阻塞,直至任务完成。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
public class CallableTestClient {

public static void main(String[] args) throws Exception {
MyCallable myCallable = new MyCallable();
FutureTask<String> futureTask = new FutureTask<>(myCallable);
Thread thread = new Thread(futureTask);
thread.start();
log.info("Result: {}", futureTask.get());
}

public static class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "Hello, Callable!";
}
}

}

特点

优点:

  • 可获取结果call() 方法可以返回异步线程的执行结果,并支持抛出异常。适合需要获取执行结果的任务。
  • 继承自 Runnable:可享有实现 Runnable 接口创建线程的全部优点。

缺点:

  • 实现复杂:代码实现比较复杂。

JVM线程简述

栈与栈帧

JVM 由堆(Heap)、栈(Stack) 和方法区(Method Area,现已演化为元空间 Metaspace) 组成。其中栈的全称为 Java Virtual Machine Stacks(Java 虚拟机栈),其内存是线程私有的,每个线程启动时,虚拟机会为其分配一块独立的栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
  • 栈的作用是存储方法调用的相关信息,包括局部变量表、操作数栈、动态链接、方法返回地址等。
  • 栈帧是栈中的最小单位,随着方法的调用和结束,栈帧会被依次压入或弹出栈。
  • 当前线程的栈帧中,只有最顶部的栈帧是活动的,代表当前正在执行的方法,其余栈帧处于等待状态。
  • 方法调用结束后,其对应的栈帧会从栈中弹出,不再占用内存。
  • 栈内存的特点
    • 线程安全:由于栈是线程私有的,不存在数据竞争。
    • 生命周期短暂:与线程的生命周期一致,线程结束时栈也会销毁。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FrameTest {

public static void main(String[] args) {
new Thread(new MyRunnable()).start();
method1();
}

private static void method1() {
method2();
}

private static void method2() {
System.out.println(1);
}

public static class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Thread name: " + Thread.currentThread().getName());
}
}

}

在程序中添加断点,运行后可查看线程及线程内部的栈帧如下:

主线程含有的栈帧

image-20241229194033974

异步线程含有的栈帧

image-20241229194203104

线程上下文切换(Thread Context Switch)

因为一些原因导致 CPU 不再执行当前线程,转而执行另一个线程,这种现象称为线程上下文切换

原因

因为以下一些原因导致 CPU 不再执行当前的线程,转而执行另一个线程的代码:

  • CPU时间片用完:操作系统基于时间片轮转机制(Time Slicing)进行线程调度,时间片用完会触发上下文切换。
  • 垃圾回收:当 JVM 的 GC 线程执行垃圾回收时,可能会暂停其他线程。
  • 锁竞争:当多个线程竞争同一个锁时,线程可能进入阻塞状态,等待锁释放。
  • 线程方法调用:如 sleepwait 等方法会主动让出 CPU,触发上下文切换。

主要步骤

  1. 保存当前线程状态:包括程序计数器(PC)、寄存器、栈指针等信息。
  2. 调度下一线程:选择一个可运行的线程。
  3. 恢复新线程状态:将被调度线程的上下文信息加载到 CPU。

相关概念

程序计数器(PC Register):

  • 是线程私有的,记录线程当前执行的字节码指令地址。
  • 在线程切换时,程序计数器保存当前线程的执行位置,确保恢复后从正确的位置继续执行。

虚拟机栈中的信息:

  • 局部变量表:保存方法局部变量,包括基本数据类型、对象引用等。
  • 操作数栈:用于执行计算操作的中间结果存储。
  • 方法返回地址:记录方法结束后返回调用点的地址。

性能消耗

  • 上下文切换会导致 CPU 缓存失效(Cache Miss),增加内存访问的时间。
  • 上下文切换的操作(保存和恢复状态)本身也需要消耗 CPU 资源。

常用API

方法名 功能 注意事项
start() 启动线程并调用其 run() 方法,线程进入就绪状态。 不能多次调用同一线程对象的 start(),否则抛出 IllegalThreadStateException
run() 定义线程执行的具体任务。 直接调用 run() 不会启动新线程,而是作为普通方法运行。
wait() / wait(long n) 使当前线程等待(指定时间)。
notify() / notifyAll() 唤醒正在等待该对象监视器的单个/所有线程。
join() / join(long n) 当前线程等待目标线程执行完成(或等待指定时间)。 可用于线程同步,避免并发问题。
getId() 返回线程的唯一标识符(由 JVM 分配)。
getName() / setName(String n) 获取/设置线程的名称。 设置名称有助于调试和日志记录。
getPriority() / setPriority(int p) 获取/设置线程的优先级(范围1~10,默认5)。 优先级只是建议,实际调度由操作系统决定。
getState() 获取线程的当前状态,如 NEWRUNNABLEBLOCKED 等。
isAlive() 判断线程是否还存活(已经启动且未终止)。
interrupt() 中断线程,设置中断标志位。 不会直接停止线程,但阻塞状态下可能抛出 InterruptedException
isInterrupted() 判断线程是否被中断(中断标志位) 不清除中断标志。
interrupted() 判断当前线程是否被中断,并清除中断标志位。
currentThread() 返回对当前正在执行线程的引用。
sleep(long n) 使当前线程休眠指定时间(毫秒)。 不释放锁,但会抛出 InterruptedException
yield() 让出当前线程的CPU资源,尝试让其他线程运行。 调用后可能立即重新获取 CPU 执行权。
setDaemon(boolean on) 设置线程为守护线程(后台运行,JVM退出时自动停止)。 必须在 start() 之前调用。
checkAccess() 检查当前线程是否允许修改目标线程。
activeCount() 获取当前线程组中活动线程的估计数。
enumerate(Thread[] tarray) 将当前线程组中所有活动线程复制到指定数组中。
stop() 停止线程运行。 官方标记过时,不再推荐使用。
suspend() 暂停线程运行。 官方标记过时,不再推荐使用。
resume() 恢复线程运行。 官方标记过时,不再推荐使用。

Java 线程状态

状态枚举

  1. NEW(新建):线程已创建,但尚未启动,即线程对象已经实例化,但尚未分配系统资源用于执行。
  2. RUNNABLE(可运行):线程正在 Java 虚拟机中执行(RUNNING),或处于操作系统中的就绪队列中(READY)等待分配 CPU 时间。
  3. BLOCKED(阻塞):线程因等待监视器锁(monitor lock)而阻塞,无法继续执行,此过程不消耗 CPU 资源。
  4. WAITING(等待):线程无限期等待另一个线程的特定操作(如Object.wait()Thread.join()),此过程不消耗 CPU 资源。
  5. TIMED_WAITING(超时等待):线程等待另一个线程的特定操作,且有时间限制(如Thread.sleep(long)Object.wait(long)),此过程不消耗 CPU 资源。
  6. TERMINATED(终止):线程已完成执行或因异常终止。

状态转换

stateDiagram-v2
    [*] --> NEW

    NEW --> RUNNABLE : start()

    RUNNABLE --> BLOCKED : 请求锁被占用
    RUNNABLE --> WAITING : 调用wait()/join()
    RUNNABLE --> TIMED_WAITING : 调用sleep()/wait(long)/join(long)
    RUNNABLE --> TERMINATED : run()结束或异常

    BLOCKED --> RUNNABLE : 获取到锁

    WAITING --> RUNNABLE : 被notify()/notifyAll()/中断
    TIMED_WAITING --> RUNNABLE : 超时或被notify()/notifyAll()/中断

    TERMINATED --> [*]

竞态条件(Race Conditions)

在多线程环境下,多个线程同时访问和修改共享资源时,如果没有适当的同步机制,就会产生竞态条件。竞态条件的出现导致多个线程对共享变量的操作交叉执行,从而引发数据不一致的问题。

关键点

  1. 共享资源:线程共同访问的变量或对象。
  2. 操作交叉:线程对共享资源的访问顺序不是确定的,可能导致不可预测的结果。

临界区(Critical Section)

临界区是指一段代码,它在多线程环境中需要独占访问共享资源,以确保数据的完整性和一致性。同一时间,只有一个线程能够进入临界区执行这段代码,其他线程需要等待,直到当前线程完成操作并退出临界区。

关键特点

  1. 共享资源:临界区内的代码会访问或修改共享资源,这些资源在多线程环境下容易引发数据竞争问题。
  2. 互斥性:临界区保证在某个时间点,只有一个线程能执行该段代码,避免线程之间的冲突。
  3. 同步机制:通过锁或其他同步工具(如 synchronizedReentrantLock)来实现对临界区的保护。

关键API

start() 与 run()

特性 start() 方法 run() 方法
主要功能 启动一个新线程,导致 JVM 调用该线程的 run() 方法。 定义线程执行的具体任务,当通过 start() 启动线程时被自动调用。
线程创建 是的,调用 start() 会在 JVM 中创建一个新的线程。 否,直接调用 run() 只是作为普通方法在当前线程中同步执行。
并发执行 是,start() 启动的新线程与调用线程并发执行。 否,直接调用 run() 方法不会启动新线程,而是在当前线程中顺序执行。
方法执行顺序 异步执行,start() 方法立即返回,新线程开始独立执行。 同步执行,调用线程会等待 run() 方法执行完毕后再继续。
线程状态转换 NEWREADYRUNNING 不涉及线程状态转换,因为 run() 在当前线程中执行。
异常处理 异常在新线程中抛出,不会影响调用 start() 的线程。 异常在当前线程中抛出,可能影响调用 run() 方法的线程。
调用频率 每个 Thread 实例只能调用一次 start(),再次调用会抛出 IllegalThreadStateException 可以多次调用 run(),但每次都是在当前线程中同步执行。

关键区别总结:

  • 并发性: start() 方法启动的新线程与调用线程并发执行,而直接调用 run() 方法则在当前线程中同步执行,不会启动新线程。
  • 线程生命周期: start() 会改变线程的生命周期状态,而 run() 方法的调用不会影响线程状态。
  • 一次性启动: start() 方法只能被调用一次,一个 Thread 实例只能启动一次;而 run() 方法可以被多次调用,但每次都是在当前线程中执行。

sleep() 与 yield()

特性 Thread.sleep(long millis) Thread.yield()
主要功能 暂停当前线程执行指定时间,进入 TIMED_WAITING 状态。 提示调度器当前线程愿意让出CPU资源,以执行其他线程。
线程状态变化 RUNNABLETIMED_WAITINGRUNNABLE 可能保持在 RUNNABLE 状态。
CPU使用权 让出 CPU 使用权给其他线程。 可能会让出 CPU 使用权给其他线程。
是否释放锁 不释放任何锁,线程在休眠期间仍持有所有锁。 不释放任何锁,线程继续持有所有锁。
异常处理 抛出 InterruptedException,需要处理。 不抛出异常。
执行时机 立即进入休眠状态,等待指定时间后恢复。 在当前执行点提示调度器让出 CPU,具体行为由调度器决定。
可控性 高,通过指定具体的休眠时间控制暂停长度。 低,只是一个提示,无法控制具体的暂停时间或哪个线程被调度执行。
适用场景 需要明确暂停线程的执行,控制执行节奏或模拟延时。 希望提高线程间的协作性,允许其他线程有机会执行,但不需要具体控制暂停时间。
性能影响 适度影响,根据休眠时间长度决定。 取决于调度器实现,可能带来较小的性能影响。
可预测性 高,线程会在指定时间后恢复,但实际休眠时间可能略有偏差。 低,行为依赖调度器,不可预测。
示例用法 延时任务执行,等待资源准备,控制循环频率。 简单的线程礼让,非关键的协作场景。

总结:

  • 使用 sleep() 当你需要明确地暂停线程一段时间,控制线程执行节奏或模拟延时操作时,但不应作为线程同步或等待条件的主要手段。
  • 使用 yield() 时,要了解其行为的不确定性,仅在非关键的线程协作场景下考虑使用,高负载环境下使用可能会让线程长时间得不到执行机会。

wait() / notify() / notifyAll()

概述

wait()Object 类的一个实例方法,用于使当前线程等待,直到被其他线程通过 notify()notifyAll() 方法唤醒,或者在指定的时间后自动恢复执行。具体过程如下:

  1. 调用 wait() 方法,释放当前持有的对象监视器锁,使当前线程进入 WAITING 状态(无超时)或 TIMED_WAITING 状态(有超时)。
  2. 调用 notify() / notifyAll() 方法,唤醒一个/所有正在等待该对象监视器的线程,唤醒的线程将从 WAITINGTIMED_WAITING 状态恢复为 RUNNABLE 状态,等待竞争锁。
  3. 获得锁的线程继续执行。

方法重载

1
2
3
4
5
6
7
8
# 使线程进入等待状态,无超时时间
public final void wait() throws InterruptedException {...}

# 使线程进入等待状态,最多等待指定的毫秒数
public final void wait(long timeout) throws InterruptedException {...}

# 使线程进入等待状态,最多等待指定的毫秒数加纳秒数
public final void wait(long timeout, int nanos) throws InterruptedException {...}

代码示例

实现一个经典的“生产者-消费者模型”,生产者生产数据,消费者消费数据,通过 wait()notifyAll() 实现线程间通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class ProducerConsumerTestClient {

private final Object lock = new Object();
private boolean hasData = false;
private int data = 0;

public void produce() throws InterruptedException {
synchronized(lock) {
while (hasData) {
lock.wait(); // 线程进入 WAITING 状态,等待消费者消费数据
}
data = (int) (Math.random() * 100);
hasData = true;
System.out.println("生产者生产数据: " + data);
lock.notifyAll(); // 通知消费者线程
}
}

public void consume() throws InterruptedException {
synchronized(lock) {
while (!hasData) {
lock.wait(); // 线程进入 WAITING 状态,等待生产者生产数据
}
System.out.println("消费者消费数据: " + data);
hasData = false;
lock.notifyAll(); // 通知生产者线程
}
}

public static void main(String[] args) {
ProducerConsumerTestClient pc = new ProducerConsumerTestClient();

Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
pc.produce();
Thread.sleep(500); // 模拟生产时间
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
pc.consume();
Thread.sleep(500); // 模拟消费时间
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

producer.start();
consumer.start();

// 等待生产者线程和消费者线程执行完毕
try {
producer.join();
consumer.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

System.out.println("主线程退出");

}

}

注意事项

  1. 始终在同步块或同步方法中调用 wait()notify()notifyAll(),这些方法必须在持有对象的监视器锁的情况下调用,否则会抛出 IllegalMonitorStateException
  2. 使用 while 循环而非 if 判断条件,防止“虚假唤醒(Spurious Wakeup)”。即使线程被唤醒,也需要重新检查条件是否满足。
  3. 谨慎处理中断请求,捕获 InterruptedException 后,恢复中断状态(调用 Thread.currentThread().interrupt())。
  4. 优先使用高级并发工具,wait()notify()notifyAll() 是低级同步机制,易出错且难以维护。优先使用更高级的并发工具,如 java.util.concurrent 包中的类。
  5. 避免不必要的等待和唤醒,仅在确实需要线程进入等待状态时才调用 wait(),并在适当的位置调用 notify()notifyAll() 以唤醒等待线程,避免过度同步导致性能下降。
  6. 避免死锁,确保所有线程以相同的顺序获取多个锁,同时尽量使用带超时的锁获取方法,尽量避免多个锁的嵌套使用,降低复杂性。
  7. 使用线程监控工具(如 jstack、VisualVM、Java Mission Control 等)分析线程状态,识别潜在的性能瓶颈和线程协作问题。

join()

概述

线程的实例方法,用于使当前线程等待另一个线程完成执行。join() 方法内部实际上调用了被调用线程的 wait() 方法。具体过程如下:

  1. 当前线程调用 join() 方法,进入 WAITINGTIMED_WAITING 状态。
  2. 被调用线程执行完毕,进入 TERMINATED 状态。
  3. JVM 唤醒等待的线程,当前线程从 WAITINGTIMED_WAITING 状态恢复为 RUNNABLE 状态,继续执行后续代码。

方法重载

1
2
3
4
5
6
7
8
# 等待被调用线程完成执行,无时间限制。
public final void join() throws InterruptedException {...}

# 等待被调用线程完成执行,最多等待指定的毫秒数。
public final void join(long millis) throws InterruptedException {...}

# 等待被调用线程完成执行,最多等待指定的毫秒数加纳秒数。
public final void join(long millis, int nanos) throws InterruptedException {...}

代码示例

在主线程调用线程的 join 方法实现线程同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Thread t1 = new Thread(() -> System.out.println("t1 执行结束"));
Thread t2 = new Thread(() -> {
try {
Thread.sleep(1000);
System.out.println("t2 执行结束");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
t2.start();

// 等待 t1 和 t2 执行结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}

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

运行程序,控制台输出如下

1
2
3
t1 执行结束
t2 执行结束
主线程结束

注意事项

  1. 确保在调用 join() 方法之前已经启动了被调用线程,否则当前线程可能会无限期等待。
  2. 被调用线程可能因某种原因无法正常终止,使用带超时的 join() 方法可以避免当前线程无限期等待。
  3. 在多线程环境中,线程可能会被其他线程中断,使用 join() 方法时应妥善处理 InterruptedException(恢复中断状态)。
  4. 避免调用自身 join(),否则将导致线程永久等待,形成死锁。

interrupt() / isInterrupted() / interrupted()

线程中断概述

线程中断是 Java 中一种协作式的线程控制机制,允许一个线程请求另一个线程停止其当前的执行。中断并不强制线程立即停止,而是设置线程的中断状态,线程本身需要定期检查并响应中断请求,从而实现安全、优雅的终止。

方法概述

  • interrupt()Thread 类中的一个实例方法,可设置线程的中断状态为 true,用于中断线程,但并不直接停止线程的执行。当调用 interrupt() 方法时,以下两种情况会发生:
    1. 线程处于阻塞状态: 如果线程正在执行阻塞操作(例如 sleep()wait()join()),这些方法会立即抛出 InterruptedException,并且线程的中断状态会被清除(即设置为 false)。
    2. 线程处于运行状态: 如果线程不在阻塞状态,调用 interrupt() 仅仅设置线程的中断状态为 true,线程需要自行检查中断状态并决定如何响应。
  • isInterrupted()Thread 类中的一个实例方法,用于检查线程是否被中断,但不清除中断状态。
  • interrupted()Thread 类中的一个静态方法,用于检查当前线程是否被中断,并清除中断状态,使得之后的检查无法检测到先前的中断。

代码示例

通过中断机制实现线程的终止,确保资源的正确释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class InterruptTestClient {

public static void main(String[] args) {
Thread worker = new Thread(() -> {
System.out.println("工作线程开始执行");
try {
// 如果当前线程未被中断,则执行循环
while (!Thread.currentThread().isInterrupted()) {
// 模拟工作
System.out.println("工作线程正在处理任务...");
Thread.sleep(1000); // 休眠1秒
}
} catch (InterruptedException e) { // 捕获到异常后中断状态会被自动清除
System.out.println(Thread.currentThread().isInterrupted()); // false
System.out.println("工作线程在休眠中被中断");
// 恢复中断状态
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().isInterrupted()); // true
}
// 如果未恢复中断状态,则会导致无法中断线程(条件判断为 true,重新进入循环)
while (!Thread.currentThread().isInterrupted()) {
System.out.println("工作线程又在处理任务...");
}
// 释放资源
System.out.println("工作线程进行资源清理...");
System.out.println("工作线程终止。");
});

worker.start();

try {
Thread.sleep(3000); // 主线程休眠3秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

System.out.println("主线程请求工作线程中断");
worker.interrupt(); // 中断工作线程
}

}

步骤

  1. 工作线程启动并进入循环,每秒打印一次 “工作线程正在处理任务…”。
  2. 主线程休眠3秒后请求工作线程中断。
  3. 工作线程在 sleep() 方法中被中断,抛出 InterruptedException,进入 catch 块,打印中断信息并恢复中断状态。
  4. 工作线程退出循环,进行资源清理并终止。

运行程序,控制台打印如下:

1
2
3
4
5
6
7
8
9
10
工作线程开始执行
工作线程正在处理任务...
工作线程正在处理任务...
工作线程正在处理任务...
主线程请求工作线程中断
false
工作线程在休眠中被中断
true
工作线程进行资源清理...
工作线程终止。

问题

  1. 为什么要在捕获 InterruptedException 异常后,使用 interrupt() 方法恢复中断状态?

    • 抛出 InterruptedException 异常时,中断状态会被自动清除(中断标志重置为 false),如果不显示地恢复中断状态,线程后续的代码无法感知到中断信号。
    • 恢复中断状态可以让调用者或上层逻辑知道线程曾经被请求中断,便于统一处理或记录。
    • 某些代码逻辑可能依赖中断状态进行特定的行为,例如安全终止或资源清理。
    • 对于框架或工具类代码,恢复中断状态是种约定,允许调用者决定如何处理中断,而不是工具类隐式地吞掉中断。
  2. interrupted() 方法的应用场景是什么?

    用于检查并清除中断状态,适合一次性处理中断请求或需要主动清理中断信号的场景。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    public class InterruptedExample {
    public static void main(String[] args) {
    Thread thread = new Thread(() -> {
    while (true) {
    if (Thread.interrupted()) { // 检测并清除中断状态
    System.out.println("线程被中断,退出循环");
    break;
    }

    // 执行其他任务
    System.out.println("执行任务...");
    try {
    Thread.sleep(1000); // 模拟任务
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // 恢复中断状态
    System.out.println("任务被中断");
    }
    }
    });

    thread.start();

    try {
    Thread.sleep(3000); // 主线程休眠3秒
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }

    thread.interrupt(); // 中断线程
    }
    }

    运行程序,控制台输出如下:

    1
    2
    3
    4
    5
    执行任务...
    执行任务...
    执行任务...
    任务被中断
    线程被中断,退出循环

线程组

简介

线程组(Thread Group)是 Java 提供的一种机制,用于将多个线程组织在一起,便于统一管理和控制。它允许开发者对一组相关的线程进行批量操作,如中断、设置优先级等。

在线程数量庞大且关系复杂的应用程序中,管理各个线程可能变得困难。线程组提供了一种层次化的管理方式,使得开发者可以按照功能或模块将线程进行分组,简化管理过程。

特点

优点

  • 集中管理:便于对相关线程进行统一控制,如中断、设置优先级等。
  • 层次化组织:支持线程组的嵌套,可以构建线程的层次结构,增强组织性。
  • 简化操作:一次性操作线程组中的所有线程,减少代码复杂性。

缺点

  • 线程组的过时性:从Java 2开始,官方建议优先使用java.util.concurrent包中的并发工具类,如ExecutorServiceFutureCountDownLatch等,线程组的使用逐渐减少。
  • 功能有限:线程组提供的功能相对基础,难以满足复杂的并发需求。
  • 不支持动态调整:线程组一旦创建,其结构较为固定,不易动态调整。
  • 安全性问题:线程组允许外部线程访问和操作线程组中的线程,可能引发安全隐患。
  • 监控与调试困难:线程组的层次结构复杂时,监控和调试变得困难,尤其是在大型应用中。

创建与管理

创建线程组

  • 默认线程组:所有线程都属于一个默认的线程组,称为 main 线程组。

  • 自定义线程组:可以通过构造方法创建新的线程组。

    1
    2
    3
    4
    5
    // 创建一个名为"MyGroup"的新线程组,父线程组为当前线程的线程组
    ThreadGroup myGroup = new ThreadGroup("MyGroup");

    // 创建一个名为"ChildGroup"的子线程组,父线程组为myGroup
    ThreadGroup childGroup = new ThreadGroup(myGroup, "ChildGroup");

将线程加入线程组

在创建Thread对象时,可以指定所属的线程组:

1
2
3
// 创建一个线程,并指定线程组为myGroup
Thread thread = new Thread(myGroup, () -> {
});

主要方法

ThreadGroup类提供了多种方法,用于管理和查询线程组中的线程和子线程组:

线程组信息

  • **getName()**:返回线程组的名称。
  • **getMaxPriority()**:获取线程组中允许的最大线程优先级。
  • **setMaxPriority(int newPriority)**:设置线程组中允许的最大线程优先级。

线程状态查询

  • **activeCount()**:返回线程组中当前活动线程的估计数。
  • **activeGroupCount()**:返回线程组中当前活动子线程组的估计数。
  • **enumerate(Thread[] list, boolean recurse)**:将线程组中的所有线程拷贝到指定的数组中。
  • **enumerate(ThreadGroup[] list, boolean recurse)**:将线程组中的所有子线程组拷贝到指定的数组中。

线程控制

  • **interrupt()**:中断线程组中的所有线程。
  • **destroy()**:销毁线程组。线程组必须为空(即没有活跃线程和子线程组),否则会抛出IllegalThreadStateException
  • **isDestroyed()**:检查线程组是否已被销毁。

其他

  • **parentOf(ThreadGroup g)**:判断当前线程组是否是指定线程组的父线程组。
  • **uncaughtException(Thread t, Throwable e)**:处理线程组中线程的未捕获异常。

代码示例

创建和管理线程组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Slf4j
public class ThreadGroupExample {
public static void main(String[] args) {
// 创建一个新的线程组
ThreadGroup group = new ThreadGroup("MyGroup");

// 创建并启动多个线程
for (int i = 1; i <= 3; i++) {
Thread thread = new Thread(group, () -> {
try {
while (!Thread.currentThread().isInterrupted()) {
log.info(Thread.currentThread().getName() + " 正在执行任务...");
Thread.sleep(1000);
}
} catch (InterruptedException e) {
log.info(Thread.currentThread().getName() + " 被中断");
Thread.currentThread().interrupt(); // 恢复中断状态
}
log.info(Thread.currentThread().getName() + " 终止");
}, "Thread-" + i);
thread.start();
}

// 等待3秒后中断线程组中的所有线程
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

log.info("主线程中断线程组中的所有线程");
group.interrupt();

// 等待线程组中的所有线程终止
int activeCount = group.activeCount();
Thread[] threads = new Thread[activeCount];
group.enumerate(threads);
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

log.info("所有线程已终止");
}
}

运行程序,控制台输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
22:52:11.148 [Thread-2] INFO  s.yangtao.group.ThreadGroupExample - Thread-2 正在执行任务...
22:52:11.148 [Thread-3] INFO s.yangtao.group.ThreadGroupExample - Thread-3 正在执行任务...
22:52:11.148 [Thread-1] INFO s.yangtao.group.ThreadGroupExample - Thread-1 正在执行任务...
22:52:12.157 [Thread-2] INFO s.yangtao.group.ThreadGroupExample - Thread-2 正在执行任务...
22:52:12.157 [Thread-3] INFO s.yangtao.group.ThreadGroupExample - Thread-3 正在执行任务...
22:52:12.157 [Thread-1] INFO s.yangtao.group.ThreadGroupExample - Thread-1 正在执行任务...
22:52:13.170 [Thread-2] INFO s.yangtao.group.ThreadGroupExample - Thread-2 正在执行任务...
22:52:13.170 [Thread-1] INFO s.yangtao.group.ThreadGroupExample - Thread-1 正在执行任务...
22:52:13.170 [Thread-3] INFO s.yangtao.group.ThreadGroupExample - Thread-3 正在执行任务...
22:52:14.153 [main] INFO s.yangtao.group.ThreadGroupExample - 主线程中断线程组中的所有线程
22:52:14.153 [Thread-2] INFO s.yangtao.group.ThreadGroupExample - Thread-2 被中断
22:52:14.153 [Thread-2] INFO s.yangtao.group.ThreadGroupExample - Thread-2 终止
22:52:14.153 [Thread-3] INFO s.yangtao.group.ThreadGroupExample - Thread-3 被中断
22:52:14.153 [Thread-1] INFO s.yangtao.group.ThreadGroupExample - Thread-1 被中断
22:52:14.153 [Thread-3] INFO s.yangtao.group.ThreadGroupExample - Thread-3 终止
22:52:14.153 [Thread-1] INFO s.yangtao.group.ThreadGroupExample - Thread-1 终止
22:52:14.153 [main] INFO s.yangtao.group.ThreadGroupExample - 所有线程已终止

最佳实践

  • 优先使用高级并发工具:尽量使用 java.util.concurrent 包中的工具类,简化并发控制逻辑。
  • 避免过度使用线程组:仅在特定需求下,或为维护旧有代码时,才考虑使用线程组。
  • 设计合理的线程管理策略:结合线程池、任务提交与控制,确保线程的高效利用与安全终止。
  • 监控与调试:定期使用线程监控工具分析线程状态,优化并发性能。
  • 线程安全设计:无论使用何种并发工具,始终遵循线程安全的设计原则,避免数据竞争与死锁。

总结

Java 并发编程的关键在于对线程的正确使用和对共享数据的安全访问。通过了解进程与线程的区别,我们明白了线程在同一进程内共享资源、切换开销小但安全性相对较低;并通过并发与并行的比较,认识到编写并行程序需要配合底层硬件资源。对于如何创建线程,Java 提供了继承 Thread、实现 Runnable、以及实现 Callable 等多种方式,分别在灵活性、返回值、异常处理能力等方面各有侧重。围绕线程生命周期和常用 API 的阐述,则让我们对线程状态、方法调用和上下文切换机制有了直观理解。

同时,本文也提示了线程安全的重要性:多个线程共同访问或修改共享资源时需要使用适当的同步或锁机制来避免竞态条件。在实践中,掌握 wait()/notify()synchronizedLock 以及更高级的并发容器和原子类库,才能编写出高质量、高并发且安全的 Java 程序。希望本文能帮助读者打下良好的并发编程基础,并在后续深入学习中更好地理解 Java 并发模型的设计理念与实战技巧。