多线程详解
多线程
1.线程中概念的基本介绍
1.1 中央处理器(CPU)
它是进行逻辑运算用的,主要由运算器、控制器、寄存器三部分组成,而我们的线程运行在CPU之上。
- 单核 :单核的CPU表示只有一个大脑,同一时刻只能执行一个线程的任务。同时间段内多个线程的话,CPU也只能交替去执行多个线程中的一个线程,但是由于其执行速度特别快,就可以做到给人一种“多线程并发”的感觉。
- 多核 :多核的CPU,真正的多线程并发是没问题的。4核CPU表示同一个时间点上,可以真正的有4个进程并行执行。
1.2 进程(Process)和线程
进程
一个正在运行的应用程序。例如,你打开浏览器、播放音乐、运行一个Java程序,每一个都是一个独立的进程。
线程
是进程中的一个执行单元(一个进程可以启动多个线程。)
eg.
对于java程序来说,当在DOS命令窗口中输入:
java HelloWorld 回车之后。会先启动JVM,而JVM就是一个进程。
JVM再启动一个主线程调用main方法(main方法就是主线程
)。
同时再启动一个垃圾回收线程负责看护,回收垃圾。
最起码,现在的java程序中至少有两个线程并发,一个是 垃圾回收线程,一个是 执行main方法的主线程。
1.3 进程和线程的关系?
进程
独立、拥有自己的资源(内存、文件句柄等),可以看作是“容器”。
线程
共享父进程的资源,是CPU执行任务的基本单位,可以看作是“执行者”。
eg.
一个公司(进程)有自己的办公大楼(内存),所有员工(线程)都在这个大楼里工作,共享办公资源(桌椅、文件柜),但不同的公司(进程)之间的大楼是相互独立的。
1.4 并发、并行、串行
1.4.1 并发
说明
并发是指在一个时间段内,多个任务交替地执行。(任务之间存在交叠,但并非同时进行。)
特点
多个任务在单核CPU上,通过CPU的快速切换(时间片轮转)来实现“同时”进行。从宏观上看是同时的,但从微观上看是交替的。
eg.
想象你是一个厨师(一个CPU),手头有三道菜要做(三个任务)。你不能同时做三道菜,但你可以在烧第一道菜时,抽空去切第二道菜的配料,再去煮第三道菜的米饭。你不断地在三个任务之间快速切换,虽然在任何一个瞬间你只做一件事,但在整个过程中,你有效地推进了所有任务的进度。
1.4.2 并行
说明
并行是指在同一时刻,多个任务真正同时执行。(多个任务同时、不间断地进行。)
特点
多个任务在多核CPU上,每个核心负责一个任务,因此它们是真正同时进行的。
eg.
想象你有三位厨师(三个CPU),每人负责一道菜。他们可以同时、独立地进行烹饪。在任何一个时刻,这三道菜都在同时被处理。
1.4.3 串行
说明
串行是指任务一个接一个地执行。( 一个任务必须等前一个任务完全完成之后,才能开始执行。—)
特点
单个任务独占资源,任务之间不存在任何时间上的交叠。
eg.
想象你只有一位厨师(一个CPU),且这位厨师是一个完美主义者。他必须把第一道菜从切菜到装盘全部做好,才能开始第二道菜。所有任务都排队等待。
1.4.4 总结
概念 | 描述 | 核心区别 | 举例 |
---|---|---|---|
并发 | 一个时间段内,多个任务交替执行。 | 假同时,宏观并行,微观串行。 | 单核CPU上,边听音乐边聊QQ。 |
并行 | 同一时刻,多个任务真正同时执行。 | 真同时,每个任务独占一个处理单元。 | 多核CPU上,一个核处理游戏,一个核处理下载。 |
串行 | 一个任务完成后,下一个任务才开始。 | 依次执行,不存在任务交叠。 | 单个程序中,一行代码执行完才执行下一行。 |
2.线程的三种实现方式
在Java中,创建和管理线程有三种主要方式:继承 Thread
类、实现 Runnable
接口,以及实现 Callable
接口。每种方式都有其独特的应用场景和优缺点。
2.1 继承Thread
类
实现步骤
- 定义一个类,继承
Thread
。 - 重写
run()
方法,在其中编写线程要执行的任务代码。 - 创建子类对象,调用
start()
方法启动线程。
代码示例
//1.自己定义一个类继承Thread
public class MyThread extends Thread{//2.重写run方法@Overridepublic void run(){//书写线程要执行的代码,这段程序运行在分支线程中(分支栈)。for(int i = 0;i < 100;i++){System.out.printf(getName() + "HelloWorld");}}}
public class ThreadDemo{public static void main(String[] args){//3.创建子类的对象,并启动该线程MyThread t1 = new MyThread(); t1.setName("线程1"); //t1.run();不会启动线程,不会分配新的分支栈。(这种方式就是单线程。)t1.start();// 这里的代码还是运行在主线程中。for(int i = 0; i < 1000; i++){System.out.println("主线程--->" + i);}}}
要点与优缺点
-
优点: 编程简单,可以直接使用
Thread
类中的方法,如getName()
。 -
缺点: 扩展性差。Java只支持单继承,一旦继承了
Thread
,就不能再继承其他类。 -
start()
vsrun()
: 必须调用start()
方法来启动线程。调用run()
只是一个普通的方法调用,不会创建新的线程或分支栈,程序依然是单线程串行执行。而用start()
会启动一个分支线程,在JVM中开辟一个新的栈空间,线程就启动成功了。
2.2 实现 Runnable
接口
2.2.1 常规实现
实现步骤
- 定义一个类,实现
Runnable
接口。 - 重写
run()
方法。 - 创建
Runnable
接口的实现类对象。 - 创建
Thread
对象,并将Runnable
对象作为参数传入。 - 调用
Thread
对象的start()
方法启动线程。
代码示例
//1.自己定义一个类实现Runnable接口
public class MyRun implements Runnable{//2.重写里面的run方法@Overridepublic void run(){//书写线程要执行的代码for(int i = 0;i < 100;i++){System.sout.println(Thread.currentThread().getName() + "HelloWorld");}}}
public class ThreadDemo{public static void main(String[] args){//3.创建自己的类的对象MyRun mr = new MyRun();//4.创建一个Thread类的对象,并开启线程Thread t1 = new Thread(mr);Thread t2 = new Thread(mr);//给线程设置名字t1.setName("线程1");t2.setName("线程2");t1.start();t2.start();}}
2.2.2 匿名内部类
public class AnonymousRunnable {public static void main(String[] args) {// 使用 Lambda 表达式创建 Runnable 任务/*Thread t1 = new Thread(() -> {for (int i = 0; i < 5; i++) {System.out.println(Thread.currentThread().getName() + " - " + i);}});*/// 创建一个匿名内部类,直接实现 Runnable 接口Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {// 线程要执行的任务for (int i = 0; i < 5; i++) {System.out.println(Thread.currentThread().getName() + " - " + i);}}});// 启动线程t1.start();// 主线程继续执行for (int i = 0; i < 5; i++) {System.out.println("主线程 - " + i);}}
}
要点与优缺点
-
优点: 扩展性强。由于实现了接口而不是继承类,你的任务类可以自由地继承其他类。同时,多个线程可以共享同一个
Runnable
对象实例,非常适合需要共享数据的场景。 -
缺点: 编程相对复杂,不能直接调用
Thread
类的方法,需要通过Thread.currentThread().getName()
等方式获取当前线程信息。
2.3 实现 Callable
接口
Callable
弥补了 Runnable
无法返回结果和抛出受检异常的不足。通常与线程池和 FutureTask
配合使用。
实现步骤
- 定义一个类,实现
Callable<V>
接口,其中V
是返回值的类型。 - 重写
call()
方法,在其中编写任务逻辑,并使用return
返回结果。 - 创建
Callable
对象。 - 创建
FutureTask
对象来封装Callable
任务。 - 创建
Thread
对象并传入FutureTask
对象。 - 调用
start()
启动线程,并通过FutureTask
的get()
方法获取结果。
代码示例
//1.创建一个类 MyCallable 实现 Callable 接口
public class MyCallable iplements Callable<Integer> {//2.重写 call 【是有返回值的,表示多线程运行的结果】@Overridepublic Integer call() throws Excepion{//求1 ~ 100之间的和int sum = 0;for(int i = 1;i <= 100;i++){sum = sum + i;}return sum;}}
public class ThreadDemo{public static void main(String[] args){//3.创建 MyCallable 的对象【表示多线程要执行的任务】MyCallable mc = new MyCallable();//4.创建 FutureTask 的对象【作用管理多线程的运行结果】FutureTask<Integer> ft = new FutureTask<>(mc);//5.创建 Thread 类的对象,并启动【表示线程】Thread t1 = new Thread(ft);t1.start();//获取多线程运行的结果Integer result = ft.get();System.out.println(result);}}
要点与优缺点
-
优点: 可以获取线程的执行结果。
call()
方法可以返回值,也能抛出异常,功能更强大。 -
缺点: 编程相对复杂,通常需要
FutureTask
或ExecutorService
线程池来配合使用。get()
方法会阻塞主线程,直到子线程执行完毕,如果使用不当,可能导致性能问题。
2.4 三种实现方式的对比
实现方式 (Method) | 优点 (Advantages) | 缺点 (Disadvantages) |
---|---|---|
继承 Thread 类 | • 编程简单,可直接使用 Thread 类的方法。• 线程对象就是任务对象,代码结构清晰。 | • 扩展性差,因为 Java 不支持多重继承,一旦继承了 Thread 类,就不能再继承其他类。 |
实现 Runnable 接口 | • 扩展性强,实现该接口的同时还可以继承其他类或实现其他接口。 • 线程任务与线程对象分离,有助于实现资源共享。 | • 编程相对复杂,不能直接使用 Thread 类的方法,需要通过 Thread 对象的引用来调用。 |
实现 Callable 接口 | • 可以获取线程执行后的返回值。 • 可以抛出异常,比 Runnable 接口的 run() 方法更灵活。 | • 编程最复杂,需要结合 ExecutorService 线程池或 FutureTask 对象来使用。 |
2.5.如何选择实现方式
场景 | 推荐方式 | 理由 |
---|---|---|
不需要返回值,且不涉及多重继承的简单任务。 | 继承 Thread 类 | 最简单直观,代码量最少。 |
不需要返回值,但需要保留继承其他类的能力,或者多个线程需要共享同一个任务实例。 | 实现 Runnable 接口 | 提供了最佳的灵活性和可扩展性,也是Java中创建线程的标准做法。 |
需要获取线程执行结果,或者希望在任务中抛出异常。 | 实现 Callable 接口 | 唯一能直接返回结果的方式,是处理复杂异步任务的现代方法,通常与线程池结合使用以优化性能。 |
3. 线程的基本使用(常用方法)
方法名称 | 说明 |
---|---|
String getName() | 返回此线程的名称 |
void setName(String name) | 设置线程的名字【构造方法也可以设置名字】 |
static Thread currentThread() | 获取当前线程的对象 |
static void sleep(long time) | 让线程休眠指定的时间,单位为毫秒 |
setPriority(int new Priority) | 设置线程的优先级【默认为5,1最低,10最高】 |
final int getPriority() | 获取线程的优先级 |
final void setDaemon(boolean on) | 设置为守护线程 |
public static void yield() | 出让线程/礼让线程 |
public void join() | 插入线程/插队线程 |
3.1 线程调度与优先级
3.1.1 概念介绍
线程优先级:每个线程都有一个优先级,用整数表示,范围是 1 到 10。Thread
类提供了三个静态常量:
MAX_PRIORITY
(10):最高优先级MIN_PRIORITY
(1):最低优先级NORM_PRIORITY
(5):默认优先级
Java 的线程调度是抢占式的。这意味着,优先级越高的线程,获得 CPU 执行权的机会就越大。
3.1.2 代码示例
Thread t1 = new Thread("飞机");
Thread t2 = new Thread("坦克");
// 设置优先级
t1.setPriority(Thread.MAX_PRIORITY);
t2.setPriority(Thread.MIN_PRIORITY);t1.start();
t2.start();
3.1.3 注意事项
- 线程优先级只是一个提示,不是绝对的保证。高优先级的线程不一定总会先执行完。
- 操作系统的调度策略和电脑性能等因素都会影响线程的实际执行顺序,因此线程调度具有一定的随机性。
3.2 线程让步:yield()
3.2.1 概念介绍
Thread.yield()
方法的作用是让当前正在执行的线程暂停,并让出 CPU 执行机会给同等或更高优先级的线程。它能让线程的执行机会分配得更均匀,避免某个线程长时间独占 CPU。
3.2.2 代码示例
public class ThreadDemo {public static void main(String[] args) {Thread t = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 15; i++) {System.out.println(Thread.currentThread().getName()+"-->"+i);Thread.yield();}}});t.setName("t");t.start();//主线程for (int i = 0; i < 15; i++) {System.out.println(Thread.currentThread().getName()+"-->"+i);}}
}
3.3 守护线程:setDaemon()
3.3.1 概念介绍
守护线程(Daemon Thread)是一种特殊的线程,它的存在是为了服务其他非守护线程。当 JVM 中所有非守护线程都执行完毕时,JVM 就会自动退出,不管守护线程是否还在运行。
你可以把它想象成一个“备胎”或后台服务。例如,垃圾回收器(GC)就是一个典型的守护线程。
3.3.2 代码示例
public class MyThread1 extends Thread{@Overridepublic void run(){for(int i = 1;i <= 10;i++){System.out.println(getName() + "@" + i);}}
}
public class MyThread2 extends Thread{@Overridepublic void run(){for(int i = 1;i <= 100;i++){System.out.println(getName() + "@" + i);}}
}
public class ThreadDemo{public static void main(String[] args){MyThread1 t1 = new MyThread1();MyThread2 t2 = new MyThread2();t1.setName("女神");t2.setName("备胎");//把第二个线程设置为守护线程【备胎线程】t2.setDaemon(true);t1.start();t2.start();}
}
3.4 插入线程:join()
3.4.1 概念介绍
join()
方法允许一个线程等待另一个线程执行完毕。当一个线程调用 t.join()
时,当前线程(比如 main
线程)会进入阻塞状态,直到 t
线程执行完毕,才会继续执行。
3.4.2 代码示例
public class MyThread extends Thread{@Overridepublic void run(){for(int i = 1;i <= 100;i++){System.out.println(getName() + "@" + i);}}
}
public class ThreadDemo{public static void main(String[] args){MyThread t = new MyThread();t.setName("土豆");t.start();// main 线程等待子线程 t 执行完毕t.join();// 当 t 线程执行完后,main 线程才继续执行for (int i = 0; i < 10; i++) {System.out.println("main线程 --> " + i);}}
}
4.线程的生命周期
线程的生活周期中,它有五种状态:
- 新建(New):线程对象被创建,但还未调用
start()
方法。 - 就绪(Runnable):调用
start()
后,线程进入可运行队列,等待被 CPU 调度。 - 运行中(Running):线程被 CPU 选中,开始执行
run()
方法中的代码。 - 阻塞(Blocked):线程由于某种原因(如调用
sleep()
、join()
、等待锁等)暂停执行。 - 死亡(Dead):线程的
run()
方法执行完毕,或因为异常终止。
5.线程安全的问题
5.1 问题的引入:为什么会出现线程安全问题?
当多个线程同时访问和操作同一个共享数据时,如果没有正确的同步机制,就可能导致数据不一致或错误的结果。这就是线程安全问题。
例如,在电影院卖票的例子中,ticket
变量是所有窗口线程共享的。当一个线程(如“窗口1”)判断 ticket < 100
为真后,正准备执行 ticket++
时,另一个线程(如“窗口2”)可能也进入了 if
语句,并且也对 ticket
进行了操作。这就可能导致:
- 同一张票被卖多次:两个或多个线程同时拿到相同的
ticket
值。 - 超出范围的票数:最终
ticket
的值可能大于 100。
示例代码(存在线程安全问题)
public class MyThread extends Thread {// 共享数据:所有线程共享这一个静态变量static int ticket = 0;@Overridepublic void run() {while (true) {if (ticket < 100) {// 模拟业务操作耗时try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}// 这三行代码不是一个原子操作,可能被多个线程同时执行ticket++;System.out.println(getName() + "正在卖第" + ticket + "张票!");} else {break;}}}
}//...创建并开启线程
执行结果
5.2 解决方案:同步机制
为了解决线程安全问题,我们需要确保在任何时刻,只有一个线程能够访问和操作共享数据。这可以通过**同步(Synchronization)**来实现。Java 提供了多种同步机制。
5.2.1 同步代码块(synchronized
)
同步代码块是 Java 中最基本的同步机制。它通过一个锁对象来控制代码的执行。
- 语法:
synchronized(锁对象) { 操作共享数据的代码 }
- 工作原理:
- 当一个线程进入同步代码块时,会自动获取指定的锁。
- 如果锁已被其他线程持有,当前线程就会进入阻塞状态,直到锁被释放。
- 当线程执行完同步代码块中的所有代码,锁会自动释放。
- 锁对象:锁对象必须是唯一的。在卖票的例子中,
static
变量是共享的,因此锁对象也必须是共享的,一般使用static final Object obj = new Object();
或类名.class
。
示例代码(使用同步代码块)
public class MyThread extends Thread {static int ticket = 0;// 唯一的锁对象,所有线程共享static final Object lock = new Object();@Overridepublic void run() {while (true) {// 同步代码块,锁住操作共享数据的代码synchronized (lock) {if (ticket < 100) {try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}ticket++;System.out.println(getName() + "正在卖第" + ticket + "张票!");} else {break;}}}}
}
5.2.2 同步方法
- 同步方法是
synchronized
关键字的另一种用法,它直接作用于方法上,将整个方法体作为同步区域。- 语法:
修饰符 synchronized 返回值类型 方法名(...) { ... }
- 锁对象:
- 非静态方法:锁对象是当前实例对象,即
this
。 - 静态方法:锁对象是当前类的字节码文件对象,即
类名.class
。
- 非静态方法:锁对象是当前实例对象,即
- 语法:
示例代码(使用同步方法)
public class MyRunnable implements Runnable {static int ticket = 0;@Overridepublic void run() {while (true) {// 在这里调用同步方法if (sellTicket()) {break;}}}// 静态同步方法,锁对象是 MyRunnable.classprivate synchronized static boolean sellTicket() {if (ticket == 100) {return true;} else {try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}ticket++;System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票!");return false;}}
}
5.2.3 Lock
接口
从 JDK 5 开始,Java 提供了 Lock
接口,它提供了比 synchronized
更灵活、更强大的锁定操作。
- 使用方式:
- 创建一个
Lock
实例,通常使用其实现类ReentrantLock
。 - 在需要同步的代码块前调用
lock.lock()
来手动上锁。 - 在代码块后调用
lock.unlock()
来手动释放锁。通常将其放在finally
块中,以确保锁总能被释放,避免死锁。
- 创建一个
- 优点:
Lock
提供了更细粒度的控制,例如尝试非阻塞地获取锁、可中断地获取锁等,这些是synchronized
不具备的。
示例代码(使用 ReentrantLock
)
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class MyRunnable implements Runnable {static int ticket = 0;// 创建一个静态的锁对象static final Lock lock = new ReentrantLock();@Overridepublic void run() {while (true) {// 手动上锁lock.lock();try {if (ticket == 100) {break;} else {try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}ticket++;System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票!");}} finally {// 在 finally 块中手动解锁,保证锁一定会被释放lock.unlock();}}}
}
5.3 延伸:StringBuffer
与 StringBuilder
StringBuffer
:线程安全。它的大多数方法都使用了synchronized
关键字进行修饰,因此适用于多线程环境。StringBuilder
:线程不安全。它的方法没有加锁,因此在单线程环境下,其性能比StringBuffer
更高。
部分源码如下
6.线程死锁
6.1 什么是死锁?
死锁是一种特殊的情况,当两个或多个线程在执行过程中,因争夺资源而造成的一种相互等待的现象,若无外力干涉,它们都将无法继续执行。
你可以把死锁想象成两个人都想过独木桥,但他们各自都站在了桥的一头,谁也不肯后退,最终谁也过不去。
6.2 死锁的四个必要条件
- 互斥条件(Mutual Exclusion):某个资源在同一时刻只能被一个线程占用。
- 请求与保持条件(Hold and Wait):当一个线程持有(占有)一个资源时,又去请求另一个它所需的资源,而该资源正被其他线程占用。
- 不剥夺条件(No Preemption):线程已获得的资源在未使用完之前,不能被强行剥夺,只能由该线程自己释放。
- 循环等待条件(Circular Wait):在发生死锁时,必然存在一个线程–资源的循环链。例如,线程
A
等待B
,线程B
等待C
,而线程C
又在等待A
。
6.3 示例代码(死锁场景)
- 线程
t1
:先尝试获取dress
锁,然后尝试获取trousers
锁。 - 线程
t2
:先尝试获取trousers
锁,然后尝试获取dress
锁。
如果 t1
获取了 dress
锁,而 t2
同时获取了 trousers
锁,那么 t1
就会因为无法获取 trousers
锁而等待,而 t2
也因为无法获取 dress
锁而等待。双方都持有对方想要的资源,并且都在等待对方释放资源,这就形成了死锁。
public class Thread_DeadLock {public static void main(String[] args) {Object dress = new Object();Object trousers = new Object();Thread t1 = new Thread(() -> {// t1 线程:先锁住 dress,再尝试锁住 trouserssynchronized (dress) {System.out.println("t1 拿到了衣服...");try {Thread.sleep(100); // 模拟耗时,增加死锁概率} catch (InterruptedException e) {e.printStackTrace();}synchronized (trousers) {System.out.println("t1 也拿到了裤子,成功穿戴!");}}}, "t1");Thread t2 = new Thread(() -> {// t2 线程:先锁住 trousers,再尝试锁住 dresssynchronized (trousers) {System.out.println("t2 拿到了裤子...");try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}synchronized (dress) {System.out.println("t2 也拿到了衣服,成功穿戴!");}}}, "t2");t1.start();t2.start();}
}
6.4 死锁的预防
要避免死锁,最有效的方法是破坏上述四个必要条件中的任意一个。
在代码中,若必须有锁的嵌套,我们通常通过改变获取锁的顺序来破坏循环等待条件。例如,让所有线程都以相同的顺序获取锁(先 dress
再 trousers
),就能避免死锁。
7.生产者和消费者
7.1 等待/唤醒机制 (wait()
/ notify()
)
生产者-消费者模式是一种经典的多线程协作模式,用于解决生产者线程与消费者线程之间的数据传输问题。这里引入一个厨师做菜,顾客在桌子上吃餐的情况。
- 生产者(
Cook
):负责生产数据。 - 消费者(
Foodie
):负责消费数据。 - 共享区域(
Desk
):生产者放置数据、消费者获取数据的共享空间。
工作流程:
- 当
Desk
上有食物:- 生产者:等待,直到食物被取走。
- 消费者:取走食物,并唤醒生产者继续制作。
- 当
Desk
上没有食物:- 生产者:制作食物,并唤醒消费者前来享用。
- 消费者:等待,直到有食物可吃。
wait()
/ notify()
方法:
方法名称 | 说明 |
---|---|
void wait() | 使当前线程进入等待状态,并释放锁,直到被其他线程唤醒 |
void notify() | 随机唤醒一个等待该锁的线程。 |
void notifyAll() | 唤醒所有等待该锁的线程。 |
注意:wait()
、notify()
和 notifyAll()
只能在同步代码块或同步方法中使用,并且它们操作的锁对象必须与同步块中的锁对象一致
示例代码
public class ThreadDemo {public static void main(String[] args) {Cook c = new Cook();Foodie f = new Foodie();c.setName("厨师");f.setName("吃货");c.start();f.start();}
}// 共享资源类
class Desk {public static int foodFlag = 0; // 0: 无食物,1: 有食物public static int count = 10; // 总供应量public static final Object lock = new Object(); // 锁对象
}// 生产者线程
class Cook extends Thread {@Overridepublic void run() {while (true) {synchronized (Desk.lock) {if (Desk.count == 0) {break;} else {if (Desk.foodFlag == 1) { // 如果有食物,等待try {Desk.lock.wait();} catch (InterruptedException e) {e.printStackTrace();}} else { // 如果没有食物,制作System.out.println("厨师做了一碗面条");Desk.foodFlag = 1;Desk.lock.notifyAll(); // 唤醒消费者}}}}}
}// 消费者线程
class Foodie extends Thread {@Overridepublic void run() {while (true) {synchronized (Desk.lock) {if (Desk.count == 0) {break;} else {if (Desk.foodFlag == 0) { // 如果没有食物,等待try {Desk.lock.wait();} catch (InterruptedException e) {e.printStackTrace();}} else { // 如果有食物,享用Desk.count--;System.out.println("吃货吃了一碗面条,还能再吃" + Desk.count + "碗");Desk.foodFlag = 0;Desk.lock.notifyAll(); // 唤醒生产者}}}}}
}
7.2 阻塞队列方式 (BlockingQueue
)
在实际开发中,使用 wait()
/ notify()
机制实现生产者-消费者模式比较繁琐,且容易出错。Java 5 引入的并发包(java.util.concurrent
)提供了阻塞队列(BlockingQueue
),它完美解决了生产者-消费者模式中的同步问题,无需手动管理锁和线程的等待、唤醒。
阻塞队列的特点:
- 当队列满时,生产者线程的
put()
方法会被阻塞,直到队列有空闲位置。 - 当队列空时,消费者线程的
take()
方法会被阻塞,直到队列中有数据。
这种阻塞机制使得生产者和消费者线程能够自动地进行协作,无需你编写复杂的 synchronized
、wait()
或 notify()
代码。
核心方法:
put(E e)
:将元素放入队列,如果队列满则阻塞。take()
:从队列中取出元素,如果队列空则阻塞。
常用实现类:
ArrayBlockingQueue
:基于数组的有界队列。LinkedBlockingQueue
:基于链表的有界队列。
代码示例(基于阻塞队列)
import java.util.concurrent.ArrayBlockingQueue;public class ThreadDemo {public static void main(String[] args) {// 创建一个容量为1的阻塞队列ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1);Cook c = new Cook(queue);Foodie f = new Foodie(queue);c.start();f.start();}
}// 生产者
class Cook extends Thread {private final ArrayBlockingQueue<String> queue;public Cook(ArrayBlockingQueue<String> queue) {this.queue = queue;}@Overridepublic void run() {while (true) {try {// 生产一个面条并放入队列,如果队列满则阻塞queue.put("面条");System.out.println("厨师放了一碗面条");} catch (InterruptedException e) {e.printStackTrace();}}}
}// 消费者
class Foodie extends Thread {private final ArrayBlockingQueue<String> queue;public Foodie(ArrayBlockingQueue<String> queue) {this.queue = queue;}@Overridepublic void run() {while (true) {try {// 从队列中取面条,如果队列空则阻塞String food = queue.take();System.out.println("吃货吃了一碗面条:" + food);} catch (InterruptedException e) {e.printStackTrace();}}}
}
8.多线程的六种状态
状态名称 | 描述 | 触发条件或方法 |
---|---|---|
新建 (NEW) | 线程对象刚被创建,但还未调用 start() 方法。 | new Thread() |
可运行 (RUNNABLE) | 线程已调用 start() ,进入就绪队列,等待操作系统分配 CPU 时间片。 | start() 方法 |
阻塞 (BLOCKED) | 线程在等待获取一个监视器锁(synchronized ),以进入同步代码块或方法。 | 无法获得 synchronized 锁 |
等待 (WAITING) | 线程无限期地等待另一个线程执行特定的操作。它会放弃 CPU 执行权和锁。 | Object.wait() 、Thread.join() |
计时等待 (TIMED_WAITING) | 线程在指定的时间内等待另一个线程的操作。它会放弃 CPU 执行权。 | Thread.sleep() 、Object.wait(long timeout) |
终止 (TERMINATED) | 线程的 run() 方法执行完毕或因异常退出,线程生命周期结束。 | run() 方法执行完毕 |
图视
9.线程池
9.1 为什么需要线程池?
传统的多线程编程模式存在一些弊端:
- 频繁创建和销毁:每次执行任务都创建新线程,任务结束后销毁,这会带来额外的性能开销。
- 资源消耗大:过多的线程会导致系统资源(如内存)被大量占用,可能引发系统性能下降。
线程池的核心思想是**“复用”**。它在程序启动时就创建好一定数量的线程,并将这些线程保存在一个“池子”中。当有任务提交时,直接从池中取出空闲线程来执行;任务执行完毕后,线程不会被销毁,而是返回池中等待下一个任务。
9.2 线程池的执行流程
线程池会根据任务提交的顺序和池子的状态,自动管理线程的创建、复用和任务的排队。这个过程通常有三个临界点:
- 核心线程数量未满:当有新任务提交时,线程池会创建一个核心线程来执行任务。
- 核心线程已满,队列未满:如果核心线程都在忙碌,新提交的任务会进入任务队列中排队等待。
- 核心线程已满,队列已满,最大线程数量未满:如果任务队列也满了,线程池会创建临时线程(也叫非核心线程)来执行任务。
- 所有都已满:如果核心线程、任务队列和临时线程都已达到上限,线程池就会触发拒绝策略。
9.3 线程池的创建
9.3.1 使用 Executors
工具类
Executors:是一个线程池的工具类,提供了多种便捷的静态工厂方法来创建不同类型的线程池。
-
Executors.newCachedThreadPool()
:创建一个可缓存的线程池。如果线程池中的线程数量超过了处理任务所需的数量,一些空闲线程就会被回收。如果任务增多,线程池可以动态地增加线程,但其最大线程数量为Integer.MAX_VALUE
,这可能会导致资源耗尽。 -
Executors.newFixedThreadPool(int nThreads)
:创建一个固定大小的线程池。线程池的线程数量被固定为nThreads
。如果任务数量超过线程数,多余的任务会排队等待。
示例代码
public class MyRunnable implements Runnable{@Overridepublic void run(){for(int i = 1; i<= 100;i++){System.out.println(Thread.currentThead().getName() + "---" + i);}}}public class MyTheadPoolDemo{//1.获取线程池对象ExecutorService pool1 = Executors.newCachedThreadPool();ExecutorService pool2 = Executors.newFixedThreadPool(3);//线程池的最大线程数为3//提交任务pool1.submit(new MyRunnable);pool1.submit(new MyRunnable);}
9.3.2 自定义线程池
ThreadPoolExecutor
的构造方法有七个核心参数,可以用餐厅来类比:
参数 | 对应核心元素 | 含义 |
---|---|---|
corePoolSize | 正式员工数量 | 核心线程数。当任务提交时,线程池会优先创建此数量的线程来执行任务。 |
maximumPoolSize | 餐厅最大员工数 | 线程池允许创建的最大线程数。当任务队列满了,会创建临时线程,直到达到这个上限。 |
keepAliveTime | 临时员工空闲时间 | 临时线程在空闲多长时间后会被销毁。 |
unit | 时间单位 | keepAliveTime 的时间单位。 |
workQueue | 排队的顾客 | 用于存放等待执行任务的阻塞队列。 |
threadFactory | 从哪里招人 | 用于创建线程的工厂。 |
handler | 拒绝服务 | 当线程池和任务队列都满了,如何处理新任务。 |
自定义线程池示例代码
import java.util.concurrent.*;public class MyThreadPoolDemo {public static void main(String[] args) {// 创建一个自定义线程池ThreadPoolExecutor pool = new ThreadPoolExecutor(3, // 核心线程数6, // 最大线程数60, // 临时线程存活时间TimeUnit.SECONDS, // 时间单位new ArrayBlockingQueue<>(3), // 任务队列,容量为3Executors.defaultThreadFactory(), // 默认线程工厂new ThreadPoolExecutor.AbortPolicy()// 拒绝策略);// 提交任务for (int i = 0; i < 10; i++) {pool.submit(() -> {System.out.println(Thread.currentThread().getName() + " 正在执行任务...");});}}
}
9.4 线程池的拒绝策略
当线程池已满(核心线程、临时线程、任务队列都满了)时,新的任务将无法被执行,这时就需要拒绝策略来处理。
策略名称 | 说明 |
---|---|
AbortPolicy (默认) | 直接抛出异常 RejectedExecutionException 。 |
DiscardPolicy | 直接丢弃新任务,不抛出异常。 |
DiscardOldestPolicy | 丢弃任务队列中等待时间最久的任务,然后将新任务加入队列。 |
CallerRunsPolicy | 调用者线程自己执行任务,绕过线程池。 |