1. 为什么会有线程池
“池”这种思想,本质上就是能提高程序的效率
最初引入线程,就是因为进程太重了,频繁创建、销毁进程,开销比较大
- “大/小”是相对的,随着业务上对于性能要求越来越高,对应的,线程穿件/销毁的频次越来越多
- 此时,线程创建和销毁的开销就变得比较明显,无法忽略不计了
线程池就是解决上述问题的常见方案 - 把线程提前从系统中申请好,放到一个地方,后面需要使用线程的时候直接从这个地方取,而不是从系统中重新申请
- 线程用完之后,也是还回到刚才这个地方
2. 内核态和用户态
为啥从线程池里面取线程,比从系统申请来的更高效呢?
- 内核态 & 用户态,操作系统中的概念
- 操作系统=操作系统内核+操作系统配套的应用程序
- 内核是操作系统的核心功能部分,负责完成一个操作系统的核心操作。向下要管理各种硬件资源(包括调用驱动程序,操控各种硬件设备);向上要给这些应用程序提供一个稳定的运行环境,给他们一些对应的 API 来完成相关的操作
2.1 场景构造
在银行里,你需要办理取款业务,就需要来到柜台前,给柜员说清楚你的需求,然后柜员再给你在后面的保险柜里面取钱(你是无法直接跨过柜台,自己取钱的)
- 柜台内部——操作系统内核
- 柜台外部——用户
像我们平时执行的应用程序(CCtalk、画图板…),就是应用态的应用程序,而操作系统内核,提供了一些相关的系统API
来给这些应用程序进行使用
比如执行一个
println
操作
- 应用程序来到台前,给内核里面的人说:我现在要往控制台打印一个什么什么东西,你用空帮我弄一下呗
- 然后内核里面的人就把数据拿到,开始操作显示器、控制台,完成这里的打印操作
我们进行的很多操作,都是用户态的应用程序和操作系统内核(内核态)的逻辑相互配合,来完成工作
对应的,平时执行的很多代码逻辑,都是要用户态的代码和内核态的代码配合来完成的
应用程序有很多,这些应用程序都是由内核同一负责管理和服务,内核里的工作就可能非常繁忙,进而提交给内核的要做的任务可能是不可控的
比如说你要办一张银行卡
你来到了银行柜台前,但你没有身份证复印件,柜员说你有两个选择:
- 自己去旁边的自助复印机进行处理
- 等他帮你处理,但要稍等一会
- 如果选择自助复印,我就会立即去到复印机那里,立即开始复印,拿到复印件后立即回到窗口。整个过程是连贯的,是可控的,效率就比较高
- 如果选择柜员帮忙,那么他就可能拿着身份证,跑到柜台后面,消失了,但什么时候能退回来,这就不知道了。因为他跑到后面后可能先得上个厕所、回个消息、玩下手机、吃点东西… 这整个过程是不可控的,因此效率比较低
- 从系统创建线程,就相当与是让银行的人给我复印
- 这样的逻辑就是调用系统 API,由系统内核执行一系列逻辑来完成这个过程
- 直接从线程池里面来取,这就相当于是自助复印
- 整个过程都是纯用户态代码,都是咱们自己控制的,整个过程更可控,效率更高
因此,我们通常认为,纯用户态操作,比经过内核的操作效率更高
3. 标准库的线程池
- 标准库提供了类—
ThreadPoolExecutor
(构造方法提供了很多参数)
3.1 构造方法的参数
3.1.1 核心线程数和最大线程数
此线程池,可以支持“扩容”
某个线程初识情况下,可能有 M 个线程,实际使用中发现 M 不太够用,就会自动增加 M 的个数
因为 CPU 上的核心数量是有限的,所以线程不能无限扩容
- 在 Java 标准库的线程池中,把里面的线程分为了两类
- 核心线程
- 最少有多少个线程
- 始终存在于线程池内部
- 非核心线程
- 线程扩容的时候新增的
- 繁忙的时候被创建出来,空闲了就会把这些线程真正的释放掉
- { 核心线程数 + 非核心线程数 } m a x = 最大线程数 \{核心线程数+非核心线程数\}_{max}=最大线程数 {核心线程数+非核心线程数}max=最大线程数
3.1.2 非核心线程允许摸鱼的最大时间
非核心线程会在线程空闲空闲多久后会被销毁
比如:
公司招了个实习生,某一天,实习生没活干,但公司不会把他裁了,要是明天有活干呢?
第二天,实习生又没活干,裁吗?要是后天有活呢…
所以就要设一个最大时间,超过这个时间之后就裁掉
3.1.3 工作队列(阻塞队列)
- 线程池的工作过程是典型的“生产者消费者模型”
- 程序员工作的时候,通过形如“
submit
”这样的方法,把要执行的任务,设定到线程池里 - 线程池内部的工作线程,负责执行这些任务
- 此处就有一个阻塞队列
此处的队列可以让我们自行指定
- 队列的容量—
capcity
- 队列的类型—基于链表?数组?优先级?…
通过 Runnable
给线程设定要执行的任务
Runnable
接口本身的含义就是一段可移植性的任务Runnable
是基于任务的抽象表示
3.1.4 线程工厂
工厂设计模式
“工厂”指的是“工厂设计模式”,也是一种常见的设计模式
- 是一种在创建类的实例时使用的设计模式
- 由于构造方法有“坑”,通过这个设计模式来填坑
构造方法是一种特殊的方法
- 必须和类名一样
- 多个版本的构造方法必须构成“重载”(
overload
)实现
构造方法的局限性:
比如需要描述一个点(Point
),有两种描述方法:
- 通过横纵坐标进行描述
- 参数为 ( d o u b l e x , d o u b l e y ) (double\; x, double\; y) (doublex,doubley)
class Point{ public Point(double x, double y) {} }
- 通过极坐标进行描述(三角函数)
- 参数为 ( d o u b l e r , d o u b l e a ) (double\; r, double\; a) (doubler,doublea)
- x = r ∗ s i n α , y = r ∗ c o s α x=r*sinα,y=r*cosα x=r∗sinα,y=r∗cosα
class Point { public Point(double r, double a){} }
- 但此时两个构造方法的参数个数和类型都是一样的,所以无法构成重载
因此,使用构造方法创建实例,就会存在上述局限性
为了解决上述问题,就引入了“工厂设计模式”
- 通过“普通方法”(通常是静态方法)完成对象构造和初始化的操作
class Point{ public static Point makePointByXY(double x, double y) { Point p; p.setX(x); p.setY(y); return p; } public static Point makePointByRA(double r, double a) { Point p; p.setR(r); p.setA(a); return p; } }
- 这就是最简单的工厂设计模式的写法
- 此处用来创建对象的
static
方法就称为“工厂方法”- 有的时候,工厂方法也会放到单独的类里实现,用来放工厂方法的类,称为“工厂类”
//工厂类 class PointFactory{ public static Point makePointByXY(double x, double y) { Point p; p.setX(x); p.setY(y); return p; } public static Point makePointByRA(double r, double a) { Point p; p.setR(r); p.setA(a); return p; } }
- ThreadFactory 就是 Thread 类的工厂类,通过这个类,完成 Thread 实例创建和初始化操作
- 此处的
ThreadFactory
可以针对线程池里的线程,进行批量的设置属性 - 此处一般都不会进行调整,就是用标准库提供的默认值即可
3.1.5 拒绝策略
- 最重要、最复杂的参数
如果线程池的任务队列满了,但还是要继续给这个队列添加任务,怎么办呢?
比如:你去向女神表白
- 女神对你说:你是个好人(不见得是坏事),这样你可以趁早死心,趁早开始新的生活
- 但如果女神只笑笑,也不说话,也不正面回答你,也不对你热情,也不对你冷淡,这才麻烦(很可能你就已经成为了备胎,你可能就在女神这里阻塞住)
对于这两种情况,第一种肯定是更好,毕竟长痛不如短痛,伸头一刀缩头一刀,趁早让人死心
- 所以,当队列满了,不要阻塞,而是要明确地拒绝
- Java 标准库给出了四种不同的拒绝策略
拒绝策略类型 | 说明 |
ThreadPoolExecutor. AbortPolicy | 默认拒绝策略,添加任务的时候,直接抛出异常 |
ThreadPoolExecutor.CallerRunsPolicy | 拒绝执行,由调用 submit 的线程负责执行 |
ThreadPoolExecutor.DiscardOldestPolicy | 把任务队列中最老的任务踢掉,然后执行新增加的任务 |
ThreadPoolExecutor.DiscardPolicy | 把任务队列中,最新的任务踢掉 |
四种拒绝策略
情景介绍:
这周你行程安排满了,你欲哭无泪,结果,你们导员突然找到你,让你和她一起参加一个什么比赛,在这时:
AbortPolicy
,添加任务的时候,直接抛出异常
你心里最后一跟稻草被压到了:“本来就事多,还要我做牛马,我受不了了啊“,悲伤之下,你进了医院,你们导员也就理所应当的取消了这次比赛,并且你这周的满课也上不了了CallerRunsPolicy
,拒绝执行,由调用 submit 的线程负责执行
你耐心给导员说:“导员,我周事实在太多了,完全抽不开身”,于是,你们善解人意的导员就理解了,他就自己一个人去参加了那个比赛DiscardOldestPolicy
,把任务队列中最老的任务踢掉,然后执行新增加的任务
你不想拒绝导员,你看了下行程表,划去了最后一个任务,将所有的行程往后移,然后你跟着导员一起去参加比赛去了DiscardPolicy
,把任务队列中,最新的任务踢掉
你给导员说去不了之后,导员说:“那我也不去了”,于是这个比赛就被踢掉了
3.2 线程池的使用
ThreadPoolExecutor
功能很强大,使用很麻烦
标准库对这个类进一步封装了一下,Executors
提供了一些工厂方法,可以更方便构造出线程池
newCachedThreadPool
设置了非常大的最大线程数,可以对线程池进行不停地扩容newFixedThreadPool
把核心线程数和最大线程数设置成了一样的值,固定了数量,不会自动扩容
public class Demo2 { public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(4); for (int i = 0; i < 100; i++) { service.submit(() -> { Thread current = Thread.currentThread(); //这里的i报错了,因为出现了“变量捕获” System.out.println("hello thread" + i + "," + current.getName()); }); } } }
submit
也可以使用lambda
表达式- 如果想
i
不报错,就需要i
是被final
修饰的或者是没有修改的(发生了 [[03 多线程-线程的核心操作#^191375|变量捕获]]) - 我们就可以在
lambda
外面重新定义一个变量id
,每次进入循环就创建一个等于i
的变量,之后之后也不会修改
public class Demo2 { public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(4); for (int i = 0; i < 100; i++) { int id = i; service.submit(() -> { Thread current = Thread.currentThread(); //这里的i报错了,因为出现了“变量捕获” System.out.println("hello thread" + id + "," + current.getName()); }); } } }
- 虽然代码的
100
个任务都执行完毕了,但是整个进程并没有结束,因为此处线程池创建出来的线程都是“前台线程”,虽然main
线程结束了,但是这些线程池里的前台线程仍然存在 - 若想解决
- 将每次创建出来的线程设为“后台进程”
- 使用
shutdown
操作,把线程池里面所有的线程都终止掉
public class Demo2 { public static void main(String[] args) throws InterruptedException { ExecutorService service = Executors.newFixedThreadPool(4); for (int i = 0; i < 100; i++) { int id = i; service.submit(() -> { Thread current = Thread.currentThread(); System.out.println("hello thread" + id + "," + current.getName()); }); } Thread.sleep(2000); service.shutdown(); System.out.println("程序退出"); } }
- 使用
sleep
是为了保证所有线程都执行完了
使用线程池的时候,需要指定线程个数,那该如何指定呢?该指定多少呢?
实际开发中建议的做法,是通过实验的方式,找到一个合适的线程数的个数的值
- 给线程池设置不同的线程数,分别进行性能测试,关注响应时间/消耗的资源指标,挑选一个比较合适的数值
- 一台主机上,并不是只有一个程序
- 你写的这个程序,也不是 100%的每个线程都跑满 CPU,线程工作过程中,可能会涉及到一些 IO 操作/阻塞操作主动放弃 CPU
- 如果线程代码里都是算术运算,确实能跑满 CPU
- 如果是包含了 sleep,wait,加锁,打印,网络通信,读写硬盘… 都会使线程主动放弃 CPU 一会(给其他线程提供更多资源)
4. 线程池的模拟实现
固定线程数目的线程池
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; class MyThreadPool { private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000); //构造方法,n为创建的线程个数 public MyThreadPool(int n) { //先创建 n 个线程 for (int i = 0; i < n; i++) { Thread t = new Thread(() -> { //循环的从队列中取出任务 while (true) { Runnable runnable = null; try { runnable = queue.take(); } catch (InterruptedException e) { throw new RuntimeException(e); } runnable.run(); } }); t.start(); } } //添加任务 public void submit(Runnable runnable) { try { queue.put(runnable); } catch (InterruptedException e) { throw new RuntimeException(e); } } } public class Demo3 { public static void main(String[] args) { MyThreadPool pool = new MyThreadPool(4); for (int i = 0; i < 1000; i++) { int id = i; pool.submit(() -> { System.out.println("执行任务" + id + "," + Thread.currentThread().getName()); }); } } }