目录
·前言
一、什么是线程池
1.引入线程池的原因
2.线程池的介绍
二、标准库中的线程池
1.构造方法
2.方法参数
(1)corePoolSize 与 maximumPoolSize
(2)keepAliveTime 与 unit
(3)workQueue(任务队列)
(4)threadFactory(线程工厂)
(5) handler(拒绝策略)
3.使用标准库中线程池
三、实现线程池
·结尾
·前言
在我们学习编程知识过程中一定听说过很多池,比如常量池,还有在我前面 MySql 专栏中 JDBC 编程里提到的数据库连接池,以及本篇文章要为大家介绍的线程池,所谓的这些池作用其实都差不多,都是提前把要用的对象创建好,然后把用完的对象不立即释放留着以备下次使用,这样就可以起到提高效率的作用,本篇文章就会为大家介绍一下什么是线程池,在我们 Java 标准库中的线程池是什么样的,以及使用 Java 代码来实现一个简单的线程池让大家能更清晰的认识线程池,那么就开始本篇文章的介绍内容吧。
一、什么是线程池
1.引入线程池的原因
在我们最开始,引入进程的概念就能够解决并发编程的问题,后来由于频繁创建销毁进程带来的开销太大,从而引入了线程(轻量级进程)这样的概念,使用复用资源的方式来提高创建销毁的效率,但是如果创建和销毁线程的频率也进一步提高呢?此时,线程的创建和开销也就不能无视了。
为了优化线程的创建与销毁的效率,有下面两种解决方案:
- 引入轻量级线程,也称为“协程”;
- 使用线程池。
为什么协程可以优化线程的开销与销毁,这是因为协程的本质是我们在用户态代码中进行调度,不是靠内核的调度器调度的,这样就可以节省很多调度上的开销,此时,我们代码中创建上千个线程会卡死,但是创建上千个协程就没什么事了。
虽然协程有很多的好处,但是在 Java 中不是很推荐用上述做法来优化线程的创建与销毁,这是因为引入协程会引入额外的复杂性,使用协程可能不是很稳定,协程的调试比较困难……所以相比于协程,使用线程池对于优化线程的创建与销毁会更好一些,那么下面就进一步介绍一下线程池是什么吧。
2.线程池的介绍
线程池就是要把使用的线程提前创建好,用完了一个线程也不要直接释放,而是放到线程池中以备下次的使用,这样就节省了线程创建与销毁的开销,因为在这使用线程的过程中,并没有真的频繁创建和销毁线程,只是从线程池中取线程使用,用完还会放回去。
那么为什么从线程池中取线程就比从系统中申请更高效呢?这就好比你让室友帮你取快递,室友答应帮你取,但是什么时候给你取回来,他在帮你取快递的途中会不会做一些什么事情都是不确定的,相比之下,你自己去取快递,就会更高效,通过上面的例子,我们可以得到以下结论:
- 从线程池中取线程是纯用户态代码,是可控的;
- 通过系统申请创建线程,需要系统内核来完成,这是不可控的。
二、标准库中的线程池
1.构造方法
在 Java 标准库中 ThreadPoolExecutor 这个类就是用来创建线程池的,关于这个类,它的构造方法有很多的参数,由我来给大家介绍一下,下面是这个类的几个构造方法,如下图所示:
如上图,ThreadPoolExecutor 一共涉及到四个构造方法,这里我只对第四个构造方法的每个参数进行一个介绍,这是因为,最后一个构造方法的参数是最全的,可以这么理解,介绍完第四个构造方法的各个参数,其余三个构造方法也就都包含了。
2.方法参数
(1)corePoolSize 与 maximumPoolSize
在标准库提供的线程池中,持有的线程个数并不是一成不变的,它会根据当前的任务量来自适应当前线程的个数(任务数量很多,就会多创建几个线程,任务量比较少,就会少创建几个线程),在构造方法中的前两个参数 int corePoolSize 代表线程池中核心线程数有多少也就是一个线程池中最少得有多少个线程,int maximumPoolSize 代表了线程池中最大线程数是多少也就是一个线程池中最多能有多少个线程。
(2)keepAliveTime 与 unit
第三个参数:long keepAliveTime 代表的意思就是线程池中除了核心线程外的线程的保持存活时间,在上面介绍了标准库中的线程池是根据当前的任务量来自适应当前线程的个数,这个参数就是自适应实现的一个重要标准,keepAliveTime 可以记录除了核心线程外的线程空闲的时间,如果这些线程的空闲时间超过了 keepAliveTime 的值就会自动销毁这些线程,来达到一个自适应的效果,这里的第四个参数:TimeUnit unit 就是搭配 keepAliveTime 这个参数的,unit 代表的是时间单位,它可以是 s、min、ms、hour……也就代表了空闲时间 keepAliveTime 的时间单位。
(3)workQueue(任务队列)
第五个参数:BlockingQueue了<Runnable> workQueue 代表线程池中可以有很多个任务,这里使用 Runnable 来作为描述任务的主体,线程池中线程不断从这个阻塞队列中取任务来执行。
(4)threadFactory(线程工厂)
第六个参数:ThreadFactory threadFactory 意思是线程工厂,通过这个工厂类就可以来创建线程对象(Thread 对象),这个类里提供了方法,方法中封装了 new Thread 这样的操作,并且同时给 Thread 设置了一些属性,由此就构成了 ThreadFactory 线程工厂。
这里的线程工厂也用到了一种设计模式:工厂模式,它是通过专门的“工厂类”/“工厂对象”来创建指定对象,那么为什么使用工厂模式呢?我们来看下面的一个代码示例:
// 表示平面上的一个点
class Point {
// 笛卡尔坐标系 x 与 y
private double x;
private double y;
// 极坐标系 r 与 a
private double r;
private double a;
// 通过笛卡尔坐标系来构造这个点
public Point(double x, double y) {
setX(x);
setY(y);
}
// 通过极坐标系来构造这个点
public Point(double r, double a) {
setR(r);
setA(a);
}
public double x() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double y() {
return y;
}
public void setY(double y) {
this.y = y;
}
public double r() {
return r;
}
public void setR(double r) {
this.r = r;
}
public double a() {
return a;
}
public void setA(double a) {
this.a = a;
}
}
不知道大家看完上面的代码有没有发现什么问题,这里的问题就在于 Point 这个类的两个构造方法不构成重载,它们的参数列表是一样的,如下图所示:
想必我们都知道,使用笛卡尔坐标和使用极坐标都可以表示一个点,并且这两个表示方法并不相同,想通过同一个类的构造方法来用这两种不同的方式表示不同的点就违背了 Java 的语法规则,为了解决上述的问题,就引入了“工厂模式”。
工厂模式的基本逻辑就是使用普通方法来创建对象,在普通方法中把构造方法进行封装,利用工厂模式修改后的代码及运行结果如下所示:
// 表示平面上的一个点
class Point {
// 笛卡尔坐标系 x 与 y
private double x;
private double y;
// 极坐标系 r 与 a
private double r;
private double a;
// 通过笛卡尔坐标系来构造这个点
public static Point makePointByXY(double x, double y) {
Point point = new Point();
point.setX(x);
point.setY(y);
return point;
}
// 通过极坐标系来构造这个点
public static Point makePointByRA(double r, double a) {
Point point = new Point();
point.setR(r);
point.setA(a);
return point;
}
public double x() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double y() {
return y;
}
public void setY(double y) {
this.y = y;
}
public double r() {
return r;
}
public void setR(double r) {
this.r = r;
}
public double a() {
return a;
}
public void setA(double a) {
this.a = a;
}
}
public class PointFactory {
public static void main(String[] args) {
Point point1 = Point.makePointByXY(3,4);
Point point2 = Point.makePointByRA(3,30);
System.out.println("point1 的笛卡尔坐标:->(" + point1.x() + "," + point1.y() + ")");
System.out.println("point2 的极坐标:->(" + point2.r() + "," + point2.a() + ")");
}
}
此时,利用工厂模式就可以创建出两个方式表示的点,代码中的 makePointByXY 方法与 makePointByRA 方法也称为工厂方法,如果把工厂方法放到一个其他的类中,这个类就叫做“工厂类”,总的来说,通过静态方法封装 new 操作,在方法内部设定不同的属性来完成对象的初始化,构造对象的过程,就是工厂模式。
(5) handler(拒绝策略)
第七个参数:RejectedExecutionHandler handler 这个参数可以算是最重要的一个参数,在前面介绍的第五个参数:BlockingQueue了<Runnable> workQueue 这是线程池中的一个阻塞队列,用来存储当前线程池要执行的任务都有哪些,它能够容纳的元素是有上限的,此时当这个阻塞队列中的任务已经排满了,还有新的任务要往这个阻塞队列中添加,线程池该怎么办?这就需要我们的第七个参数:RejectedExecutionHandler handler 来指明一个拒绝策略,如下图所示:
上图中的这四个类也就代表了四种拒绝策略,它们所对应的拒绝策略如下表所示:
ThreadPoolExecutor.AbortPolicy | 继续添加任务,直接抛出异常。 |
ThreadPoolExecutor.CallerRunsPolicy | 新的任务由添加任务的线程负责执行。 |
ThreadPoolExecutor.DiscardOldestPolicy | 丢弃最老的任务,添加新的任务。 |
ThreadPoolExecutor.DiscardPolicy | 丢弃最新的任务。 |
3.使用标准库中线程池
上面介绍了 ThreadPoolExecutor 类的构造方法及构造方法中的参数,可以看出来 ThreadPoolExecutor 类本身用起来比较复杂,因此在标准库中还提供了另一个版本的线程池,也就是把 ThreadPoolExecutor 类给封装了一下,这个线程池就是 Executors 工厂类,通过这个类来创建出的不同线程池对象,Executors 类在内部把 ThreadPoolExecutor 创建好了,并且设置了不同的参数,下面就使用 Executors 演示一下标准库中线程池的效果吧。
如下图所示,在 Executors 中内置了很多版本的线程池,这里我们使用固定数目的线程池来简单演示一下线程池的效果即可。
下面使用线程池的具体代码及运行结果如下所示:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TestDemo7 {
public static void main(String[] args) {
// ExecutorService 提供了一种管理和控制异步任务执行的方式
ExecutorService service = Executors.newFixedThreadPool(4);
// 使用 submit 方法把任务添加到线程池中
service.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
介绍完这两个标准库中的线程池,可以明确一点,当我们只是想简单用一下线程池,就可以使用 Executors ,当我们希望高度定制化一个线程池,就可以使用 ThreadPoolExecutor。
三、实现线程池
在前面介绍了标准库中的线程池及演示了使用的效果,下面我就来写代码实现一个简单的线程池,这里我就直接写一个固定线程数目的线程池,下面是这个简单线程池中包含的内容:
- 提供构造方法,指定创建多少个线程;
- 在构造方法中,把这些线程都创建好;
- 创建一个阻塞队列,能够持有要执行的任务;
- 提供 submit 方法,可以添加新的任务。
那么下面我就直接上代码了,关于这个简单线程池实现的细节我会在代码中以注释的方式进行介绍,线程池实现的代码及运行结果如下所示:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
class MyThreadPoolExecutor {
// 创建阻塞队列,用来接收任务,这里设置最多容纳任务量为 100
private BlockingQueue<Runnable> blockingQueue = new ArrayBlockingQueue<>(100);
// 创建线程链表,把创建的每个线程都用线程链表组织起来
private List<Thread> threadList = new ArrayList<>();
// 构造方法,指定线程池中固定的线程数,并且将线程都创建好
public MyThreadPoolExecutor(int num) {
for (int i = 0; i < num; i++) {
Thread t = new Thread(()->{
while (true) {
// 利用 runnable 来接收阻塞队列中的任务
Runnable runnable = null;
try {
// 获取任务
runnable = blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 执行任务
runnable.run();
}
});
// 启动线程
t.start();
// 将线程加入到线程链表中
threadList.add(t);
}
}
// 方法 sumbit 用来向阻塞队列中添加新的任务
public void sumbit(Runnable runnable) throws InterruptedException {
blockingQueue.put(runnable);
}
}
public class ThreadDemo8 {
public static void main(String[] args) throws InterruptedException {
// 创建线程池,指定线程数目为 4
MyThreadPoolExecutor executor = new MyThreadPoolExecutor(4);
// 循环 100 次,向线程池中添加 100 个任务
for (int i = 0; i < 100; i++) {
int n = i;
executor.sumbit(new Runnable() {
@Override
public void run() {
// 任务的内容
System.out.println("执行任务:->" + n + ",执行的线程是:->" + Thread.currentThread().getName());
}
});
}
}
}
如上图的运行结果可以看出,多个线程之间的执行顺序是不确定的,某个线程获取到了某个任务,但并非是立即执行,在这个过程中很有可能另一个线程就插到前面了,这里的这些线程彼此之间都是等价的。
·结尾
文章到这里就要结束了,回顾本篇文章,我介绍了什么是线程池,标准库中线程池还有实现了一个简单的线程池,其中还是要多理解一下标准库中线程池构造方法每个参数的意思,及理解拒绝策略的含义,这可以让我们对 ThreadPoolExecutor 类的使用更加清晰,后面实现的线程池也就可以看出线程池基本的工作原理,那就是不断利用这 4 个线程来执行任务,这样就省去创建和销毁线程的开销,那么如果你感觉本篇文章对你有所帮助,还是希望能收到你的三连鼓励,如果对文章的内容有所疑问欢迎在评论区进行讨论,我们下一篇文章再见吧~~~