🥰🥰🥰来都来了,不妨点个关注叭!
👉博客主页:欢迎各位大佬!👈
文章目录
在多线程专栏中,介绍多线程的代码案例有:单例模式、阻塞队列、定时器,本期内容将介绍多线程中的第 4 个案例 —— 线程池,我们一起来看看是怎么回事吧!
1. 线程池含义和作用
线程池是什么呢?看到"池"这个字,我们很容易联想到,字符串常量池,数据库连接池等,有关于"池"这个概念,还是很常见的,池的目的是为了提高效率
线程的创建虽然比进程轻量,但是在频繁创建的情况下,开销也是不可忽略的!因此,希望进一步提高效率,有以下两种:
1)协程,即轻量级线程,目前Java标准库还不支持(C++是标准库层面支持,Java是第三库层面上支持,Go、Python是语法层面上支持,本期内容不作展开)
2)线程池
【线程池】是通过预先创建一定数量的线程,并将这些线程放入一个池中,当有任务需要执行时,线程池会从池中取出一个空闲的线程来执行该任务,而不是每来一个任务就创建一个新的线程,任务执行完毕后,线程并不会被销毁,而是返回线程池中等待下一个任务的到来,即提前把线程准备好,创建线程的时候不是直接从系统中申请,而是从池子里拿出来,等到线程不用了,归还给池子
线程池作用:线程池最大的好处即为减少每次创建、销毁线程的损耗
- 降低资源消耗:通过复用已存在的线程,减少线程创建和销毁的开销,从而节省CPU和内存资源
- 提高响应速度:当任务来时,可立即从线程池中取出空闲线程来执行任务,无需等待新线程的创建
- 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控
2. 线程池获取线程高效的原因
Q:为什么从线程池获取线程比从系统创建线程更高效呢?
A:从线程池获取线程,纯用户态操作;从系统创建线程,涉及到用户态和内核态之间的切换,纯用户态操作时间是可控的,但涉及到内核态操作,时间就不太可控了!
【补充】用户态和内核态相关知识
用户态,内核态是操作系统的基本概念
一个操作系统 = 内核 + 配套的应用程序
其中,内核是操作系统最核心的功能模块集合,如硬件管理,各种驱动,进程管理,内存管理,文件系统等等,而在这个内核需要给上层应用程序提供支持
比如,在完成打印 hello world 的操作,System.out.println("hello world");
,应用程序就要调用系统内核,告诉内核操作,我要进行一个打印字符的操作,内核再通过驱动程序,操作显示器,完成上述打印功能
但是同一时刻可能有很多个应用程序,而内核仅只有一个,内核要给这么多程序提供服务,因此,有时候就会导致服务不一定那么及时!
举一个生活中的栗子,以便我们更方便理解:
假如去银行办理业务,如果人很多的时候需要排队,依次办理,这里的大厅就相当于用户态,柜台就相当于内核态,假设该银行仅有 1 个柜台,首先排到的人先办理业务,她想办一张银行卡,只带了身份证却没有带身份证复印件,这个时候,工作人员给她两个方案:一是自己拿着身份证去大厅复印机复印,二是将身份证交给工作人员,由工作人员帮忙在柜台复印
这两种方案在效率上是有差异的:
1)自己去复印,就立即去复印立即回来了,中间不耽误,时间是可控的
2)工作人员去复印,比如工作人员现在肚子不舒服需要去上个厕所,或者是去打个水喝口水,总之工作人员可能还会干点别的事情,最后的结果肯定是可以复印的,但是就没有那么及时了,时间不太可控了~
3. Java 标准库中的线程池
3.1 ExecutorService 接口
Java 标准库中提供 ExecutorService,ExecutorService 在 Java 中是 java.util.concurrent(简称JUC)包下的一个接口,继承 Executor 接口,它代表一个异步执行机制,用于管理和执行异步任务
ExecutorService 接口定义了一组方法,用于管理线程池,比如:
-
提交任务:通过 ExecutorService,可以提交任务,比如 Runnable 或 Callable 实例,给线程池来异步执行,而不需要显式地创建和管理线程,通过
submit(Runnable task)
或submit(Callable<T> task)
提交任务给线程池执行 -
关闭线程池:通过
shutdown()
或shutdownNow()
方法来关闭线程池,shutdown() 方法会等待已提交的任务执行完成后再关闭线程池,而 shutdownNow() 方法会尝试停止所有正在执行的任务,并返回等待执行的任务列表 -
等待任务完成:通过
awaitTermination(long timeout, TimeUnit unit)
方法可以等待线程池中的所有任务完成,或者在指定的超时时间后返回
ExecutorService 的实现类主要有 ThreadPoolExecutor 和 ScheduledThreadPoolExecutor(在本期后续内容,将会继续介绍 ThreadPoolExecutor 类),这里先简单介绍一下这两个类:
- ThreadPoolExecutor类:一个灵活的线程池实现,允许自定义线程池的核心线程数、最大线程数、空闲线程存活时间、任务队列等参数
- ScheduledThreadPoolExecutor类: ThreadPoolExecutor 的一个子类,它支持在给定延迟后运行命令,或者定期地执行命令
通过 ExecutorService 接口,可以将任务提交给线程池,由线程池自动分配和执行任务,线程池管理线程的创建、复用与销毁,使得多线程任务执行更加的高效与可控!
3.2 工厂模式
3.2.1 含义
【工厂模式】指的是在创建对象的时候,不再直接使用 new,而是使用一些其它的方法,通常是静态方法,协助我们把对象创建出来
3.2.2 分类
在Java中,工厂模式主要分为三种类型:
- 简单工厂模式(Simple Factory Pattern),又称为静态工厂方法模式
- 工厂方法模式(Factory Method Pattern)
- 抽象工厂模式(Abstract Factory Pattern)
3.2.3 意义
Q:为什么需要工厂模式呢?
A:其实,工厂模式是用来填构造方法的"坑"(实属无奈之举),我们可以知道,如果一个类想要提供不同的构造对象的方式,就需要基于构造方法重载,但是构造方法具有局限性,我们回顾一下构造方法的名称,知道构造方法的名称必须与类名相同,这就会造成一个问题
比如以下这个场景:在平面上,构造一个点,有两种方式
1)通过横纵坐标构造一点,需要传入的参数:x,y
2)根据极坐标构造,需要传入的参数:r,α(其中 r 为点到原点的距离,α 为角度)
这样的方式可行吗?显然是不行的,因为这两个方法名完全相同,并不能构成重载!
于是引入了工厂模式,解决上述问题,构造一个工厂类,使用静态方法设置属性:
3.2.4 案例
在 Java 中,创建线程池也是如此,通过工厂模式构造线程池,代码如下:
public class ThreadDemo {
public static void main(String[] args) {
//创建线程池
ExecutorService pool = Executors.newFixedThreadPool(10);
//添加任务到线程池中去
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello hi");
}
});
}
}
打印结果:
图解分析:
通过 ExecutorService newFixedThreadPool 中的源代码可以看到:
3.2.5 作用
工厂模式,是面向对象设计模式中的一种创建型模式,它提供了一种创建对象的最佳方式,在工厂模式中,可以通过使用工厂类来创建对象,而不是直接在客户端中使用 new 关键字实例化对象,这样在创建对象的时候不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口来指向新创建的对象,使对象的创建和使用分离开来,工厂模式主要解决了接口选择的问题,让类的实例化推迟到子类中进行,降低客户端代码与具体对象的耦合度,使代码更加灵活,维护性较强
3.3 常见创建线程池的方法
Executors 类中静态方法创建线程池的4种常见方式:
- newFixedThreadPool:创建固定线程数的线程池
- newCachedThreadPool:创建线程数目动态增长的线程池,即不会设定固定值,按需创建,用完后也不会销毁,留着以后备用
- newSingleThreadExecutor:创建只包含一个线程的线程池
- newScheduledThreadPool:类似与定时器Timer,只不过不是扫描线程执行,而是由线程池中的线程执行,设定延迟时间后执行命令,或者定期执行命令
Executors 本质上是 ThreadPoolExecutor 类的封装, ThreadPoolExecutor 类提供了更多的可选参数,可以进一步细化线程池行为的设定, ThreadPoolExecutor 类的参数很多,还是挺抽象的,因此,ThreadPoolExecutor 类使用起来太麻烦了~上述的这些工厂方法都是通过包装 ThreadPoolExecutor 类实现的,使用起来比较简单方便(谁不喜欢方便的东西捏!)
下面我们一起来看看 ThreadPoolExecutorl 类!
4. ThreadPoolExecutor 类
ThreadPoolExecutor 类,是 ExecutorService 接口的一个实现类,是原装的线程池类,上述的所有工厂方法都是对这个类进行进一步的封装
打开 Java 官方文档,查阅 ThreadPoolExecutor 类,可以详细看到 Java 官方文档对这个类的说明
4.1 构造方法
ThreadPoolExecutor 类提供很多灵活的线程池功能,它有如下构造方法:
可以看到 ThreadPoolExecutor 类的构造方法,参数有很多,最后一个构造方法的参数最多,参数包含前 3 个构造方法的参数,因此,这里以最后一个构造方法为例,深入认识这里面的参数
ThreadPoolExecutor {
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
}
参数解释如下:
- corePoolSize:核心线程数,线程池中始终保持存活的线程数,只有当线程池中的线程数量大于这个值时,非核心线程才会在空闲时间超过 keepAliveTime 后被销毁
- maximumPoolSize:最大线程数,线程池中允许的最大线程数量,当工作队列满了之后,线程池会创建新线程来处理任务,直到线程数量达到这个最大值
- keepAliveTime:非核心线程保持存活的时间
- unit:keepAliveTime 参数的时间单位,(比如毫秒,秒,分钟)
- workQueue:阻塞队列,用于保存等待执行任务,线程池里要管理很多任务,这些任务也是通过阻塞队列来组织的,程序员可以手动指定给线程池一个队列,此时程序猿就可以很方便可以控制或者获取队列中的信息,其中 submit()方法就是把任务放到该队列中
- threadFactory:工厂模式,创建线程的辅助类,用于创建新线程的工厂,可以通过线程工厂给自定义线程池中的线程设置名字、属性、优先级等
- handler:线程池的拒绝策略,如果线程池满了,继续往里面添加任务,如何进行拒绝
上述解释可能比较抽象,我们可以想象一个生活中实际的场景,帮助我们进行理解:
将线程池理解为一个公司,我们知道,公司有正式员工,也有实习生/临时工,线程池中的核心线程可以当作是正式员工,非核心线程可以当作是实习生,即corePoolSize = 正式员工的数量,maximumPoolSize = 正式员工 + 实习生的数量,在公司运转很忙的情况下,公司就会多招几个实习生帮忙干活,就类似于线程池多创建几个非核心线程来帮忙完成任务,等到公司不忙了,闲下来的时候,为节省成本与资源,公司又将实习生辞退,相对应线程池中就是非核心线程被销毁,正式员工是签订劳务合同的,不能随意辞退,即使核心线程处于空闲状态也不会被销毁,是始终存在的,而实习生没有签订劳务合同,只是实习合同,是随时可以辞退的,keepAliveTime 就规定实习生存活的时间,即非核心线程保持存活的时间
下面介绍 4 种拒绝策略,重点来啦!
4.2 四种拒绝策略
结合生活中的一个实例综合理解,比如你正值课多的时候,忙得焦头烂额,这个时候有个同学找你帮忙一起去个地方完成某个任务
-
ThreadPoolExecutor.AbortPolicy:直接抛异常,如果线程池满了,还要继续添加任务,添加操作直接抛出异常(一听到这个消息,直接绷不住了,课也不上了,直接一整个心烦意乱)
-
ThreadPoolExecutor.CallerRunsPolicy:添加的线程自己负责执行这个任务(直接怼回去,要去你去,我才不去呢,你自己负责)
-
ThreadPoolExecutor.DiscardOldestPolicy:丢弃最老的任务,即丢弃阻塞队列的首元素,不执行了,直接删除(看了一下课表,决定把最早的一节课逃掉去完成这个任务)
-
ThreadPoolExecutor.DiscardPolicy:丢弃最新的任务,还是做原来的任务(还是继续上自己的课,这个去完成的任务直接丢弃)
【注意】
- 这几个拒绝策略使用哪个,结合具体场景来确定!!!
- 线程池没有依赖阻塞行为,而是通过额外实现了其它逻辑更好地处理这个场景的操作,阻塞有的时候可行,有的时候不可行,线程池中不希望依赖"满了阻塞",其实主要是利用"空了阻塞"(这就好比,你到底去不去完成这个任务,需要给你的同学一个立即的答复,如果阻塞等待的话,你干不了什么事情,这个同学也干不了啥,只能干等着)
5. 手动实现线程池
5.1 完整代码
下述代码实现一个固定线程数量的线程池,代码如下:
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
class MyThreadPool {
//定义一个阻塞队列
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
public void submit(Runnable runnable) throws InterruptedException {
//像生产任务一样,将任务提交到队列中去
queue.put(runnable);
}
//此处实现一个固定线程数的线程池
public MyThreadPool(int n) {
for(int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
try {
while(true) {
Runnable runnable = queue.take();
runnable.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
//!!!不要忘记启动线程,上述只是创建线程了 需要启动线程~
t.start();
}
}
}
public class ThreadDemo22 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool = new MyThreadPool(10);
for(int i = 0; i <= 1000; i++) {
//lambda表达式变量捕获规则
int number = i;
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello" + number);
}
});
}
}
}
运行结果如下:
5.2 实现过程
- 手动实现线程池,核心的数据结构是 BlockingQueue,用于存放各个可执行的任务
- submit()方法,是提交任务,将任务添加到队列中
- 实现一个固定线程数的线程池,MyThreadPool(int n),其中 n 为固定线程数量,在线程池构造方面里面,通过 for 循环,创建 n 个 工作线程,n 个线程并发执行,每个线程的任务是从队列中获取的,并进行执行,即在 while(true) 循环中,既有取队列操作,又有执行的操作;将 while(true) 中,循环条件设置为 true,是不让线程在执行完任务后终止,保持工作线程活跃的状态,如果去掉 true,可以看到,只会执行 n 次,因为在执行完后线程终止了不再执行,线程池数量不够任务数量,就无法处理后续任务
- 在主线程中,创建线程池,并通过 for 循环 与 submit() 向阻塞队列提交 1000 个任务,工作线程从队列中获取任务并进行执行
- 为什么要将 i 赋值 给 number,而不直接打印 i,这里涉及到 lambda 表达式的变量捕获规则(在介绍 Thread类中有具体介绍),lambda 表达式捕获的变量必须是 final 修饰或者是实际 final,实际 final 是不能被修改的,在这里因为 i 变量被修改了,创建一个新的变量保存 i,即可解决
5.3 如何给线程池设置合适线程数量
在实际开发中如何给线程池设置合适的线程数量呢?
我们要知道,线程不是越多越好,线程的本质上是要在 CPU 上调度的,一个线程池的线程数量设置为多少合适,这需要结合实际情况实际任务决定
- CPU 密集型任务:主要做一些计算工作,要在 CPU上运行
- IO 密集型任务:主要是等待 IO 操作,比如等待读写硬盘,读写网卡等,不怎么消耗 CPU 资源
极端情况下,如果线程全是使用 CPU 运行,线程数就不应该超过 CPU 核心数(逻辑核心,比如一个电脑是6核12线程,即12个逻辑核心,以12为基准),如果线程全是使用的 IO,则线程数可以设置很多,远远超出 CPU 的核心数
在实际开发中,很少有这么极端的情况,需要具体通过测试的方式来确定,测试方式的大体思路是,运行程序,通过记录时间戳计算一下执行时间,同时监测资源的使用状态,线程数量取一个执行效率可以并且占用资源也还可以的数量
5.4 线程池的执行流程
线程池处理任务的流程图如下所示:
- 任务提交
一个新的线程任务被提交到线程池时,线程池会首先尝试在线程池中分配一个空闲线程来执行这个任务,当此时的空闲线程数小于核心线程数,就会创建一个核心线程来执行任务 - 队列处理
如果此时的空闲线程数大于核心数,就会将任务添加到工作队列中,线程池会检查工作队列是否已满,如果工作队列未满,则将新任务放入工作队列中等待,直到有空闲线程取出并执行;如果工作队列已满,且当前线程数已达到最大线程数时,如果再有新任务提交到线程池,则会触发拒绝策略 - 线程调度
根据当前线程池的状态(如空闲线程数、工作队列状态、存活线程数等)来决定如何处理新提交的任务,判断是否创建新线程或是复用空闲线程执行任务 - 任务执行
线程池中的工作线程从任务队列中获取任务并执行,每个线程在执行完任务后继续从任务队列获取下一个任务 - 线程回收
如果线程池中的线程在一定时间内(keepAliveTime)没有新的任务执行,且当前运行的线程数大于核心线程数,非核心线程会被回收,直到线程池中的线程数缩减到核心线程数,核心线程数始终不变 - 线程池关闭
当不再需要线程时,应显示关闭线程池,释放相关资源(可以看到上述代码打印完成后,并没有结束程序!)
线程池的执行流程是一个动态调整的过程,通过线程池的管理,可以有效地管理和复用线程资源,提高系统的性能和稳定性!
💛💛💛本期内容回顾💛💛💛
✨✨✨本期内容到此结束啦~