线程诞生的意义,是因为进程的创建/销毁,太重了(比较慢),虽然和进程比,线程更快了,但是如果进一步提高线程创建销毁的频率,线程的开销就不能忽视了。
这时候我们就要找一些其他的办法了。
有两种典型的办法可以进一步提高这里的效率:
1: 协程 (轻量级线程,相比于线程,把系统调度的过程给省略了,变成由程序员手工调度)
(当下,一种比较流行的并发编程的手段,但是在Java圈子里,协程还不够流行,GO和Python用的比较多)
2:线程池(Java用的)
接下来我们就来介绍一些线程池
优化频繁创建销毁线程的场景
首先,我们先来了解一下什么是池:
假设一个美女,有很多人追,美女就从这些人里面挑了一个她最喜欢的交往,交往一段时间之后,美女她腻了,她想换一个男朋友,那她接下来就要干两件事
1:想办法和这个男的分手,需要一些技巧,比如挑他毛病,作 之类的
2:再找一个小哥哥,培养感情,然后交往
但是这样的效率就比较低啦,有没有什么办法来提高一些效率呢?
当然有,只有美女在和前一个交往的时候,和另一个或多个小哥哥,搞暧昧(养),把他们都放到自己的鱼塘里,那和上一个分手,下一个男朋友来的就很快了。
我们还是只做了两步,只是把第二步提前了。
那“鱼塘”里的这些人,我们通常叫他们 —— “备胎”
那这个“鱼塘” 就 可以看成 “池” ,来放“备胎”
同样的,线程池,就是在使用第一个线程的时候,提前把 2,3,4,5...(多个)线程创建好(相对于前面的培养感情),那后续我们想使用新的线程的时候,就不必重新创建了,直接拿 池 里的线程用就行了。(此时创建线程的开销就被降低了)
这是因为,从池子里取 这个动作,是存粹的 用户态 的操作,而创建新的线程,这个动作,则是需要 用户态 + 内核态 相互配合完成的操作。
如果一段程序,是在系统内核中执行的,此时就称为“内核态” ,如果不是,则称为“用户态”
操作系统,是由 内核 + 配套的应用程序 构成的,
内核:系统最核心的部分
创建线程操作,就需要调用系统 api,进入到内核中,按照内核态的方式来完成一系列动作。
内核态的操作要比 纯用户态的操作开销要更大 :至于为什么,我们来举一个例子解释一下:
银行办业务的例子
首先这个来办理业务的人他不能 进入柜台后面,只能在大厅里,
这个人想来办张银行卡,需要身份证复印件,但是这个人他忘带了,那此时柜台的服务人员就给了他两个选择:
1:把身份证给她,她去帮他复印
2:大厅的角落,有一个自助复印机,他可以去那里自己复印
那这两个选择中的第二个,自己复印就是纯 用户态操作(这个人可以立即去复印,完事后立即回来办理业务,整个过程非常利落,非常可控)
但是如果交给 柜台的服务人员(第一个选择),这个过程就涉及到 内核态 操作了,那此时,你把东西交给他俩,你也不知道柜员消失之后去做了那些事情,也不是的她啥时候回来,整个过程是不可控的。
操作系统内核,是要给所有的进程提供服务的,当你要创建线程的时候,内核虽然会帮你做,但是做的过程中难免也要做一些其他的事情。那在你这边的结果,就不是那么可控。
上述就是内核态 和 用户态的区别 。
我们发现了,线程池这个对象不是我们直接 new 的,而是通过一个专门的方法,返回了一个线程池的对象。
这种写法就涉及到了 “工厂模式”(校招常考的设计模式)(和上一篇介绍的 单例模式 并列)
通常我们创建对象 都是使用 new,new 关键字就会触发 类的构造方法,但是构造方法,存在一定的局限性。
“工厂模式” 就是给 构造方法填坑的。
那 “工厂模式” 具体是填的什么 坑 呢,我们举一个例子:
假设 考虑 一个类,来表示平面上的点
然后我们给这个类提供构造方法:
第一个构造方法:
期待使用笛卡尔坐标系来构造对象。
第二个构造方法:
使用极坐标来构造对象
但是编译失败了。
原因:
很多时候,我们希望构造一个对象,可以有多种构造方式 。那多种方式,我们就需要使用多个版本的构造方法来分别实现,但是构造方法要求方法的名字必须是类名,不同的构造方法 只能通过 重载 的方式来区分了,而重载又要求 参数类型 或 个数 不同。
而上面的两个构造方法 很明显没有构成 重载,当然会编译失败。
这就是 构造方法的局限性 。
那“工厂模式”就能解决上述问题 :
使用普通的方法,代替构造方法完成初始化工作,普通的方法就可以使用方法的名字来区分了。也就不受 重载的规则制约了。
在实践中,我们一般单独 搞一个类,然后给这个类搞一些静态方法,由这些静态方法负责构造出对象
伪代码
class PointFactory { public static Point makePointByXY(double x, double y) { Point point = new Point(); point.setX(x); point.setY(y); return p; } public static Point makePointByRA(double r, double a) { //和上边类似 } } class Demo { public static void main(String[] args) { //使用 Point p = PointFactory.makePointByXY(10,20); } }
上述介绍之后,我们就知道了为啥 线程池 的 对象我们不直接 new 了
这种方法就是 工厂模式
此时构造出的线程池对象,有一个基本特点,线程数目是能够动态适应的。
cached: 缓存,用过之后不着急释放,先留着以备下次使用。
也就是说,随着往线程池里添加任务,这个线程池中的线程会根据需要自动被创建出来,创建出来之后也不会着急销毁,会在池子里保留一定的时间,以备随时再使用。
除了上边的线程池,我们还有其他的线程池:
这个方法就需要我们指定 创建几个线程,线程个数是固定的 (Fix:固定)
只有单个线程的线程池:
类似于 定时器, 只是 不是只有一个 扫描线程 负责执行任务了,而是有多个线程执行时间到的任务.
第一种和第二种常用
上述这几个工厂方法生成的线程池,本质上都是对 一个类进行的封装 —— ThreadPoolExector
ThreadPoolExector 这个类的功能十分丰富,它提供了很多参数,标准库中上述的几个工厂方法,其实就是给这个类填写了不同的参数来构造线程池。
ThreadPoolExector 的核心方法:
1.构造方法
2.注册任务(添加任务)
构造方法中的参数,很多,且重要,
我们打开Java文档 Overview (Java Platform SE 8 ) (oracle.com)
打开这个包 juc —— 这个包里放的试和 “并发编程” 相关的内容(Java中,并发编程最主要的体现形式就是多线程)
点进这个包然后往下找:
然后我们直接翻到构造方法 :
上面的四个构造方法,都差不多,就是参数个数 不一样,第四个 参数最多,能够涵盖上述的三个版本。
所有我们重点看第四个构造方法:
这一组参数,描述了线程池中,线程的数目:
这个线程池里的线程 的数目试可以动态变化的,
变化的范围就是【corePoolSize, maximumPoolSize】
那 “核心线程” 和 “最大线程” 如何理解呢?
如果把一个线程池,理解为一个公司,此时,公司里有两类员工
1.正式员工
2.实习生
那正式员工的数目,就是核心线程数,正式员工 + 实习生的数目就是最大线程数
正式员工和实习生的区别:
正式员工,允许摸鱼,不会因为摸鱼被公司开除,有劳动法罩着。
但是实习生,不允许摸鱼,如果这段时间任务多了,此时,就可以多搞几个实习生去干活,如果过段时间任务少了,并且这样的状态还持续了一定时间,那空闲的实习生就可以裁掉了。
这样做,既可以满足效率的要求,又可以,避免过多的系统开销 。
ps:
使用线程池,需要设置线程的数目,数目设置多少合适?
一定不是一个具体的数字!!!因为在接触到实际的项目代码之前,这个数目是无法确定的!!!
一个线程 执行的代码,主要有两类:
1.cpu 密集型:代码里主要的逻辑是在进行 算术运算/逻辑判断。
2.IO 密集型:代码里主要进行的是IO操作。
—— 假设一个线程的所有代码都是 cpu 密集型代码,这个时候,线程池的数量就不应该超过N,就算设置的比N大,此时也无法提高效率,因为cpu吃满了。
—— 假设一个线程的所有代码都是 IO 密集型代码,这个时候不吃cpu,此时设置的线程数,就可以是超过N,(一个核心可以通过调度的方式来并发执行)
上述,我们就知道了,代码不同,线程池的线程数目设置就不同,我们无法知道一个代码,具体多少内容是cpu密集,多少内容是IO密集。所以我们无法确定 数目设置多少合适。
正确做法:使用实验的方式,对程序进行性能测试,测试的过程中尝试修改不同的线程池的线程数目,看那种情况,更符合要求。
这一组参数,描述了允许实习生摸鱼的时间,(实习生不是 一摸鱼就马上被开除)
这个参数的意思是 阻塞队列 ,用来存放线程池里的任务。
可以根据需要,灵活设置这里的队列是啥,比如需要优先级, 就可以设置 PriorityBlockingQueue
如果不需要 优先级,并且任务数目是相对恒定的,可以使用 ArayyBlockingQueue,如果不需要优先级,并且任务数目变动比较大,就可以用 LinkedBlockingQueue
这个参数就是 工厂模式的体现 ,此处使用 ThreadFactory 作为 工厂类 由这个类负责创建线程
使用工厂类来创建线程,主要是为了在创建线程的过程中,对线程的属性做出一些设置。
如果手动创建线程,就得手动设置这些属性,就比较麻烦,使用工厂方法封装一下,就更方便。
下面这个参数是最重要的 ,是线程池的拒绝策略
一个线程池,能容纳的任务数量,有上限,当持续往线程池里添加任务的时候,一旦达到了上限,还继续添加,会出现什么效果?
拒绝策略就是来解决这个问题的: 不同的拒绝策略有不同的效果。
上面的这四个就是不同的拒绝策略
如果队列满了,再添加就直接抛出异常
新添加的任务,由添加任务的线程负责执行
丢弃最老的任务
丢弃当前新加的任务
这个代码比较简单,就不多说了,代码里都有注释
import java.awt.*;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @Author: iiiiiihuang
*/
public class ThreadPool {
//任务阻塞队列
private BlockingQueue queue = new ArrayBlockingQueue<>(4);
//通过这个方法,把任务添加到队列中
public void submit(Runnable runnable) throws InterruptedException {
//此处的拒绝策略,相当于第五种策略,阻塞等待(下策)
queue.put(runnable);
}
//构造方法
public ThreadPool(int n) {
//创建出n个线程,负责执行上诉队列中的任务
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
//让这个线程,从队列中消费任务,并执行
try {
//取出
Runnable runnable = queue.take();
//执行
runnable.run();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
}