多线程、同步与线程池:共享状态如何保持正确
从并发存款出发,理解锁、等待条件、任务结果和线程池边界。
继续上次小节 →Java 进阶线 · 线路目录
Java 进阶线
对象与类型设计3 站
文件、配置与并发4 站
网络、框架与复习3 站
本页导读
day12|java.lang 包下的常用 API:多线程
本日围绕 java.lang.Thread 展开:先掌握线程的三种创建方式与 Thread 的常用 API,再重点理解「多个线程同时修改同一共享资源」为什么会出现业务错误,以及用同步代码块、同步方法、Lock 锁解决它的原理与区别;最后是线程通信(生产者消费者模型)、线程池(ExecutorService/ThreadPoolExecutor/Executors)、并发与并行、线程的生命周期。学完后应能判断一段多线程代码是否存在线程安全问题,并写出正确的加锁版本。
1. 线程与多线程是什么
1.1 线程与单线程程序
线程(Thread)是一个程序内部的一条执行流程。
public static void main(String[] args) {
// 代码…
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
// 代码...
}
上面的 main 方法本身由一条「主线程」负责执行。程序中如果只有一条执行流程,那这个程序就是单线程的程序。
(补充说明)main 方法由名为 main 的主线程执行,这也解释了为什么后面可以直接用 Thread.currentThread().getName() 打印出 main。
1.2 多线程是什么,用在哪里
多线程是指从软硬件上实现的多条执行流程的技术(多条线程由 CPU 负责调度执行)。
- 好处:多条流程可以「同时」推进,充分利用 CPU,提升吞吐与响应速度。
- 应用场景:下载软件的「同时下载、同时上传」、消息通信、淘宝/京东这类高并发系统,都离不开多线程技术。
Java 是通过 java.lang.Thread 类的对象来代表线程的,因此「创建多条线程」在代码里就是「创建多个 Thread 对象并启动」。
2. 多线程的创建方式一:继承 Thread 类
2.1 步骤
- 定义一个子类
MyThread继承线程类java.lang.Thread,重写run()方法; - 创建
MyThread类的对象; - 调用线程对象的
start()方法启动线程(启动后还是执行run()方法)。
2.2 代码
public class ThreadDemo1 {
// 注意:main 方法本身就是由一条主线程负责执行的。
public static void main(String[] args) {
// 目标:掌握线程的创建方式一:继承 Thread 类。
// 3、创建线程对象代表具体的线程
Thread t = new MyThread();
// 4、启动线程:会自动执行线程的 run 方法
// 如果直接调用 run,CPU 不会注册新线程执行,此时相当于还是单线程
t.start();
for (int i = 0; i < 4; i++) {
System.out.println("main线程输出:" + i);
}
}
}
// 1、定义一个类继承 Thread 类,成为线程类
class MyThread extends Thread {
// 2、重写 run 方法,声明线程要干的事情
@Override
public void run() {
for (int i = 0; i < 4; i++) {
System.out.println("子线程输出:" + i);
}
}
}
2.3 两个必须注意的点
- 启动线程必须是调用
start()方法,不是调用run()方法。直接调用run()会当成普通方法执行,此时相当于还是单线程执行;只有调用start()才是启动一个新的线程执行。否则主线程一直是先跑完的,相当于是一个单线程的效果了。 - 不要把主线程任务放在启动子线程之前。
2.4 方式一的优缺点
| 维度 | 说明 |
|---|---|
| 优点 | 编码简单 |
| 缺点 | 线程类已经继承 Thread,无法继承其他类,不利于功能的扩展(单继承的局限性) |
3. 多线程的创建方式二:实现 Runnable 接口
3.1 步骤
- 定义一个线程任务类
MyRunnable实现Runnable接口,重写run()方法; - 创建
MyRunnable任务对象; - 把
MyRunnable任务对象交给Thread处理; - 调用线程对象的
start()方法启动线程。
3.2 代码
public class ThreadDemo2 {
public static void main(String[] args) {
// 目标:掌握多线程的创建方式二:实现 Runnable 接口的方式。
// 3、创建任务类的一个对象
Runnable target = new MyRunnable();
// 4、把任务对象交给线程对象
// public Thread(Runnable target)
Thread t = new Thread(target, "1号");
// 5、启动线程
t.start();
for (int i = 0; i < 4; i++) {
System.out.println("main线程输出:" + i);
}
}
}
// 1、定义一个任务类实现 Runnable 接口
class MyRunnable implements Runnable {
// 2、重写 run 方法
@Override
public void run() {
for (int i = 0; i < 4; i++) {
System.out.println("子线程输出:" + i);
}
}
}
3.3 匿名内部类写法
可以创建 Runnable 的匿名内部类对象,再交给 Thread 线程对象,再调用线程对象的 start() 启动线程。
public class ThreadDemo2_2 {
public static void main(String[] args) {
// 1、创建任务类的一个对象
Runnable target = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 4; i++) {
System.out.println("子线程1输出:" + i);
}
}
};
Thread t = new Thread(target);
t.start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 4; i++) {
System.out.println("子线程2输出:" + i);
}
}
}).start();
// (补充说明)Runnable 是函数式接口,也可以用 Lambda 简化
new Thread(() -> {
for (int i = 0; i < 4; i++) {
System.out.println("子线程3输出:" + i);
}
}).start();
}
}
3.4 方式二的优缺点
| 维度 | 说明 |
|---|---|
| 优点 | 任务类只是实现接口,可以继续继承其他类、实现其他接口,扩展性强 |
| 缺点 | 需要多一个 Runnable 对象;线程有执行结果时不能直接返回 |
4. 多线程的创建方式三:Callable 接口 + FutureTask 类
4.1 为什么需要方式三
假如线程执行完毕后有一些数据需要返回,方式一、方式二重写的 run() 方法均不能直接返回结果。JDK 5.0 提供了 Callable 接口和 FutureTask 类来实现(多线程的第三种创建方式),这种方式最大的优点:可以返回线程执行完毕后的结果。
4.2 步骤
- 定义一个类实现
Callable接口,重写call()方法,封装要做的事情和要返回的数据; - 把
Callable类型的对象封装成FutureTask(线程任务对象); - 把线程任务对象交给
Thread对象; - 调用
Thread对象的start()方法启动线程; - 线程执行完毕后,通过
FutureTask对象的get()方法去获取线程任务执行的结果。
4.3 FutureTask 的 API
| 成员 | 说明 |
|---|---|
FutureTask(Callable<V> callable) | 把 Callable 对象封装成未来任务对象 |
V get() | 获取线程执行完毕后的结果;如果线程还没执行完,会在这里等待直到执行完毕再取结果 |
4.4 代码
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class ThreadDemo3 {
public static void main(String[] args) {
// 目标:掌握多线程的创建方式三:可以返回线程执行完毕后的结果。
// 3、创建 Callable 对象
Callable<String> call = new MyCallable(100);
// 4、把 Callable 对象,封装成 FutureTask 对象
// 未来任务对象有两个作用:
// 它是一个 Runnable 对象
// 它可以获取线程执行后的结果
FutureTask<String> task = new FutureTask<>(call);
// 5、把未来任务对象交给线程对象
Thread t = new Thread(task);
// 6、启动线程
t.start();
Callable<String> call2 = new MyCallable(200);
FutureTask<String> task2 = new FutureTask<>(call2);
Thread t2 = new Thread(task2);
t2.start();
try {
// 如果第一个线程没有执行完毕,会在这里等待第一个线程执行完毕后再取结果
String rs1 = task.get();
System.out.println(rs1);
} catch (Exception e) {
e.printStackTrace();
}
try {
String rs2 = task2.get();
System.out.println(rs2);
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 1、定义一个类实现 Callable 接口
class MyCallable implements Callable<String> {
private int n;
public MyCallable(int n) {
this.n = n;
}
// 2、重写 call 方法,声明任务和返回的结果
@Override
public String call() throws Exception {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return "子线程求和1-" + n + "的结果是:" + sum;
}
}
4.5 方式三的优缺点
| 维度 | 说明 |
|---|---|
| 优点 | 线程任务类只是实现接口,可以继续继承类和实现接口,扩展性强;可以在线程执行完毕后去获取线程执行的结果 |
| 缺点 | 编码复杂一点 |
4.6 三种创建方式对比
| 方式 | 入口方法 | 能否返回结果 | 能否再继承其他类 | 代码复杂度 |
|---|---|---|---|---|
继承 Thread | 重写 run() | 不能 | 不能(已单继承) | 简单 |
实现 Runnable | 重写 run() | 不能 | 能 | 较简单(多一个任务对象) |
实现 Callable + FutureTask | 重写 call() | 能,通过 FutureTask.get() | 能 | 复杂一点 |
5. Thread 的常用方法
5.1 常用方法一览
| 方法 | 说明 |
|---|---|
String getName() | 获取当前线程的名称,默认线程名称是 Thread-索引 |
void setName(String name) | 设置线程名称 |
public static Thread currentThread() | 返回对当前正在执行的线程对象的引用 |
public static void sleep(long time) | 让当前线程休眠指定的时间,单位为毫秒,时间到了会自动醒来继续执行 |
public final void join() | 让调用该方法的线程插队,执行完毕后其他线程再执行 |
5.2 线程名称与 currentThread
public class ThreadDemo1 {
public static void main(String[] args) {
Thread t1 = new MyThread("1号线程");
t1.start();
Thread t2 = new MyThread("2号线程");
t2.start();
// 这个代码是哪个线程在执行,就会得到哪个线程对象。
Thread m = Thread.currentThread();
m.setName("最牛的");
System.out.println(m.getName()); // main
for (int i = 0; i < 4; i++) {
System.out.println(m.getName() + "线程输出:" + i);
}
}
}
// 1、定义一个类继承 Thread 类,成为线程类
class MyThread extends Thread {
public MyThread(String name) {
super(name);
}
// 2、重写 run 方法,声明线程要干的事情
@Override
public void run() {
for (int i = 0; i < 4; i++) {
// 当前线程对象的名字
System.out.println(Thread.currentThread().getName() + " => 线程输出:" + i);
}
}
}
5.3 线程休眠 sleep
public class ThreadDemo2 {
public static void main(String[] args) throws Exception {
// 目标:掌握线程休眠,线程 join。
for (int i = 1; i <= 5; i++) {
System.out.println("输出:" + i);
// 作用:让当前线程执行的慢一点
Thread.sleep(1000); // 休眠 1s 后再执行
}
}
}
5.4 线程插队 join
public class ThreadDemo3 {
public static void main(String[] args) throws Exception {
// 目标:join 线程
Thread t = new MyThread2();
t.start();
for (int i = 0; i < 4; i++) {
System.out.println("main线程输出:" + i);
if (i == 2) {
t.join(); // 让 t 插队先执行完毕!
}
}
}
}
class MyThread2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 4; i++) {
System.out.println("子线程输出:" + i);
}
}
}
5.5 其他控制方法
Thread 类还提供了诸如 yield()、interrupt()、守护线程、线程优先级等线程的控制方法,在开发中很少使用,这些方法会后续需要用到的时候再讲解。
6. 线程安全:问题是什么
6.1 什么是线程安全问题
场景:小明和小红是一对夫妻,他们有一个共同的账户,余额是 10 万元。如果小明和小红同时来取钱,并且 2 人各自都在取钱 10 万元,可能会出现什么问题呢?
多个线程,同时操作同一个共享资源的时候,可能会出现业务安全问题。
6.2 取钱问题的执行过程
两个线程对同一个共享账户(余额 100000)分别执行下面三步:
| 步骤 | 小明线程 | 小红线程 |
|---|---|---|
| 1 | 判断余额是否足够 → true | 判断余额是否足够 → true |
| 2 | 吐出 100000 元 | 吐出 100000 元 |
| 3 | 更新账户余额 | 更新账户余额 |
结果:2 人都取钱 10 万,银行亏了 10 万。原因是两个线程都基于「取钱前的余额」做出了判断,CPU 在两条线程之间调度时把复合操作交错了。
6.3 出现线程安全问题的原因
- 存在多个线程在同时执行;
- 同时访问一个共享资源;
- 存在修改该共享资源。
三个条件同时满足时才会出现线程安全问题。
6.4 用程序模拟线程安全问题
需求:小明和小红是一对夫妻,他们有一个共同的账户,余额是 10 万元,模拟 2 人同时去取钱 10 万。
分析:
- 需要提供一个账户类,接着创建一个账户对象代表 2 个人的共享账户;
- 需要定义一个线程类(用于创建两个线程,分别代表小明和小红);
- 创建 2 个线程,传入同一个账户对象给 2 个线程处理;
- 启动 2 个线程,同时去同一个账户对象中取钱 10 万。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
private String cardId;
private double money; // 余额
// 小明 小红拿同一个账户对象 acc 取钱
public void drawMoney(double money) {
String name = Thread.currentThread().getName();
if (this.money >= money) {
System.out.println(name + "取钱成功,吐出:" + money);
// 更新余额
this.money -= money;
System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
} else {
System.out.println(name + "来取钱:余额不足!");
}
}
}
// 取钱线程类
public class DrawThread extends Thread {
private Account acc;
public DrawThread(String name, Account acc) {
super(name);
this.acc = acc;
}
// 小明 小红
@Override
public void run() {
// 小明 小红拿同一个账户对象 acc 取钱
acc.drawMoney(100000);
}
}
public class Test {
public static void main(String[] args) {
// 目标:模拟线程安全问题。
// 1、创建一个账户对象,两个人共享
Account acc = new Account("ICBC-110", 100000);
// 2、创建两个线程代表小明和小红,同时去同一个账户取钱。
new DrawThread("小明", acc).start();
new DrawThread("小红", acc).start();
}
}
(补充说明)该演示代码的结果不确定:可能出现「两人都取钱成功、账户余额为 -10 万」的错误数据,也可能其中一个线程先执行完而显示「余额不足」。
7. 线程同步
7.1 认识线程同步
线程同步是解决线程安全问题的方案,其思想是:让多个线程实现先后依次访问共享资源,这样就解决了安全问题。
线程同步的常见方案是「锁对象 + 加锁」:每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动解锁,然后其他线程才能再加锁进来。
7.2 方式一:同步代码块
作用:把访问共享资源的核心代码给上锁,以此保证线程安全。
synchronized (同步锁) {
访问共享资源的核心代码
}
原理:每次只允许一个线程加锁后进入,执行完毕后自动解锁,其他线程才可以进来执行。
同步锁的注意事项:对于当前同时执行的线程来说,同步锁必须是同一把(同一个对象),否则会出 bug。
锁对象的使用规范:
- 锁对象不要随便选择一个唯一的对象,不好,会影响其他无关线程的执行;
- 建议使用共享资源作为锁对象,对于实例方法建议使用
this作为锁对象; - 对于静态方法建议使用字节码(
类名.class)对象作为锁对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
private String cardId;
private double money; // 余额
// 小明 小红拿同一个账户对象 acc 取钱
public void drawMoney(double money) {
String name = Thread.currentThread().getName();
synchronized (this) {
if (this.money >= money) {
System.out.println(name + "取钱成功,吐出:" + money);
// 更新余额
this.money -= money;
System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
} else {
System.out.println(name + "来取钱:余额不足!");
}
}
}
}
小结:对出现问题的核心代码使用 synchronized 进行加锁,每次只能一个线程占锁进入访问。
7.3 方式二:同步方法
作用:把访问共享资源的核心方法给上锁,以此保证线程安全。
修饰符 synchronized 返回值类型 方法名称(形参列表) {
操作共享资源的代码
}
原理:每次只能一个线程进入,执行完毕以后自动解锁,其他线程才可以进来执行。
同步方法的底层原理:同步方法其实底层也是有隐式锁对象的,只是锁的范围是整个方法代码。
- 如果方法是实例方法:同步方法默认用
this作为锁对象; - 如果方法是静态方法:同步方法默认用
类名.class作为锁对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
private String cardId;
private double money; // 余额
// 小明 小红拿同一个账户对象 acc 取钱
public synchronized void drawMoney(double money) {
String name = Thread.currentThread().getName();
if (this.money >= money) {
System.out.println(name + "取钱成功,吐出:" + money);
// 更新余额
this.money -= money;
System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
} else {
System.out.println(name + "来取钱:余额不足!");
}
}
}
7.4 同步代码块还是同步方法
- 范围上:同步代码块锁的范围更小,同步方法锁的范围更大;
- 可读性:同步方法更好。
选择原则是「锁的范围尽量小」,同时保证被锁的范围覆盖了完整的复合操作(判断余额 + 扣款 + 写回余额)。
7.5 方式三:Lock 锁
Lock 锁是 JDK 5 开始提供的一个新的锁定操作,通过它可以创建出锁对象进行加锁和解锁,更灵活、更方便、更强大。Lock 是接口,不能直接实例化,可以采用它的实现类 ReentrantLock 来构建 Lock 锁对象。
Lock 的常用方法:
| 方法 | 说明 |
|---|---|
void lock() | 获得锁 |
void unlock() | 释放锁 |
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Data
@NoArgsConstructor
public class Account {
private String cardId;
private double money; // 余额
private final Lock lk = new ReentrantLock();
public Account(String cardId, double money) {
this.cardId = cardId;
this.money = money;
}
// 小明 小红拿同一个账户对象 acc 取钱
public void drawMoney(double money) {
String name = Thread.currentThread().getName();
try {
lk.lock(); // 加锁
if (this.money >= money) {
System.out.println(name + "取钱成功,吐出:" + money);
// 更新余额
this.money -= money;
System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
} else {
System.out.println(name + "来取钱:余额不足!");
}
} finally {
lk.unlock(); // 解锁
}
}
}
(补充说明)unlock() 必须放在 finally 中,保证加锁后的代码出现异常时锁依然会被释放;锁对象应作为共享资源(账户对象)的成员,不能让每条线程各自 new 一把。
8. 线程通信【了解】
8.1 什么是线程通信
当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以相互协调,并避免无效的资源争夺。
线程通信的常见模型是「生产者与消费者模型」:生产者线程负责生产数据,消费者线程负责消费生产者生产的数据。
注意:生产者生产完数据应该等待自己,通知消费者消费;消费者消费完数据也应该等待自己,再通知生产者生产!
8.2 案例:包子铺
需求:3 个生产者线程负责生产包子,每个线程每次只能生产 1 个包子放在桌子上;2 个消费者线程负责吃包子,每人每次只能从桌子上拿 1 个包子吃。
@Data
public class Desk {
private String data; // 放包子数据的地方
// 消费者:小懒猪 小萝莉
public synchronized void get() throws Exception {
String name = Thread.currentThread().getName();
if (data == null) {
// 没有包子。暂停自己,唤醒别人
this.notifyAll();
this.wait();
} else {
// 有包子,开始吃
System.out.println(name + "吃了:" + data);
data = null;
this.notifyAll();
this.wait();
}
}
// 生产者:大肚婆 小李子 狗不理
public synchronized void put() throws Exception {
String name = Thread.currentThread().getName();
if (data == null) {
// 没有包子。做包子
data = name + "做的肉包子";
System.out.println(name + "生产了一个肉包子!");
this.notifyAll();
this.wait();
} else {
// 有包子
this.notifyAll();
this.wait();
}
}
}
// 生产者线程
public class MakeThread extends Thread {
private Desk desk;
public MakeThread(Desk desk, String name) {
super(name);
this.desk = desk;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
desk.put();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
// 消费者线程
public class ConsumerThread extends Thread {
private Desk desk;
public ConsumerThread(Desk desk, String name) {
super(name);
this.desk = desk;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
desk.get();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
public class Test {
public static void main(String[] args) {
// 目标:线程通信。
// 1、竞争一个桌子
Desk desk = new Desk();
// 2、创建 2 个消费者线程
new ConsumerThread(desk, "小懒猪").start();
new ConsumerThread(desk, "小萝莉").start();
// 3、创建 3 个生产者线程
new MakeThread(desk, "大肚婆").start();
new MakeThread(desk, "小李子").start();
new MakeThread(desk, "狗不理").start();
}
}
8.3 Object 类的等待和唤醒方法
| 方法 | 说明 |
|---|---|
void wait() | 让当前线程等待并释放所占锁,直到另一个线程调用 notify() 或 notifyAll() |
void notify() | 唤醒正在等待的单个线程 |
void notifyAll() | 唤醒正在等待的所有线程 |
注意:上述方法应该使用当前同步锁对象进行调用。
(补充说明)wait()/notify()/notifyAll() 定义在 Object 中,原因是它们必须由「持有该锁的线程」调用,而任意对象都可以用作同步锁;因此上面的代码写作 this.wait()、this.notifyAll()(this 即同步锁对象)。
9. 线程池
9.1 认识线程池
不使用线程池的问题:用户每发起一个请求,后台就需要创建一个新线程来处理,下次新任务来了肯定又要创建新线程处理,而创建新线程的开销是很大的,并且请求过多时,肯定会产生大量的线程出来,这样会严重影响系统的性能。
线程池就是一个可以复用线程的技术。
线程池的工作原理:任务(Runnable/Callable)提交后先进入任务队列(WorkQueue),线程池中的工作线程(WorkThread)从队列中取任务执行,线程执行完任务后不销毁,而是回到池中复用。
9.2 如何得到线程池对象
JDK 5.0 起提供了代表线程池的接口:ExecutorService。
- 方式一:使用
ExecutorService的实现类ThreadPoolExecutor自创建一个线程池对象; - 方式二:使用
Executors(线程池的工具类)调用方法返回不同特点的线程池对象。
9.3 ThreadPoolExecutor 的构造器与七个参数
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory,
RejectedExecutionHandler handler)
| 参数 | 含义 | 课件的形象比喻 |
|---|---|---|
corePoolSize | 指定线程池的核心线程的数量 | 正式工:3 |
maximumPoolSize | 指定线程池的最大线程数量 | 最大员工数:5(正式工 3 + 临时工 2) |
keepAliveTime | 指定临时线程的存活时间 | 临时工空闲多久被开除 |
unit | 指定临时线程存活的时间单位(秒、分、时、天) | — |
workQueue | 指定线程池的任务队列 | 客人排队的地方 |
threadFactory | 指定线程池的线程工厂 | 负责招聘员工的(HR) |
handler | 指定线程池的任务拒绝策略(线程都在忙,任务队列也满了的时候,新任务来了该怎么处理) | 忙不过来咋办? |
9.4 线程池的注意事项
- 临时线程什么时候创建?新任务提交时发现核心线程都在忙,任务队列也满了,并且还可以创建临时线程,此时才会创建临时线程。
- 什么时候会开始拒绝新任务?核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始拒绝任务。
9.5 线程池处理 Runnable 任务
使用 ExecutorService 的方法:void execute(Runnable target)。
public class ThreadPoolExecutorDemo1 {
public static void main(String[] args) {
// 目标:创建线程池对象,处理 Runnable 任务
// 1、创建线程池
ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());
// 2、处理任务
Runnable target = new MyRunnable();
pool.execute(target); // 自动创建线程,并处理此任务
pool.execute(target); // 自动创建线程,并处理此任务
pool.execute(target); // 自动创建线程,并处理此任务
pool.execute(target); // 核心线程已满,进入任务队列
pool.execute(target); // 核心线程已满,进入任务队列
pool.execute(target); // 核心线程已满,进入任务队列
pool.execute(target); // 到了临时线程的时机了
pool.execute(target); // 到了临时线程的时机了
pool.execute(target); // 到了任务拒绝策略了!
// 3、线程池没有死亡。
// pool.shutdownNow(); // 立即关闭,不管任务是否完成!!返回没有执行完的任务
// pool.shutdown(); // 等待全部任务执行完毕后,再关闭
}
}
public class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println(Thread.currentThread().getName() + "输出:" + i);
System.out.println(Thread.currentThread().getName() + "线程进入休眠!~");
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
9.6 线程池处理 Callable 任务
使用 ExecutorService 的方法:Future<T> submit(Callable<T> command),返回的未来任务对象用于获取任务执行完后的结果。
public class ThreadPoolExecutorDemo2 {
public static void main(String[] args) {
// 1、创建线程池
ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());
// 2、处理任务
Future<String> f1 = pool.submit(new MyCallable(100));
Future<String> f2 = pool.submit(new MyCallable(200));
Future<String> f3 = pool.submit(new MyCallable(300));
try {
String s = f1.get();
System.out.println(s);
} catch (Exception e) {
e.printStackTrace();
}
try {
String s = f2.get();
System.out.println(s);
} catch (Exception e) {
e.printStackTrace();
}
try {
String s = f3.get();
System.out.println(s);
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 1、定义一个类实现 Callable 接口
public class MyCallable implements Callable<String> {
private int n;
public MyCallable(int n) {
this.n = n;
}
// 2、重写 call 方法,声明任务和返回的结果
@Override
public String call() throws Exception {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return Thread.currentThread().getName() + "求和1-" + n + "的结果是:" + sum;
}
}
9.7 ExecutorService 的常用方法
| 方法 | 说明 |
|---|---|
void execute(Runnable command) | 执行 Runnable 任务,没有返回值 |
Future<T> submit(Callable<T> task) | 执行 Callable 任务,返回未来任务对象用于获取线程执行结果 |
void shutdown() | 等任务执行完毕后关闭线程池 |
9.8 新任务拒绝策略
| 策略 | 行为 |
|---|---|
ThreadPoolExecutor.AbortPolicy | 丢弃任务并抛出 RejectedExecutionException 异常(默认策略) |
ThreadPoolExecutor.DiscardPolicy | 丢弃任务,但是不抛出异常(不推荐) |
ThreadPoolExecutor.DiscardOldestPolicy | 抛弃队列中等待最久的任务,然后把当前任务加入队列 |
ThreadPoolExecutor.CallerRunsPolicy | 由主线程负责调用任务的 run() 方法,从而绕过线程池直接执行 |
9.9 Executors 工具类实现线程池
Executors 是一个线程池的工具类,提供了很多静态方法用于返回不同特点的线程池对象。
| 方法 | 特点 |
|---|---|
public static ExecutorService newFixedThreadPool(int nThreads) | 创建固定线程数量的线程池,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程替代它 |
public static ExecutorService newSingleThreadExecutor() | 创建只有一个线程的线程池对象,如果该线程出现异常而结束,那么线程池会补充一个新线程 |
public static ExecutorService newCachedThreadPool() | 线程数量随着任务增加而增加,如果线程任务执行完毕且空闲了 60s 则会被回收掉 |
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) | 创建一个线程池,可以实现在给定的延迟后运行任务,或者定期执行任务 |
注意:这些方法的底层,都是通过线程池的实现类 ThreadPoolExecutor 创建的线程池对象。
public class ExecutorsDemo3 {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(3);
// 2、处理任务
Future<String> f1 = pool.submit(new MyCallable(100));
Future<String> f2 = pool.submit(new MyCallable(200));
Future<String> f3 = pool.submit(new MyCallable(300));
try {
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());
} catch (Exception e) {
e.printStackTrace();
}
}
}
9.10 Executors 使用可能存在的陷阱
大型并发系统环境中使用 Executors 如果不注意可能会出现系统风险。对此,阿里巴巴 Java 开发手册中有强制要求:
- 【强制】线程池不允许使用
Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。CachedThreadPool和ScheduledThreadPool:允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
结论:Executors 不适合做大型互联网场景的线程池方案,建议使用 ThreadPoolExecutor 来指定线程池参数,这样可以明确线程池的运行规则,规避资源耗尽的风险。
10. 其它细节:并发与并行
10.1 进程与线程
正在运行的程序(软件)就是一个独立的进程。线程是属于进程的,一个进程中可以同时运行很多个线程。进程中的多个线程其实是并发和并行执行的。
10.2 并发的含义
进程中的线程是由 CPU 负责调度执行的,但 CPU 能同时处理线程的数量有限,为了保证全部线程都能往前执行,CPU 会轮询为系统的每个线程服务,由于 CPU 切换的速度很快,给我们的感觉这些线程在同时执行,这就是并发。并发对应的是单核 CPU 的场景。
10.3 并行的理解
并行指的是在同一个时刻上,同时有多个线程在被 CPU 调度执行(例如 4 核 CPU 上同时调度 4 条线程)。并行需要多核 CPU 的支持。
10.4 小结
- 并发:CPU 分时轮询的执行线程;
- 并行:同一个时刻同时在执行。
11. 线程的生命周期
11.1 Java 线程的状态
线程的生命周期,也就是线程从生到死的过程中,经历的各种状态及状态转换。理解线程这些状态有利于提升并发编程的理解能力。Java 总共定义了 6 种状态,6 种状态都定义在 Thread 类的内部枚举类中。
public class Thread {
// ...
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
// ...
}
11.2 6 种状态的互相转换
| 起点 | 触发条件 | 到达状态 |
|---|---|---|
| New 新建 | start() | Runnable 可运行 |
| Runnable 可运行 | 执行完毕 / 出现异常 | Terminated 被终止 |
| Runnable 可运行 | 未获得锁对象 | Blocked 锁阻塞 |
| Blocked 锁阻塞 | 获得锁对象 | Runnable 可运行 |
| Runnable 可运行 | 获得锁对象后调用了 wait() | Waiting 无限等待 |
| Waiting 无限等待 | 被其他线程 notify 并获得锁对象 | Runnable 可运行 |
| Waiting 无限等待 | 被其他线程 notify,但未获得锁对象 | Blocked 锁阻塞 |
| Runnable 可运行 | 1、sleep(毫秒) 2、wait(毫秒) | Timed Waiting 计时等待 |
| Timed Waiting 计时等待 | 1、sleep 时间到 2、wait 时间到,并得到锁对象 3、wait 时间没到,被其他线程 notify,并得到锁对象 | Runnable 可运行 |
| Timed Waiting 计时等待 | 1、wait 时间到,未获得锁对象 2、wait 时间没到,被其他线程 notify,未获得锁对象 | Blocked 锁阻塞 |
11.3 6 种状态总结
| 状态 | 名称 | 说明 |
|---|---|---|
NEW | 新建 | 线程刚被创建,还没有调用 start() |
RUNNABLE | 可运行 | 线程已经调用了 start(),正在等待 CPU 调度或正在执行 |
BLOCKED | 锁阻塞 | 线程等待获取锁对象(想进入同步代码块/同步方法但锁被别的线程持有) |
WAITING | 无限等待 | 线程调用了 wait(),需要其他线程 notify 唤醒并抢到锁才能继续 |
TIMED_WAITING | 计时等待 | 线程调用了 sleep(毫秒) 或 wait(毫秒),时间到了会自动转入下一状态 |
TERMINATED | 被终止 | 线程执行完毕或出现异常 |
(补充说明)课件把 RUNNABLE 归为「可运行」,它涵盖了操作系统层面的就绪与运行两种情形;Java 线程状态不区分这两者。
12. 作业与练习
作业文档:day12-多线程/作业/03_题目/day09每日练习.md(文件名沿用 day09,内容是多线程的 5 道加强训练/综合扩展练习,均要求使用同步方法或同步代码块保证数据安全)。
| 题目 | 要求 | 训练点 |
|---|---|---|
| 题目 1(加强训练) | 模拟简单银行系统:账户初始余额 1000 元,两个线程各存 10 次、每次存 100 元,不允许出现错误数据 | 账户类 Account、任务类 Transfer implements Runnable、synchronized 同步方法/同步代码块 |
| 题目 2(加强训练) | 小明和小王一起抄写 100 遍 HelloWorld,每人的惩罚次数与内容可变 | Runnable 方式创建线程 + 共享资源计数 + 同步 |
| 题目 3(综合扩展) | 抽奖箱中有 10 个奖品,「第一组」「第二组」两条线程随机抽取 | List 共享集合 + 死循环内嵌套 synchronized + 随机下标移除 |
| 题目 4(综合扩展) | 年会两个入口模拟 100 名员工入场并各发一张双色球彩票,统计每个入口人数 | 工具类生成双色球、任务类计数、同步保护共享集合 |
| 题目 5(综合扩展) | 4 条线程:2 条打印 1-52,2 条打印 A-Z,各自单独看为升序 | 一个任务类交给多条线程、同步代码块的范围设计 |
题目 1 参考要点:
public class Transfer implements Runnable {
private Account account;
public Transfer(Account account) {
this.account = account;
}
public void run() {
for (int i = 0; i < 10; i++) { // 向账户中存入 10 次钱
transfer(); // 调用同步方法
}
}
// 同步方法
public synchronized void transfer() {
account.deposit(100); // 向账户中存入 100 块钱
System.out.println(Thread.currentThread().getName()
+ " 存入100元后,账户的余额是: " + account.getLeftMoney());
}
}
public class Test01 {
public static void main(String[] args) {
Account account = new Account(1000);
Transfer transfer = new Transfer(account);
Thread t1 = new Thread(transfer, "小王");
Thread t2 = new Thread(transfer, "小李");
t1.start();
t2.start();
}
}
(片段)题目 1 也可改写为同步代码块,把 account.deposit(100) 与打印语句一起放进 synchronized (this) { ... } 中。
13. 复习要点
- 线程是程序内部的一条执行流程,
Thread对象代表线程,多条线程由 CPU 负责调度;main方法由主线程执行。 - 创建线程三种方式:继承
Thread(重写run())、实现Runnable(任务对象交给Thread)、实现Callable+FutureTask(用get()取结果)。启动线程必须调用start(),直接调run()等于单线程。 - 三种方式取舍:
Thread编码简单但有单继承局限;Runnable扩展性强但不能返回结果;Callable能返回结果但编码复杂。 - Thread 常用方法:
getName()/setName()、currentThread()、sleep(long)、join();yield、interrupt、守护线程、线程优先级在开发中很少使用。 - 线程安全问题出现的三个条件:多条线程同时执行 + 访问同一共享资源 + 存在修改该资源;「取钱」案例说明复合操作被交错会写坏数据。
- 同步代码块
synchronized (锁对象) { ... }:锁对象对同时执行的线程必须是同一把;实例方法建议用this,静态方法建议用类名.class。 - 同步方法:在修饰符上加
synchronized,隐式锁对象同样是this或类名.class;锁的范围比同步代码块大,但可读性更好。 Lock锁(JDK 5 起,实现类ReentrantLock):lock()/unlock()手动加解锁,unlock()要放在finally中,更灵活但容易忘记释放。- 线程通信(生产者消费者模型):
wait()/notify()/notifyAll()来自Object,必须用当前同步锁对象调用。 - 线程池:
ExecutorService代表线程池,可用ThreadPoolExecutor自建(7 个参数:核心线程数、最大线程数、临时线程存活时间与单位、任务队列、线程工厂、拒绝策略),也可用Executors返回常用线程池;execute()处理Runnable,submit()处理Callable。 - 拒绝策略有
AbortPolicy(默认)、DiscardPolicy、DiscardOldestPolicy、CallerRunsPolicy;Executors创建的线程池队列或线程数可能无界,大型互联网场景应改用ThreadPoolExecutor明确规则。 - 并发是 CPU 分时轮询执行线程(单核场景),并行是同一时刻多条线程同时被执行(多核);线程有 6 种状态:
NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。