只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

53、线程池

内容来自王争 Java 编程之美

虽然在平时的业务开发中我们很少直接使用线程池,但是在开发中所用到的很多框架、系统中,比如:Tomcat、Dubbo RPC 等,都离不开线程池
可以这么说,只要用到线程的地方,线程的创建、管理基本上都是由线程池负责的

我们在使用这些框架、系统的时候,需要对线程池参数做设置
线程池参数设置的合理性,特别是线程池的大小,直接影响了硬件的利用率和系统的性能,那么线程池到底开多大才合适呢?有什么理论依据吗?
带着这个问题,我们来学习今天的内容:线程池

1、线程池的简介

1.1、介绍

线程池是池化技术的一种,常见的池化技术还有数据库连接池、对象池等,池化技术用来:避免资源的频繁创建和销毁,提高资源的复用率

回到线程池,Java 线程的创建和销毁会涉及到 Thread 对象的创建和回收、系统调用以及系统调用引起的上下文切换,因此会消耗一定的时间
如果某个系统频繁使用线程处理短暂请求,比如 Tomcat 处理 HTTP 请求,那么频繁的创建和销毁线程势必会影响系统性能
这时我们就可以使用线程池来避免线程的频繁创建和销毁
当系统需要线程来处理请求时,直接从线程池中获取线程,当请求处理完成之后,系统再将线程归还给线程池以供复用

1.2、示例

在 Java 中线程池对应的类为 ThreadPoolExecutor 类,我们先来通过一个简单的例子,对 ThreadPoolExecutor 的用法有个直观的了解,示例代码如下所示

public class Demo {
public static void main(String[] args) throws InterruptedException {
// 创建与配置
ThreadPoolExecutor pool = new ThreadPoolExecutor(
5, 10, 1000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(15),
new ThreadFactory() {
private final AtomicInteger idx = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "pool-" + idx.getAndIncrement());
}
}, new ThreadPoolExecutor.DiscardPolicy());
// 执行
pool.execute(new Runnable() {
@Override
public void run() {
System.out.println("xiao zheng ge!");
}
});
// 关闭
pool.shutdown(); // 发起关闭请求
boolean terminated = false;
while (!terminated) {
// 返回值为 false 表示超时, 返回值为 true 表示线程池真正关闭
terminated = pool.awaitTermination(100, TimeUnit.SECONDS);
}
System.out.println("pool is shutdown.");
}
}

对于上述示例,你可能有很多疑问,没有关系,接下来,我们从线程池的创建、执行、关闭、配置这 4 个方面,详细讲解 ThreadPoolExecutor 类的使用方法和底层实现原理

2、线程池的创建

ThreadPoolExecutor 类的构造函数有很多,其中最底层的构造函数如下所示,其他构造函数均调用这个最底层的构造函数来实现

线程池的创建非常复杂,需要传递很多参数,用起来很不方便,JUC 当然不会坐视不管不管
JUC 的 Executors 类提供了一些工厂方法,用来创建一些常用类型的线程池,简化线程池的创建过程,关于 Executors 类以及这些工厂方法,我们留在下一节中讲解
比如 newFixedThreadPool()、newSingleThreadExecutor()、newCachedThreadPool(0、newScheduledThreadPool()

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);

从上述构造函数我们可以发现,创建线程池需要设置的参数有很多,接下来我们一一讲解一下这些参数

2.1、corePoolSize

这个参数表示核心线程池的大小,整个线程池分为两部分:核心线程池和非核心线程池
核心线程池中的线程一旦创建就不会销毁,非核心线程中的线程在创建之后,如果长时间没有被使用,便会销毁

2.2、maximumPoolSize

这个参数表示整个线程池的大小
因为整个线程池包含核心线程池和非核心线程,所以 maximumPoolSize 这个参数减去上一个参数 corePoolSize 就是非核心线程池的大小

2.3、keepAliveTime 和 unit

刚刚讲到,非核心线程池中的线程长时间没有被使用就会销毁,那么这里的 "长时间" 到底是多长时间呢?
实际上这个时间值就是通过 keepAliveTime 和 unit 这两个参数来决定,其中 unit 表示时间单位,可以是:毫秒、纳秒、分钟、小时、天等

2.4、workQueue

workQueue 用来存储任务
当有新的任务请求线程处理时,如果核心线程池已满,那么新来的任务将放入 workQueue 中,等待线程处理,workQueue 是阻塞队列
JUC 提供的阻塞队列有很多种,比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue 等等,都可以用于 workQueue

2.5、threadFactory

如果在创建 ThreadPoolExecutor 对象时,传入了 ThreadFactory 工厂类对象,那么线程池中线程的创建均通过工厂类中的 newThread() 方法来实现
我们可以自定义 newThread() 函数的实现方式,在创建线程的时候,附加一些其他信息,比如线程名称,具体用法可以参看本节开头的示例代码

2.6、handler

如果线程池中没有空闲的线程,也无法再创建新的线程(线程池中已经存在 maximumPoolSize 个线程),并且等待队列 workQueue 已满
那么此时有新的任务请求线程池执行,就会触发线程池的拒绝策略

我们可以通过参数 hanlder 来设置拒绝策略,当然这里只针对 workQueue 为有界阻塞队列(比如 ArrayBlockingQueue 或设置了大小的 LinkedBlockingQueue)的情况
如果 workQueue 是无界阻塞队列(比如 PriorityBlockingQueue 或没有设置大小的 LinkedBlockingQueue),那么拒绝策略永远都不会触发

RejectedExecutionHandler 是一个接口,接口的定义非常简单,如下所示

public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

ThreadPoolExecutor 预先定义好的一些拒绝策略类,如下所示,当然我们也可以通过实现 RejectedExecutionHandler 接口来自定义拒绝策略类

  • CallerRunsPolicy 对应的拒绝策略为:由任务递交者代替线程池来执行这个任务
  • AbortPolicy 对应的拒绝策略为:直接放弃执行任务,并抛出 RejectedExecutionException 异常
  • DiscardPolicy 对应的拒绝策略为:直接放弃执行任务
  • DiscardOldestPolicy 对应的拒绝策略为:删掉 workQueue 中的一个任务,再次调用 execute() 执行当前任务
// 由任务递交者代替线程池来执行这个任务
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public CallerRunsPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) { r.run(); }
}
}
// 直接放弃执行任务,并抛出 RejectedExecutionException 异常
public static class AbortPolicy implements RejectedExecutionHandler {
public AbortPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + e.toString());
}
}
// 直接放弃执行任务
public static class DiscardPolicy implements RejectedExecutionHandler {
public DiscardPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { }
}
// 删掉 workQueue 中的一个任务,再次调用 execute() 执行当前任务
public static class DiscardOldestPolicy implements RejectedExecutionHandler {
public DiscardOldestPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
e.getQueue().poll();
e.execute(r);
}
}
}

3、线程池的执行

当需要线程池执行任务时,我们只需要将待执行的任务,封装成 Runnable 对象,然后将 Runnable 对象传递给 execute() 函数即可,execute() 函数全权负责任务的执行
实际上线程池在创建时,并不会事先把线程创建好,线程池中的线程是在有任务需要执行时才创建,当任务到来时,线程池可能处于不同的状态,进而对应不同的处理方式,如下所示

  • 检查核心线程池是否已满,如果未满,则创建核心线程执行任务
  • 如果核心线程池已满,那么再检查等待队列是否已满,如果等待队列未满,则将任务放入等待队列
  • 如果等待队列已满,则再检查非核心线程池是否已满,如果未满,则创建非核心线程执行任务
  • 如果核心线程池、非核心线程池、等待队列都满,则按照拒绝策略对任务进行处理

我们将 execute() 函数的上述执行过程,用图表示出来,如下所示,图中的 1 ~ 4 编号分别对应上述的 4 种不同的处理方式
image

实际上线程池执行任务的过程,并非是从线程池中取出线程然后执行任务,而是将任务放到等待队列中等待线程的读取并执行

  • 核心线程被创建之后,会调用 workQueue 上的 take() 函数,不停的从 workQueue 中取任务处理
    take() 函数是阻塞函数,当 workQueue 中没有待执行的任务时,take() 函数会一直阻塞等待
  • 非核心线程被创建之后,会调用 workQueue 上的 poll(),不停的从 workQueue 中取任务处理
    poll() 函数也是阻塞函数,跟 take() 函数的不同之处在于,poll() 函数可以设置阻塞的超时时间
    如果 poll() 函数的阻塞时间超过 keepAliveTime(在创建线程池时设置的非核心线程空闲销毁时间),那么 poll() 函数会从阻塞中返回 null
    因为非核心线程在 keepAliveTime 内,没有执行任务,所以会执行线程销毁逻辑

4、线程池的关闭

当应用程序关闭时,线程池也需要关闭,ThreadPoolExecutor 提供了两个线程池关闭函数,如下所示,对应两种不同的线程池关闭方式

public void shutdown();
public List<Runnable> shutdownNow();

当我们在线程池上执行 shutdown() 函数时

  • 线程池会拒绝接收新的任务
  • 但是会将正在执行的任务以及等待队列中的任务全部执行完成,这是一种比较优雅的关闭线程池的方式

当我们在线程池上执行 shutdownNow() 函数时

  • 线程池会同样拒绝接收新的任务
  • shutodownNow() 会清空等待队列,返回值为等待队列中未被执行的任务,并向所有的线程发送中断请求
    • 如果这个线程:在调用 take() 或 poll() 阻塞等待获取任务,那么这个线程会被中断,然后结束
    • 如果这个线程:正在执行任务,收到中断请求之后
      既可以选择响应中断,终止执行,也可以选择不理会,继续执行直到任务执行完成,具体看业务逻辑有没有编写对中断的响应处理逻辑

需要注意的是,shutdown() 和 shutdownNow() 函数返回时,线程池内的线程有可能还在执行任务
因此如果我们要确保所有的线程都已经结束,需要调用 awaitTermination() 函数阻塞等待,具体可以参看本节开头的示例

5、线程池的配置

线程池应该开多大是开发和面试中经常遇到的一个问题,对于这个问题,我们需要分以下几种情况来分析

  • 对于 CPU 密集型程序,对应的线程池不需要开太大,跟可用 CPU 核数相当或稍大即可,这样便可以充分的利用 CPU 资源
  • 对于 I / O 密集型程序,因为程序的大部分时间都在执行 I / O 操作,所以 CPU 利用率很低
    为了提高 CPU 的利用率,我们可以将线程池适当开大点,以便众多线程轮流使用 CPU
  • 那么既非 CPU 密集又非 I / O 密集的程序,对应的线程池大小又该如何设置呢?有没有具体的计算机公式可以给出线程池大小的确切值呢?

从理论上来讲,确实存在这样的计算公式(因为大部分非 CPU 耗时一般都花费在 I / O 上,因此直接使用 I / O 耗时代指非 CPU 耗时)
假设通过监控统计,我们得知线程池执行的任务平均 CPU 耗时为 cpu_time 毫秒,平均 I / O 耗时为 io_time 毫秒,那么线程池大小的计算公式如下所示
以下是针对单核 CPU 的计算公式,如果 CPU 核有 N 个,那么我们只需要在计算结果上乘以 N,便是最终线程池的大小
除此之外,以下公式计算出的线程池大小指的是 CPU 利用率 100% 时对应的线程池大小

pool_size = (cpu_time + io_time) / cpu_time

对于上面的公式,我们举例解释一下,假设执行任务的平均 CPU 耗时占总耗时的 1 / 3,平均 I / O 耗时占总耗时的 2 / 3
那么根据上述计算公式,在单核 CPU 上,我们需要将线程池大小设置为 3,才能将 CPU 没有一丁点空闲时间,也就是 CPU 利用率 100%,具体如下图所示
image

上述计算公式的合理性还有两个前提

  • 一是没有瓶颈操作:各个操作不会随着线程的增加而性能降低
  • 二是没有瓶颈资源:各个资源的数量满足所有线程的需求

我们拿 Redis 举例来解释一下瓶颈操作

尽管 Redis 执行命令这一任务是 I / O 密集型的,根据上述公式,理应将线程池开大点,才能充分利用 CPU 资源,但是 Redis 执行命令的过程中,I / O 操作才是瓶颈操作
尽管我们可以将线程池开的很大,让 CPU 利用率高达 100%,但命令的执行都阻塞在 I / O 操作上了,整体的执行效率并不会提高
这时我们就要重点关注 I / O 的利用率,而非 CPU 的利用率
如果单线程执行命令就可以让 I / O 负荷达到 100%,我们又何必使用多线程呢?

我们拿数据库连接举例来解释一下瓶颈资源

如果任务的执行依赖数据库,数据库连接是通过数据库连接池来管理的,假设数据库连接池的大小为 N
当线程数大于 N 时,数据库连接就成了瓶颈资源,多出来的线程需要等待数据库连接,整体的执行效率也不会提高
对于存在瓶颈资源的任务来说,在计算或者估计线程池大小时,不能再以 CPU 利用率 100% 为目标,而是以充分利用瓶颈资源为目标
也就是说,线程池大小应该设置为跟数据库连接池大小相当才算合理

6、课后思考题

如何统计线程池执行任务的平均耗时?
我们使用代理模式对 ThreadPoolExecutor 附加额外的功能:统计执行时间

public class EnhancedThreadPoolExecutor extends ThreadPoolExecutor {
private final List<Long> times = Collections.synchronizedList(new ArrayList<>());
public EnhancedThreadPoolExecutor(
int corePoolSize, int maximumPoolSize, long keepAliveTime,
@NotNull TimeUnit unit, @NotNull BlockingQueue<Runnable> workQueue,
@NotNull ThreadFactory threadFactory, @NotNull RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
}
/**
* 没有返回值
*/
public void execute(Runnable task) {
long startTime = System.currentTimeMillis();
super.execute(task);
times.add(System.currentTimeMillis() - startTime);
}
public List<Long> getTimes() {
return Collections.unmodifiableList(times);
}
public long getAvgTime() {
List<Long> snapshot = Collections.unmodifiableList(times);
long sum = 0;
for (int i = 0; i < snapshot.size(); i++) {
sum += snapshot.get(i);
}
return sum / snapshot.size();
}
}
posted @   lidongdongdong~  阅读(39)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开