多线程
1. 线程Thread
- 一个程序内部的一条执行流程
- 程序中只有一条执行流程 — 单线程的程序
- 从软硬件上实现的多条执行流程的技术 — 多线程
- 多线程的好处:多个程序并发进行
2. 如何在程序中创建多个线程
⭐创建方式一:继承Thread
1)步骤:
① 创建MyThread类继承Thread
② 重写Thread的run方法:编写该线程要执行的程序
//1、掌握创建线程的方式一:继承Thread类来实现
public class MyThread extends Thread{
// 2、重写Thread类的run方法
@Override
public void run() {
// 3、在run方法中编写要执行的代码(线程要完成的任务)
for (int i = 0; i < 10; i++) {
System.out.print("\nThread1:" + i + "\n");
}
}
}
③ 创建MyThread类的对象
④ 启动线程start
// ---------------- 主函数 --------------
// 4、创建线程对象,代表线程
Thread t1 = new MyThread();
// 5、启动线程
t1.start();
// 主线程: main方法本身是由一条主线程负责推荐执行的
for (int i = 0; i < 10; i++) {
System.out.print("主线程:" + i);
}
🫣可以看到两个线程是并发执行的
2)优缺点:
- 优点:编码简单
- 缺点:线程类已经继承Thread,无法继承其他类(Java的类不支持多继承,只支持多层继承),不利于功能的扩展,线程有执行结果是不能直接返回的
3)注意事项
- 启动线程必须调用start方法 ---- 调用run方法还是单线程执行(被当成普通方法执行)
- 不要把主线程任务放在启动子线程之前 ---- 否则一直只有主线程先跑完
⭐创建方式二:实现Runnable接口 — 线程任务
🤔在创建Thread时作为参数传递,然后启动
1)步骤
① 定义一个线程任务类
MyRunnable实现Runnable接口
② 重写run方法
:编写该线程要执行的程序
// 1、方式二:实现Runnable接口
public class MyRunnable implements Runnable{
// 2、重写run方法
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.print("\tMyRunnable:" + i);
}
}
}
③ 创建MyRunnable任务对象
④ 把MyRunnable任务对象交给Thread处理
- 利用Thread提供的构造器将Runnable对象封装成为线程对象:
Thead(Runnable target)
⑤ 启动线程start
//3、创建线程任务类的对象代表一个线程任务
Runnable mr = new MyRunnable();
// 4、把线程任务对象交给一个线程对象来处理
// 把线程任务对象作为参数传递给Thread类的构造方法
Thread t2 = new Thread(mr);
// 5、启动线程
t2.start();
2)优缺点
- 优点:任务类只是实现接口,可以继续继承其他类,实现其他接口,扩展性强
- 缺点:需要额外多创建一个Runnable对象,且线程有执行结果是不能直接返回的
3)匿名内部类写法
① 创建Runnable的匿名内部类对象
② 再交给Thread线程对象
③ 调用线程对象的start()启动线程
/* new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++){
System.out.println("Thread3:" + i);
}
}
}).start();*/
// Lambda表达式
new Thread(() ->{
for (int i = 0; i < 10; i++){
System.out.println("Thread3:" + i);
}
}).start();
⭐创建方式三:实现Callable接口和FutureTask类来实现
💡前两种线程创建方式都存在问题:线程执行完成后不能直接返回结果(void)
1)步骤
① 创建任务对象
- 实现
Callable接口
,重写Call方法
,封装要做的事情,和要返回的数据
// 1.创建一个实现Callable接口的类
// 泛型接口:声明返回类型
public class MyCallable implements Callable<String> {
// 传参---成员变量
private int n;
public MyCallable(int n) {
this.n = n;
}
// 2、实现call方法,将此线程需要执行的操作声明在call方法中
@Override
public String call() throws Exception {
int sum = 0;
// 3、在call方法中编写要执行的代码(线程要完成的任务)
for (int i = 0; i < n; i++) {
System.out.println("MyCallable:" + i);
sum += i;
}
// 有返回值
return "1-" + this.n + "的计算结果为" + sum;
}
}
- 把Callable类型的对象
封装成FutureTask
(线程任务对象)- FutureTask对象:线程对象 + 线程执行完后的结果
② 把线程任务对象交给Thread对象
- 未来任务对象本质上是一个Runnable线程任务对象,所以可以交给Thread线程对象处理
③ 调用Thread对象的start方法
启动线程
// 4、创建Callable接口实现类的对象
Callable<String> myCallable = new MyCallable(10);
// 5、创建FutureTask对象,将Callable接口实现类的对象作为参数传递,将Callable对象封装成FutureTask对象
FutureTask<String> f1 = new FutureTask<>(myCallable);
// 6、创建Thread对象,将FutureTask对象作为Thread对象的target对象
Thread t1 = new Thread(f1);
// 7、调用Thread对象的start方法,开启线程,执行run方法
t1.start();
④ 线程执行完毕后,通过FutureTask对象的get方法
获取线程任务执行的结果
try {
// 8、调用FutureTask对象的get方法,获取线程执行完毕后的结果
// 如果线程没有执行完毕,get方法会阻塞当前线程,让出CPU,直到线程执行完毕,获取线程执行完毕后的结果
System.out.println(f1.get());
} catch (Exception e) {
e.printStackTrace();
}
创建两个线程的结果
2)优缺点
- 优点:
- 只是实现接口,还可以继承其他的类和接口
- 可以获取线程执行的结果
- 缺点:
- 编码复杂一点
3. 线程的常用方法
1)getName()
- 线程默认名:Thread-索引
- 主线程名(main函数):main
System.out.println(Thread.currentThread().getName()); // main
Thread t = new MyThread();
System.out.println(t.getName()); // Thread-0
2)currentThread() 获取当前线程:
哪个线程调用这段代码,就拿到哪个线程
3)setName(String name) 为线程设置名字:
启动前set设置/有参构造器设置
Thread t = new MyThread();
System.out.println(t.getName()); // Thread-0
// setName(String name):设置线程名称
t.setName("线程1");
System.out.println(t.getName()); // 线程1
t.start(); // 启动线程
⚠️启动前set设置线程名字 : 否则线程启动时,可能名字并没有变,只有在主线程中执行到了更改线程名字的代码,该线程的名字才会变更 ------ 该线程前后名字不一致
4)sleep() 线程休眠
5)join() 线程插队
// 线程插队
MyThread t1 = new MyThread();
t1.start();
for (int i = 0; i <= 10; i++) {
System.out.println(" " + Thread.currentThread().getName() + ":" + i);
if (i == 1) {
try {
t1.join();
} catch (Exception e) {
e.printStackTrace();
}
}
}
4. 线程安全(同步与互斥)
4.1 线程安全问题
- 多个线程,同时操作同一个共享资源的时候,可能会出现业务安全问题
💡1)线程问题出现的条件:
- 存在多个线程同时运行
- 同时访问同一个资源
- 存在修改该同一个资源的行为
2)模拟线程安全问题的场景:取钱
需求
- 小明和小红是一对夫妻,他们有一个共同的账户,余额是10万元,用程序模拟两人同时取钱10万元
分析:
- 小明和小红的共同财产 >> 设计一个账户类,创建一个账户对象,代表两人共同财产
- 模拟两人同时取钱 >> 设计一个线程类,创建并启动两个线程
根据结果给出了可能的程序执行的顺序之一,还有其他情况
4.2 线程同步 > 线程安全问题的解决方案
- 让多个线程先后依次访问共享资源,以避免出现线程安全问题
(只是在互斥访问资源时是先后执行,其他还是同步执行)
常见方案:加锁
- 每次只运行一个线程加锁,加锁后才能进入访问,访问完毕后自动解锁,然后其他线程才能再加锁进来
1)⭐同步代码块:使用synchronized进行加锁
synchronized(lock){
}
-
作用:
- 把访问共享资源的核心代码给上锁,以此保证线程安全
-
原理:
- 每次只允许一个线程加锁后进入,执行完毕后自动解锁,其他线程才可以进来执行
-
注意事项
- 对于当前同步执行的线程来说,同步锁必须是同一把锁
ctrl+Alt+T
// ------------------ 给资源区上锁 --------------------------------------
public void drawMoney(double money){
// 查看当前是谁来取钱
String name = Thread.currentThread().getName();
// 判断余额是否充足
// 加锁
synchronized ("account") {
if(this.banlance >= money){
// 余额充足,取钱
System.out.println(name + "取钱成功,取了" + money + "元");
// 更新余额
this.banlance -= money;
System.out.println(name + "取钱后余额为:" + this.banlance);
}else{
System.out.println(name + "取钱失败,余额不足");
}
}
}
💡锁对象如果选择一个唯一的对象,那么所有执行到该资源区的进程都会被锁住
- 使用规范:
- 建议使用共享资源作为锁对象,对于实例方法建议使用this作为锁对象
- 对于静态方法建议使用**字节码(类名.class)**对象作为锁对象
// ---------- 实例方法用this -------------------------
synchronized (this) {
if(this.banlance >= money){
// 余额充足,取钱
System.out.println(name + "取钱成功,取了" + money + "元");
// 更新余额
this.banlance -= money;
System.out.println(name + "取钱后余额为:" + this.banlance);
}else{
System.out.println(name + "取钱失败,余额不足");
}
}
2)⭐同步方法
修饰符 synchronized 返回值类型 方法名称(形参列表){
操作共享资源的代码
}
-
作用:
- 把访问共享资源的核心方法给上锁,以此保证线程安全
-
原理:
- 每次只能一个线程进入,执行完毕以后自动解锁,其他线程才可以进来执行
- 底层有隐式锁对象(与同步代码块的使用规范相同),范围是整个代码
public synchronized void drawMoney(double money){
....
}
3)⭐Lock锁
通过创建出锁对象进行加锁和解锁,更灵活、更方便、更强大
- Lock作为接口,不能直接实例化,需要使用它的实现类ReentrantLock来构建Lock锁对象
常用方法
- lock()上锁
- unlock()解锁
- ⚠️建议放到finally代码块中,确保锁用完后一定会被释放,避免资源区代码出现异常,导致执行不到释放锁的代码
- ⚠️建议锁对象进行final修饰,防止外部篡改,提高安全性
public void drawMoney(double money){
// 查看当前是谁来取钱
String name = Thread.currentThread().getName();
System.out.println(name + "来取钱");
// 判断余额是否充足
lock.lock(); // 获取锁
try {
if(this.banlance >= money){
// 余额充足,取钱
System.out.println(name + "取钱成功,取了" + money + "元");
// 更新余额
this.banlance -= money;
System.out.println(name + "取钱后余额为:" + this.banlance);
}else{
System.out.println(name + "取钱失败,余额不足");
}
} finally {
lock.unlock(); // 释放锁
}
}
5. 线程池
5.1 认识
-
线程池:一个可以复用线程的技术
-
不使用线程池 >>> 不断为新任务创建新线程处理 >>> 大量的线程开销大,影响系统的性能
工作原理:
固定线程数量,依次执行队列中的线程任务 >>> 餐厅中固定餐桌数,排队上桌就餐的客人
5.2 创建线程池 ExecutorService接口
⭐方式一:
1)使用ExecutorService
的实现类ThreadPoolExecutor
自创建一个线程池对象
// 1、使用线程池的实现类ThreadPoolExecutor声明七个参数来创建线程池对象
ExecutorService pool = new ThreadPoolExecutor(
3,
5,
3,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
💡参数五六七理解:
2)ExecutorService常用方法
- MyRunnable类
实现Runnable接口
public class MyRunnable implements Runnable{
// 重写run方法
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + "输出:" + i);
}
}
}
- 创建MyRunnable对象,在线程池中
添加Runnable任务
// 使用线程池对象来执行任务:看会不会复用线程
Runnable target = new MyRunnable();
pool.execute(target); // 提交第一个任务 创建线程 自动启动线程处理这个任务
pool.execute(target); // 提交第二个任务 创建线程 自动启动线程处理这个任务
pool.execute(target); // 提交第三个任务 创建线程 自动启动线程处理这个任务
pool.execute(target); // 提交第四个任务 workQueue任务队列 -- 复用线程
🤔创建的线程池的核心线程数corePoolSize是3,任务队列workQueue的容量是3,所以当任务队列未满时,核心线程处理完当前线程任务后就会复用线程,接着处理队列中的线程任务
- 关于只有一个Runnable实现类对象,但可以提交多个线程任务的理解
3)模拟线程池创建临时线程的场景
💡临时线程的创建时机
- 新任务提交时
- 核心线程都在忙
- 任务队列也满了
- 此时还可以创建临时线程
①修改MyRunnable类的run方法中调用sleep() 方法,模拟线程忙碌
- 区分线程任务和线程
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MyRunnable implements Runnable{
private String name; // 线程任务名 : 标记当前处理的是哪个任务
// 重写run方法
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(this.name + "输出:" + i); // 输出线程任务名+输出内容
try {
Thread.sleep(1000000000); //休眠1000000秒
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
②通过for循环创建多个线程任务对象,并加入到线程池中
- 核心线程3个,任务队列容量3个:当核心线程忙碌且任务队列满时,才创建临时线程 >>> 第七个线程任务加入时才会创建
// 1、使用线程池的实现类ThreadPoolExecutor声明七个参数来创建线程池对象
ExecutorService pool = new ThreadPoolExecutor(
3,
5,
3,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
// 2、使用线程池对象来执行任务:看会不会复用线程
for (int i = 1; i <= 8; i++) {
pool.execute(new MyRunnable("Task:" + i));
}
③结果解析
由结果可以看出,临时线程执行的不是任务队列中的,而是新加入的。
4)模拟任务拒绝的场景
💡任务拒绝策略
- 核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始拒绝任务
🤔核心线程3个、任务队列容量3个,临时线程数2个 >>> 超出 3 + 3 + 2 = 8个线程任务后,就会开始拒绝任务。由于pool创建时,采取的时AbortPolicy策略
,所以会抛出异常。
⭐方式二:
- 使用
Executors(线程池的工具类)
调用方法返回不同特点的线程池对象
ExecutorService pool = Executors.newFixedThreadPool(5); // 线程池大小固定,不会自动调整
// new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
ExecutorService pool2 = Executors.newCachedThreadPool(); // 线程池大小不固定,会根据任务数量自动调整
// new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
ExecutorService pool3 = Executors.newSingleThreadExecutor(); // 线程池大小固定,只有一个线程
// new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory));
ExecutorService pool4 = Executors.newScheduledThreadPool(5); // 线程池大小固定,可以定时
- 💡底层都是ExecutorService的实现类:ThreadPoolExecutor
6. 并发/并行
6.1 进程(动态)
- 正在运行的程序(软件)是一个独立的进程
- 线程是属于进程的,一个进程中可以同时运行很多个线程
6.2 并发
- CPU分时轮询的执行线程
6.3 并行
- 同一时刻同时在执行
6.4 多线程
- 并发和并行同时进行的