目录
4.2.1 sleep(): 让当前线程暂停执行一段时间,不释放锁 。
4.2.2 join(): 让当前线程等待另一个线程执行完毕再继续。
4.2.3 wait() 让当前线程释放锁并进入等待状态,等待 notify() 或 notifyAll() 唤醒。
语雀笔记:https://www.yuque.com/yuquexiansheng/znisb2/kyn79ku9ixwzq8xu?singleDoc# 《多线程》
一,多线程概述
1.1 什么是多线程
多线程(Multithreading)是一种并发编程技术,它允许一个程序同时运行多个线程,每个线程执行不同的任务,从而提高程序的执行效率。
为什么要使用多线程,因为在现代计算机中,所有电脑基本上是多核CUP,每一个CUP都可以独立调用一条线程。这样就可以充分硬件资源,从而提高程序的执行效率。
多线程的主要优势包括:
- 提高执行效率:可以让多个任务同时运行,充分利用 CPU 资源。
- 增强程序响应能力:避免主线程被阻塞,提升用户体验(如 GUI 程序)。
- 更好地处理 I/O 任务:比如爬虫、文件下载等,使用多线程可以避免等待时间浪费。
1.2 线程与进程
从结构组成上讲,一个进程由至少一个或者多个线程组成。从系统资源分配的角度来讲,进程之间内存资源互相独立,互不打扰。同一进程之间内存资源共享,因为都来源于同一个进程。
此外,线程之间也有自己的专属工作内存。这个工作内存线程之间是不共享。
而且线程是 CPU 资源调用的基本单位,一个 CPU 轮询调用线程。多核CPU 自然就可以并行处理多线。
资源类型 | 进程(Process) | 线程(Thread) |
内存(Memory) | 拥有独立的地址空间,每个进程的内存互不影响 | 共享进程的地址空间(代码段、数据段、堆、全局变量) |
CPU(调度) | 由操作系统进行调度,一个进程至少有一个线程运行 | 线程是 CPU 调度的基本单位,多个线程可以并行执行 |
文件句柄 | 进程拥有自己的文件描述符(FD),文件独立打开 | 线程共享进程的文件描述符 |
全局变量 | 进程的全局变量独立,互不干扰 | 线程共享进程的全局变量,可能引发竞争问题 |
资源开销 | 进程创建、切换、销毁的开销较大,需要操作系统分配独立的资源 | 线程创建、切换、销毁的开销较小,共享进程资源 |
通信方式 | 需要使用 IPC(管道、消息队列、共享内存等),通信复杂 | 共享内存,直接修改变量,通信简单但需保证同步 |
二,多线程基本使用
2.1 Thread 介绍
Thread 是线程的实体类,想要将其他任务进行分给其他线程帮忙分担,可以通过Thread来实现。下面是一个工单打印的任务功能如下, 代码如下:
class EmployeeThread extends Thread { // 定义线程主体类
/** 自定义一个线程名称 */
private String name ;
public EmployeeThread(String name) {
this.name = name ;
}
@Override
public void run() {
for (int x = 1 ; x < 3 ; x ++) {
System.out.println("【流水工单】" + this.name + "生成工单号:" + this.generateJobId());
}
}
private String generateJobId() {
return "JOB-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
}
public class ThreadTest {
public static void main(String[] args) {
EmployeeThread thread1 = new EmployeeThread("张三");
EmployeeThread thread2 = new EmployeeThread("李四");
EmployeeThread thread3 = new EmployeeThread("王五");
thread1.start();
thread2.start();
thread3.start();
}
}
输出内容如下
【流水工单】王五生成工单号:JOB-CD4FE818
【流水工单】李四生成工单号:JOB-D2F81B09
【流水工单】张三生成工单号:JOB-BCD2771A
【流水工单】王五生成工单号:JOB-5CF5D7A6
【流水工单】李四生成工单号:JOB-39D61E6C
【流水工单】张三生成工单号:JOB-E728CF50
将流水工单的业务功能放在EmployeeThread 类之中,并重写run方法。通过 start() 开启一条线程。
2.2 面试题: 方式一和方式二的调用区别是什么?
public static void main(String[] args) {
EmployeeThread thread1 = new EmployeeThread("张三");
EmployeeThread thread2 = new EmployeeThread("李四");
EmployeeThread thread3 = new EmployeeThread("王五");
// 方式一
thread1.start();
thread2.start();
thread3.start();
// 方式二
thread1.run();
thread2.run();
thread3.run();
}
方式一,才是真正的开启一条新线程执行。thread1,2,3分别都开启一条新线程执行,所有仅仅上面的代码 (不考虑JVM内部相关业务) 而然是有四条线程分别是main方法主线程,thread1,2,3 各一条。方式二仅仅只是普通的调用,其执行的线程还是main方法的主线程。
三 线程的生命周期
通过上面的案例,我们可以得知,线程的开启/创建是通过start()方法执行的。那线程的任务执行完了之后,什么时候被销毁?线程的整个生命周期流程是怎么样
【文字总结】:
- 创建(new)后必须调用
start()
,才能进入调度状态。 - 阻塞状态不会自动恢复,需要被唤醒(
notify()
/sleep()
睡眠时间到)。 - 线程最终都会进入
Terminated
状态,等待垃圾回收。
四,守护线程 (Daemon Thread)
在 Java 中,守护线程 (Daemon Thread) 是一种辅助性线程,它的生命周期依赖于主线程或其他非守护线程。当所有非守护线程都执行完毕后,JVM 会自动终止所有守护线程,而不会等待它们执行完毕。
从上面的描述,可以发现其特点:线程可以在创建辅助线程(多条),而辅助线程不可以在创建辅助线程。其二一旦主线程消亡,其辅助线程也会随之消亡。
class HeartbeatDaemon extends Thread {
public HeartbeatDaemon() {
setDaemon(true);
}
@Override
public void run() {
while (true) {
System.out.println("检查服务器状态...【存活】");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class HeartbeatDaemonTest {
public static void main(String[] args) throws InterruptedException {
HeartbeatDaemon heartbeatThread = new HeartbeatDaemon();
heartbeatThread.start();
TimeUnit.SECONDS.sleep(5); // 睡眠5秒,模拟服务器运行
System.out.println("服务器已停止运行,退出程序...");
}
}
/**
输出结果:
检查服务器状态...【存活】
检查服务器状态...【存活】
检查服务器状态...【存活】
检查服务器状态...【存活】
检查服务器状态...【存活】
检查服务器状态...【存活】
服务器已停止运行,退出程序...
*/
五,常用线程API的讲解
假如我们把线程比作是流水线里的一个执行流程线,线程从开始运行到结束,整个过程的流程管理。都提供哪些对应的业务API接口。
官方文档:Thread (Java SE 23 & JDK 23)
4.1 start()和run() 方法
start() | 让线程进入就绪状态等待CPU轮询调用。并且线程只能调用一次。线程终止结束也不能重新再次调用。那线程池的线程复用是怎么实现的? |
run() | run()是线程执行任务/业务逻辑的入口。并且run方法还抽离成统一的标准接口Runnable 接口。Thread的run方法是实现弗雷Runnable而来。为什么要统一标准接口? |
【好处如下】
一,分离“线程”和“任务”,如果直接继承 Thread ,则任务和线程是绑定的,不利于代码复用。并且也避免了java继承的局限性。java是单继承多实现。
二,统一 run() 方法的标准定义,从而保证了所有的线程任务的执行逻辑是一致的。不同的任务(计算任务,I/O任务)可以通过统一标准接口Runable 方式组织,实现标准化和解耦
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行任务:" + Thread.currentThread().getName());
}
}
// 这种方式会导致:
// 任务逻辑和线程管理耦合,不利于扩展
// 无法共享任务,因为Thread 不是复用的
Runnable task = new MyTask();
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
// 使用 Runnable 让任务可复用,同一个任务被多个线程执行,增强复用性
三,线程池的线程复用也是基于 Runnable(或 Callable),而不是 Thread 重新调用start()。 在 Java 中,一个 Thread
对象的 start()
方法只能调用一次,如果试图重复调用,会抛出异常
Thread thread = new Thread(() -> System.out.println("任务执行"));
thread.start();
thread.start(); // ❌ java.lang.IllegalThreadStateException
所以 Thread
本身不能被复用,每次都要创建新的对象,这样开销大,性能差。
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> System.out.println(Thread.currentThread().getName() + " 执行任务1"));
executor.submit(() -> System.out.println(Thread.currentThread().getName() + " 执行任务2"));
executor.submit(() -> System.out.println(Thread.currentThread().getName() + " 执行任务3"));
executor.shutdown();
// 线程池取出一个空闲的 Thread。
// 这个 Thread 执行新的 Runnable 任务。
// 任务执行完毕后,线程不会销毁,而是继续等待下一个任务。
✅ 同一个线程可以被多次使用,执行不同的 Runnable
任务!
4.2 sleep(),join(), wait()方法
线程在任务执行的过程之中,还提供一些中间处理的操作,比如多线程并发执行任务,需要保持线程的有序执行等等。
4.2.1 sleep(): 让当前线程暂停执行一段时间,不释放锁 。
public class SleepExample {
public static void main(String[] args) {
System.out.println("Start");
try {
Thread.sleep(2000); // 当前线程暂停 2 秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("End");
}
}
4.2.2 join(): 让当前线程等待另一个线程执行完毕再继续。
需要确保某个线程先执行完(如主线程等待子线程完成任务)。
public class JoinExample {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println("子线程执行完毕");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
thread.join(); // 主线程等待子线程执行完毕
System.out.println("主线程继续执行");
}
}
// 执行结果
// 子线程执行完毕
// 主线程继续执行
join()
让当前线程等待 另一个线程执行完毕后再继续。 适用于线程间的同步,防止主线程过早结束 。
4.2.3 wait() 让当前线程释放锁并进入等待状态,等待 notify()
或 notifyAll()
唤醒。
线程间通信(生产者-消费者模型)
class WaitExample {
private static final Object lock = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread1: 等待中...");
try {
lock.wait(); // 释放锁,并等待唤醒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread1: 被唤醒,继续执行");
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread2: 唤醒 Thread1");
lock.notify(); // 唤醒等待的线程
}
});
thread1.start();
try {
Thread.sleep(1000); // 确保 Thread1 先执行
} catch (InterruptedException e) {
e.printStackTrace();
}
thread2.start();
}
}
/**
* Thread1: 等待中...
(1 秒后)
Thread2: 唤醒 Thread1
Thread1: 被唤醒,继续执行
*/
wait() 必须在 synchronized 代码块 内使用,否则报错。
wait() 释放锁,等待 notify()/notifyAll() 唤醒
4.2.4 【总结】
方法 | 释放锁? | 让哪个线程等待? | 何时继续执行? | 适用场景 |
| ❌ 不释放 | 当前线程 | 时间结束 | 限流、模拟延迟 |
| ❌ 不释放 | 当前线程 | 目标线程结束 | 等待某个线程执行完 |
| ✅ 释放锁 | 当前线程 |
/ | 线程间通信 |
场景分类:
- 简单延迟 👉
sleep()
- 等待另一个线程执行完 👉
join()
- 线程间通信(生产者-消费者) 👉
wait()
+notify()
六 中断异常 InterruptedException
InterruptedException
用于处理中断请求,它通常在线程调用 sleep()
、wait()
、join()
等方法时抛出。
在实际开发中,中断异常(InterruptedException
)常用于任务超时处理、定时任务、文件处理、数据库查询超时等。以下是几个常见的业务案例,帮助理解如何在真实场景中正确处理中断异常。
6.1 任务超时处理案例
在企业应用中,经常需要执行定时任务,如 数据同步、爬取网页、文件处理 等。如果某个任务执行时间过长,我们希望它能够超时退出,而不是一直阻塞。
public class TaskTimeoutExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
try {
System.out.println("任务开始...");
Thread.sleep(5000); // 模拟长时间运行任务
System.out.println("任务完成...");
} catch (InterruptedException e) {
System.out.println("任务超时,被中断...");
}
});
try {
future.get(3, TimeUnit.SECONDS); // 限制任务执行时间最多 3 秒
} catch (TimeoutException e) {
System.out.println("超时,取消任务...");
future.cancel(true); // 发送中断信号
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}
输出
任务开始...
超时,取消任务...
任务超时,被中断...
6.2 多线程文件处理
假设我们有一个任务需要批量处理多个大文件,每个线程负责解析一个文件。如果某个文件处理时间过长,我们希望它超时退出,不影响整体进度。
public class FileProcessingExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
File[] files = {new File("file1.txt"), new File("file2.txt")};
for (File file : files) {
Future<?> future = executor.submit(() -> {
try {
System.out.println("处理文件: " + file.getName());
Thread.sleep(5000); // 模拟文件处理时间
System.out.println("文件 " + file.getName() + " 处理完成");
} catch (InterruptedException e) {
System.out.println("文件 " + file.getName() + " 处理超时,被中断...");
}
});
try {
future.get(3, TimeUnit.SECONDS); // 限制单个文件处理时间最多 3 秒
} catch (TimeoutException e) {
System.out.println("文件 " + file.getName() + " 处理超时,取消任务...");
future.cancel(true); // 取消超时任务
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
executor.shutdown();
}
}
输出
处理文件: file1.txt
文件 file1.txt 处理超时,取消任务...
文件 file1.txt 处理超时,被中断...
处理文件: file2.txt
文件 file2.txt 处理超时,取消任务...
文件 file2.txt 处理超时,被中断...
6.3 stop() 和 interrupt()
在线程当中,有两种方式进行线程中断,一种是强制性的stop(), 另一种是比较温和的interrupt();
6.3.1 stop()
方法
- 已过时:
Thread.stop()
方法已经在 JDK 1.2 中被标记为过时(deprecated),并且 不推荐使用。这是因为它会强制终止线程,直接抛出ThreadDeath
错误并清理线程的资源,可能导致 数据不一致、资源泄漏 和 死锁 等问题。 - 实现机制:当调用
stop()
时,JVM 会立即终止目标线程,不管该线程是否在执行关键代码或释放资源。这种方式比较“粗暴”,可能会打断线程中正在进行的任务,导致系统的不稳定。
- 不安全:会直接强制停止线程,而不允许线程自己释放资源或进行清理。
- 可能导致数据不一致:例如,在更新共享数据时,线程被强制停止可能导致数据处于不一致的状态。
- 破坏线程的正常执行流程:调用
stop()
后,线程不能继续执行任何代码,可能导致一些逻辑错误。
6.3.2 interrupt()
方法
- 推荐使用:
Thread.interrupt()
是正确的方式来停止线程,它不会直接停止线程,而是 发送一个中断信号,让线程有机会去响应并做适当的清理工作。 - 实现机制:当调用
interrupt()
方法时,目标线程会收到一个中断信号。线程可以通过检测Thread.interrupted()
或isInterrupted()
方法来知道自己是否被中断。如果线程正在执行阻塞操作(如sleep()
、wait()
或join()
),这些操作会抛出InterruptedException
异常,并允许线程处理异常并退出或采取其他适当的行为。
优点:
- 优雅退出:线程可以在适当的时机退出,处理异常或清理资源,避免资源泄漏和死锁。
- 更安全:
interrupt()
是通过异常或检查标志来通知线程,因此线程有机会按自己的方式响应中断。
最后,如果这篇文章对你有帮助,欢迎 点赞👍、收藏📌、关注👀!
我会持续分享 Java、Spring Boot、MyBatis-Plus、微服务架构 相关的实战经验,记得关注,第一时间获取最新文章!🚀
系列文章推荐
这篇文章是 【Java SE 17源码】系列 的一部分,详细地址:
记得 关注我,后续还会更新更多高质量技术文章!