最开始引入线程,是为了解决频繁创建和销毁进程产生的资源开销太大这个问题。创建和销毁线程的开销远小于创建和销毁进程的开销。但是当线程创建销毁的频率进一步提高后,这时线程的开销也就比较大了,也是需要解决的问题。有道之“抛开剂量谈毒性都是耍流氓”,虽然单个线程创建和销毁的开销小,但是架不住“量大”。
解决频繁创建销毁线程开销大的方案有两种:
- 引入轻量级线程,也称为协程/纤程。 就是java21中引入的“虚拟线程”。 协程的本质,是程序员在用户态代码中进行调度,不靠内核态的调度器进行调度。这样就节省了很多调度上的开销。协程不是系统级别的概念,是基于线程封装出来的。 协程只能“并发”调度,当一个协程在执行,那么其他协程都会被挂起。
-
引入线程池,就是将创建好的线程放到线程池中,使用线程的时候从线程池中取,不使用线程的时候,把这个线程还给线程池。线程池提前创建好了线程,使用完的线程也不立即释放,而是放到线程池中。整个使用过程只是取和放,没有真正的销毁和创建线程,这样就节省了频繁创建和销毁线程的开销。但是也有缺点,当不需要很多线程的时候,多余的线程会占有一些空间。这个主要就是空间换效率。
为什么从线程池中取线程,比从内核中创建线程来的更高效?
举个例子,一个人要去银行取钱,如果他去找柜员操作(内核态),那这个柜员在后台的时候,可能遇到个熟人聊了会儿天再给你取钱,可能来了个大单子,他先去数那边的钱,给你这边放放。你不知道柜员会干什么,你只能等。柜员的行为是不可控的,这个效率就得不到保障。
你也可以自己去ATM中取钱(用户态),拿着银行卡直接操作,就完事了,自己中途会干什么都是可控的。效率就有了保障。
从线程池中取线程是用户态操作,从内核中申请创建线程是内核态操作,用户态操作可控,内核态操作不可控,所以用用户态操作效率更高,更有保障。
java标准库中的线程池
标准库的线程池的实现主要有两个类
-
ThreadPoolExecutor 类
-
Executors 类
ThreadPoolExecutor
ThreadPoolExecutor类主要是通过构造方法来创建线程池
这里来介绍一下,这些参数的含义:
-
corePoolSize : 核心线程数,线程池最少有的线程数。(相当于公司的正式员工,一旦录用,永不辞退)
-
maximumPoolSize : 最大线程数,当要执行的任务太多时,线程池会自动增加一些线程来分担工作(相当于临时工,临时工不干活时就辞退临时工)。最大线程数,就是允许线程池里存在最多的线程的数目,也就是正式员工+临时工的数目
-
keepAliveTime : 额外线程允许空闲的最长时间(允许临时工不干活的最长时间)
-
unit : keepAliveTime的时间单位,是秒,是毫秒,还是其他
-
workQueue : 传递任务的阻塞队列
-
threadFactory : 创建线程的工厂,参与具体的创建线程工作,通过不同线程工厂创建出的线程相当于对一些属性进行了不同的初始化设置。
-
handler: 拒绝策略,如果当任务量超出了任务队列的容量或者线程数不够处理所有任务,进入阻塞状态时的处理方式。
-
AbortPolicy() : 超出负荷,直接抛出异常,这是线程池默认的拒绝方式
-
CallerRunsPolicy() : 由调用者处理多出来的任务
-
DiscardOldestPolicy() : 丢弃队列中存在最久的任务
-
DiscardPolicy() : 丢弃新来的任务
-
工厂模式
上文,threadFactory 表示创建线程的工厂。ThreadFactory是一个工厂类,实现了工厂模式思想的类就是工厂类。
工厂模式是一种设计模式,将创建对象的逻辑封装起来,用户不需要知道是使用什么方式创建对象的,只需要传入正确的参数就可以获取到正确的对象。将调用者和创建对象解耦。
就像工厂一样,一个工厂可以生产很多的产品,你只需要告诉这个产品的关键信息,这个工厂就可以给你创建出来。
举个例子:
在数学中一个点可以用不同的坐标系表示,可以用笛卡尔坐标系表示,也可以用极坐标系来表示。
用笛卡尔坐标系表示,需要传入两个参数x,y,x表示横坐标,y表示纵坐标。
用极坐标系表示,也需要传入两个参数,r,a, r表示点到原点的距离,a表示点与x轴的角度。
如果用java代码来表示:
public class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public Point(double r, double a) {
this.x = r * Math.cos(a);
this.y = r * Math.sin(a);
}
}
但是这样写的话编译器会报错,因为两个构造方法构成不了重载,在java中要提供多个版本的构造方法,构造方法之间必须要满足重载。
构成重载必须要满足两个条件:
-
方法名相同(构造方法的名字都一样,都是类名)
-
参数类型不同
为了解决这个问题,就引入了“工厂模式”。对于上述问题,可以用一个普通的方法来创建对象,调用方只要调用这个方法就可以获取对象。这样的方法就叫做“工厂方法”,存放工厂方法的类就是“工厂类”。
public class Point {
private double x;
private double y;
public double getX() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double getY() {
return y;
}
public void setY(double y) {
this.y = y;
}
public static Point getPointByXY(double x, double y) { // 工厂方法
Point point = new Point();
point.setX(x);
point.setY(y);
return point;
}
public static Point getPointByRA(double r, double a) { // 工厂方法
Point point = new Point();
point.setX(r * Math.cos(a));
point.setY(r * Math.sin(a));
return point;
}
}
总的来说,通过普通方法封装new 对象这个操作,在方法内部设置不同属性完成对对象的初始化,构造对象的过程就是工厂模式。
工厂模式根据抽象程度的不同可以分为:
-
简单工厂模式(Simple Factory)
-
工厂方法模式(Factory Method)
-
抽象工厂模式(Abstract Factory)
这里就不细说了,感兴趣的话可以去查一下资料学习一下。
ThreadFactory threadFactory 这个线程工厂就是对new Thread操作进行封装,属于抽象工厂模式,可以根据需求实现自己想要的线程工厂。不提供自己的线程工厂,线程池就会使用自己默认的线程工厂。
Executors
Executors提供了一些静态方法来创建线程池,返回类型是ExecutorService,这里来列举最基础的几个:
-
newFixedThreadPool : 创建固定线程数的线程池
-
newSingleThreadExecutor : 创建只包含单个线程的线程池
-
newCachedThreadPool :创建线程数目动态增长的线程池
-
newScheduledThreadPool : 设定延迟时间后执行命令,或者定期实行命令,可以理解成进阶版的Timer
可以通过submit方法来向线程池中添加任务,由线程池内部分配线程来执行。也可以用execute方法。
submit方法是来自ExecutorService这个接口的,execute方法来自Executor这个接口,这两个方法的区别是,execute没有返回值,只有一个Runnable类型的参数,添加完任务就完事了,submit则提供了更多不同类型的参数,还有一些重载的方法,有返回值,返回值提供了一些方法。这里就不详细介绍了。
newScheduledThreadPool 向里面添加任务用schedule,和Timer是一样的。
Executors 本质上是ThreadPoolExecutor类的封装。Executors 是一个工厂类,通过这个类的静态方法可以创建出不同需求的线程池对象。静态方法内部已经根据不同需求设置好了不同的参数。使用起来更加方便。
如果只是简单使用一下线程池用Executors, 如果希望高度定制化就用ThreadPoolExecutor,主要还是得看实际的需求。