小小白祈祷中...

前言

上一章我们介绍了Java中的Thread类里一些常用的方法。本节我们就来聊一聊线程池

说到“池”,大家或许都不陌生,在java中,我们有见过数据库连接池,Java常量池,对象池等等,将实体进行“池化”,这种“池化”思想,有助于我们对实体进行统一的管理,监控和调用

本章的主要内容有:

  • 创建线程池
  • 构造方法的参数解读
  • 四种功能性线程池
  • 关闭线程池

作为经常被面试的一个模块,线程池的概念不是那么通俗易懂,但在实际开发应用中,线程池对程序性能优化有着不可磨灭的贡献。

线程池

线程池效率对比

首先,我们来对比下面两个程序运行的效率。程序一,使用前面我们学过的一般线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.*;

/**
* @author sixibiheye
* @date 2021/9/2
* @apiNote 线程池初识
*/
public class ThreadPoolDemo1 {
public static void main(String[] args) throws InterruptedException {
Long start = System.currentTimeMillis();
Random random = new Random();
List<Integer> list = new ArrayList<Integer>();
for (int i = 0; i < 100000; i++) {
Thread thread = new Thread( () -> {
list.add(random.nextInt());
});
thread.start();
thread.join();
}
Long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
System.out.println("大小:" + list.size());
}
}

简单理解就是创建100000个线程来对list添加数据,最后输出添加所需的总时间和list的大小。我们来看运行结果:

img程序二,使用线程池(不理解的小伙伴们可以先跳过往下看):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
* @author sixibiheye
* @date 2021/9/2
* @apiNote 线程池初识
*/
public class ThreadPoolDemo2 {
public static void main(String[] args) throws InterruptedException {
Long start = System.currentTimeMillis();
Random random = new Random();
List<Integer> list = new ArrayList<>();
ExecutorService executorService = Executors.newSingleThreadExecutor();
for (int i = 0; i < 100000; i++) {
executorService.execute( () -> {
list.add(random.nextInt());
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.DAYS);
Long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
System.out.println("大小:" + list.size());
}
}

运行结果:

img从耗时来看,使用线程池所需的时间比使用一般线程所需的时间足足减少了100多倍!由此看来,线程池对于程序的优化有着重大的意义。

实际上,从原理上理解,使用一般的线程,都需要经历创建,使用,销毁三个步骤,当创建的线程数非常大的时候,这种操作对内存的消耗比较大,导致效率低下。因此我们希望创建好的线程能够在指定时间内继续执行其他的任务,通过减少创建和销毁线程的消耗,以此来提高效率。

类比“银行办理业务排队”

其实,线程池概念的提出与我们的生活有密切关系(许多算法,概念的提出都能够在生活中找到对应的例子)。

如果要理解线程池,咱就必须提到“银行办理业务排队”的场景逻辑,这是一个非常非常非常典型的例子,请大家务必看懂,对线程池的理解非常有帮助:

去过银行办理业务的朋友们都知道,银行里有多个窗口来办理业务,此外还有等候区供人们休息。我们现在假设有这样一个场景 :

img

如上图,假设现在某银行有3个窗口(1,2,3号),2个备用窗口(4,5号),和可供3人休息的等候区。

现在有1人来办理业务, 这1个人带着“任务1”去了1号窗口办理业务:

img接着,第2,3个人也来办理业务,他们带着任务2,3分别去了2,3号窗口:

img这时,如果银行来了第4个人,他只能去等候区等候,第5,6个人亦是如此:

img此时,等候区人数已满,如果再来第7个人,银行行长只能开启备用窗口-----4号窗口,并让还在等候区等候的第4个人到4号窗口办理业务,等候区(队列)往前“挪一个”使得第7个人能够进入等候区:

img同理,如果再来第8个人,那就只能开启第二个备用窗口-----5号窗口,并让第5个人到5号窗口办理业务,等候区(队列)往前“挪一个”使得第8个人能够进入等候区:

img这时,不论是窗口数,还是等待区容量,都已经满了,如果来了第9个人,怎么办呢?

img对于第9个人,银行只能采取拒绝的方式,因为就当前情况,不管是窗口,还是等候区,都容不下第9个人了。

如何理解线程池?

上述的这个简单的银行排队流程,我们把它搬到线程里,描述如下:

线程池(银行)里有最多5个线程数,有3个是核心线程(1,2,3号窗口),另外2个是备用线程(4,5号窗口,或者叫非核心线程),当核心线程全被使用后,就将多余的任务以队列的形式放入“任务队列”(等候区)中,如果任务队列也满了,就开启备用线程(非核心线程),如果备用线程也全部被使用了,那么剩下多余的任务,就只能拒绝执行了。

理解完上述过程,理解线程池,就轻松多了。在Java中,线程池的真正实现类是ThreadPoolExecutor,翻阅源码,它有如下几种构造方法:

img

img

img

img

img

线程池的七个参数

上面四种构造方法中,最多的构造方法有七个参数,我们来看看这七个参数的具体含义:

  1. corePoolSize (必需) : 核心线程数(类比银行的1,2,3号窗口)。默认情况下,核心线程会一直存活,除非将allowCoreThreadTimeout设置为true,这样超时后,核心线程也会被回收。

  2. maxmumPoolSize (必需) : 最大线程数(类比银行的所有窗口)。对于非核心线程(4,5号窗口),在下面的keepAliveTime设定的时间超过之后,会被回收。同样的,将allowCoreThreadTimeout设置为true的话,这样超时后,核心线程也会被回收。

  3. keepAliveTime (必需) : 如上,设定非核心线程的闲置时间,超时后,非核心线程会被回收。

  4. unit (必需) : 指定上面keepAliveTime参数的单位,常用的有:

    • TimeUnit.MILLISECONDS(毫秒)

    • TimeUnit.SECONDS(秒)

    • TimeUnit.MINUTES(分)

  5. workQueue (必需) : 任务队列(类比银行的等待区)。通过线程池中的execute()方法提交的Runnable对象将存储在该对象中。一般使用阻塞队列

  6. threadFactory (可选) : 线程工厂-----指定新线程创建的方式,自定义ThreadFactory的话可以修改线程名,线程组,优先级,是否为守护线程等等,如果不想自定义,使用默认的**Executors.defaultThreadFactory() **即可。

  7. **handler (可选) : ** 当线程池创建的线程数达到最大值时,需要执行的拒绝策略拒绝策略需要实现RejectedExecutionHandler接口,并重写rejectedExecution(Runnable r , ThreadPoolExecutor executor)方法。

Executors框架为我们提供了四种常见的拒绝策略:

  • 1.AbortPolicy (默认) :丢弃任务并抛出 RejectedExecutionException 异常
  • 2.CallerRunsPolicy :丢给调度线程处理该任务
  • 3.DiscardPolicy :丢弃任务但不抛出异常。一般用于自定义处理模式。
  • 4.DiscardOldestPolicy :丢弃队列最早的未处理完的任务,然后尝试执行新任务。

请注意: 虽然指定了核心线程数和最大线程数,但是当线程池被创建后,线程不会立即创建,其会根据任务队列中是否有新任务要执行来实时地创建

ThreadPoolExecutor

下面我们来认识一下 ThreadPoolExecutor 这个类,来看源码:

首先,最底层是一个函数式接口(只有一个抽象方法Executor :

img

接着有一个叫 ExecutorService 的接口继承了 Executor ,其扩展了一些方法,如 isShutdown() , shutdown() , awaitTermination()等等:

img然后,有一个叫 AbstractExecutorService 的类实现了 ExecutorService ,提供了一些方法的实现:

img最后, 咱 ThreadPoolExecutor 类继承了 AbstractExecutorService ,并实现了 execute() 等重要的方法:

img

img现在,我们来看一个简单的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.*;

/**
* @author sixibiheye
* @date 2021/9/2
* @apiNote 线程池
*/
public class CustomThreadPool {
public static void main(String[] args) {

ExecutorService executorService = new ThreadPoolExecutor(25,50,1L, TimeUnit.SECONDS,new ArrayBlockingQueue<>(50),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
for (int i = 1; i <= 100; i++) {
executorService.execute(new TaskDemo4(i));
}
//当所有任务执行完之后,结束线程池服务
executorService.shutdown();
}
}
class TaskDemo4 implements Runnable{
private int i = 0;
public TaskDemo4(int i){
this.i = i;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "线程做了第" + i + "个任务");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

上述程序中,通过for循环产生了100个任务,请大家细细体会 ThreadPoolExecutor()构造方法中的7个参数如何取定。

如果已有任务数超过了线程池的最大线程数与任务队列容量之和,线程池就会执行拒绝策略,默认为上述第一种拒绝策略。比如将上述代码中的线程池创建参数—任务队列修改如下:

1
new ArrayBlockingQueue<>(49)

则会抛出 RejectedExecutionException 异常:

img

任务队列

七个参数中,大家比较模糊的是 workQueue ---- 任务队列

下面我们来看看 workQueue 如何取定。任务队列是基于阻塞队列实现的,采用的是生产者-消费者模式,在Java中需要实现 BlockingQueue 接口,当然我们可以自定义实现类,但JDK已经为我们提供了7种阻塞队列的实现类,我们简单的介绍其中最常用的三种:

  1. ArrayBlockingQueue :一个由顺序表结构组成的有界(需指明容量)阻塞队列,

  2. LinkedBlockingQueue :一个由链表结构组成的阻塞队列。可以指明容量,未指明容量时,默认为无界(Integer.MAX_VALUE).

  3. SynchronousQueue :一个不存储任何元素的同步阻塞队列。

请注意有界队列无界队列的区别:如果使用有界队列,当已有任务数超过了线程池的最大线程数与该队列容量之和后就会执行拒绝策略;而如果使用无界队列,队列容量无限大,已有任务数不可能超过该队列容量,所以设置 maxmunPoolSize 没有意义。

封装的线程池

基于 ThreadPoolExecutor 的七个参数值的不同设定,Executors类 (Executor接口的工具类)给我们封装了几个常用的创建线程池的方法:

可缓存线程池**(**CachedThreadPool)

img

img

  • 特点:无核心线程,非核心线程无限大,线程闲置60s后被回收,任务队列为不存储任何元素的同步阻塞队列
  • 适用场景:执行大量且耗时的操作

定长线程池**(**FixedThreadPool)

img

img

  • 特点:只有核心线程,线程一旦闲置立即被回收,任务队列为链表结构的无界阻塞队列
  • 适用场景:需要控制线程最大并发数的地方

定时线程池**(**ScheduledThreadPool)

img

img

imgimg

img

  • 特点:核心线程固定,线程闲置10ms后被回收,任务队列为延时阻塞队列
  • 适用场景:执行定时或周期性的任务

单线程化线程池**(**SingleThreadExecutor)

img

img

  • 特点:核心线程固定为1个,没有非核心线程,线程一旦闲置立即被回收,任务队列为链表结构的无界阻塞队列
  • 适用场景:串行执行所有任务

建议

总结起来,虽然使用Executors框架的4个功能线程池非常的方便,但是现在已经不建议使用了,而是采用最原始的方式:通过 ThreadPoolExecutor 来手动创建。

原因有两点:

  1. 使用原始的 ThreadPoolExecutor 可以使我们更加明确线程池的运行机制,减少对资源的浪费。

  2. 使用上述四种线程池还有自己的弊端:

  3. FixedThreadPool & SingleThreadExecutor :由于任务队列可以为无界队列,当任务过多时,可能会导致OOM(内存溢出)。

  4. CachedThreadPool & ScheduledThreadPool :由于最大线程数为无限大,当线程创建过多时,可能会导致CPU利用率接近100%。

关闭线程池

最后,我们来简单地介绍一下如何关闭线程池

在介绍如何关闭线程池之前,我们来看看线程池的五个状态:

img我们来简单认识一下这五种状态:

RUNNING

特点:线程池处在 RUNNING 状态时,能够接收新任务,能够执行已添加的任务。

  • 线程池一旦被创建,就处于 RUNNING 状态,并且线程池中的任务数为0。

SHUTDOWN

特点:线程池处在 SHUTDOWN 状态时,不接收新任务,但能处理已添加的任务。

  • 调用线程池的shutdown()方法时,线程池状态转变:RUNNING --> SHUTDOWN

STOP

特点:线程池处在 STOP 状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。

  • 调用线程池的 shutdownNow()方法 时,线程池状态转变:( RUNNING or SHUTDOWN ) --> STOP

TIDYING

特点:当所有的任务都已中止或结束后,ctl记录的“任务数量”为0,线程池会变为 TIDYING 状态。

  • 当线程池在 SHUTDOWN 状态下,任务阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN 变为 TIDYING
  • 当线程池在 STOP 状态下,线程池中执行的任务为空时,就会由 STOP 变为 TIDYING

TERMINATED

特点:线程池彻底终止,就变成TERMINATED状态。

  • 线程池处在 TIDYING 状态时,执行完terminated()方法后,就会由 TIDYING 变为 TERMINATED

接着,查阅源码,我们可以看到,JDK在ThreadPoolExecutor类中提供了几种关闭线程池的方法,源码如下:

img

img

状态解读

从上述源码结合前面学的知识可以发现:

  • 当线程池创建以后,初始时,线程池处于 RUNNING 状态,此时线程池中的任务为0;
  • 如果调用 shutdown() 方法,则线程池变为 SHUTDOWN 状态,此时线程池不能够接受新 的任务,它会等待所有任务执行完毕;
  • 如果调用 shutdownNow() 方法,则线程池处于 STOP 状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;
  • 当所有的任务已中止或结束后,“任务数量”为0,线程池会变为 TIDYING 状态。接着会执行 terminated() 函数。
  • 线程池处在 TIDYING 状态时,执行完 terminated() 之后,线程池就被设置为TERMINATED状态。

其实,关于多线程,JDK中提供的Thread类ThreadPoolExecutor类中还有许多我们可以学习的东西,如果小伙伴们感兴趣的话可以去翻阅有关源码和资料。