线程作为一种稀缺资源,在线程较多的情况下,频繁创建、销毁消除将会带来较大的系统开销。线程池基于池化思想,内部维护多个线程,可以对内部的线程进行统一的管理和监控,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

线程池

1、线程池的优点

上面我们提到,线程池是一种稀缺资源,频繁创建销毁线程会造成较大的系统开销,因此引入了线程池技术。

线程池技术主要具有以下优点:

  • 降低资源消耗。通过重复利用已经创建的线程,降低线程创建和销毁造成的损耗。

  • 提高响应速度。当任务下达时,任务不需要等待线程创建就能直接执行。

  • 提高线程可管理性。线程是稀缺资源,如果无限创建不仅会消耗系统资源,还会降低系统稳定性。使用线程池可以统一分配、调优和监控。

2、线程池架构设计

线程池这么好,那么需要我们自己实现吗?可以但没有必要,因为Java中以及封装好了线程池的实现。

Java中,线程池是通过Executor框架来实现的,该框架主要用到:Executor、ExecutorService、ThreadPoolExecutor、Executors这几个类。

image-20201114092231857

  • Executor 接口:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。
  • ExecutorService 接口:扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;提供了管控线程池的方法,比如停止线程池的运行。
  • AbstractExecutorService 抽象类:将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。
  • ThreadPoolExecutor 类:实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。

3、 线程池的简单使用

Java中的Executors线程池工具类为我们提供了3种创建线程的方法:

1
2
3
4
5
6
7
8
//执行一个长期的任务,性能好很多
ExecutorService threadPool1 = Executors.newFixedThreadPool(5);

// 一个任务一个线程执行的任务场景
ExecutorService threadPool2 = Executors.newSingleThreadExecutor();

// 适用于:执行很多短期异步的小程序或者负载较轻的服务器
ExecutorService threadPool3 = Executors.newCachedThreadPool();

尽管Executors为我们提供了3种创建线程池的方式,但是这几种创建方式并不适合在生产环境中使用:

阿里巴巴开发手册

image-20210309214056272

要分析其存在的隐患,要先去了解Java中线程池的核心类:ThreadPoolExecutor

4、 ThreadPoolExecutor

上面提到道,Executors方式创建的线程池在实践中容易导致OOM,为了解释清楚,我们先分析一下ThreadPoolExecutor。

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)

上面展示了ThreadPoolExecutor的构造方法,接下来我们了解这个7大参数

  • corePoolSize:线程池常驻核心线程数

  • maximumPoolSize:线程池能够容纳同时执行的最大线程数

  • keepAliveTime:多余的空闲线程存活时间、当空闲时间达到keepAliveTime,多余线程会被销毁直到剩下corePoolSize

  • unit:keepAliveTime单位

  • workQueue:任务队列,提交但未执行的任务。

  • threadFactory:线程池中工作线程的线程工厂,一般采用默认即可

  • handler:拒绝策略。当线程队列满并且工作线程大于线程池最大线程数,handler将指定任何拒绝新来的线程。

在使用ThreadPoolExecutor前,我们先通过图解方式讲解线程池底层执行的原理:

image-20201114094735529

  1. 创建线程池后,等待提交过来的线程任务
  2. 在调用execut()方法添加一个任务请求,线程池会做如下判断:
    • 如果正在运行的线程数量小于corePoolSize,则马上创建线程运行这个任务。
    • 如果正在运行的线程数量大于corePoolSize,则在workQueue中等待
    • 如果workQueue队列已满,且正在运行的线程数量还小于maximumPoolSize,则创建非核心线程立即执行这个任务。
    • 如果workQueue队列已满,且正在运行的线程数量大于或等于maximumPoolSize,则会使用拒绝策略执行拒绝。
  3. 当一个线程完成任务,会从workQueue中取出一个人任务执行。
  4. 当一个线程空闲时间超过keepAliveTime,线程池会判断:
    • 如果当前运行的线程数大于corePoolSize,那么线程会被停用

5、拒绝策略

上面提到当等待队列已满且线程池最大线程数也满的时候,无法继续为新任务提供服务,这时候,需要拒绝策略机制合理处理这个问题。

JDK中为我们提供了4种拒绝策略:

  • AbortPolicy:默认策略,直接抛出异常阻止系统正常运行。
  • CallerRunPolicy:“调用者运行”机制,不会抛弃任务,也不会抛出异常,而是给调用线程池的线程执行。
  • DiscardOldestPolicy:抛弃队列中等待最久的。
  • DiscardPolicy:直接丢弃任务,不做任务响应。(如果允许任务丢失,将会是最好的拒绝策略)。

6、Executors风险分析

上面我们已经简单介绍了ThreadPoolExecuto构造函数的各个参数,下面我们观察Executors中几种常见线程池的方式,进而分析其风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// FixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

// SingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

// CacheThreadPool
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

上面代码展示了Executors中创建创建线程池的逻辑,可以看到底层都是通过 ThreadPoolExecutor 来创建,这就是为什么要在分析前先了解一下它。

从上面提到的ThreadPoolExecutor 我看可以知道

  • 对于FixedThreadPool 与 SingleThreadExecutor 都是使用 LinkedBlockingQueue 作为阻塞队列,这是一个无界第队列,当阻塞的任务过多的时候,将会导致内存占用太大,从而导致OOM。
  • 对于CacheThreadPool ,最大线程数没有进行限制,当线程提交过多,将会导致线程池创建的线程太多,从而导致OOM。

7、自定义线程池

从上面分析,我们知道Executors的方式创建线程池确实存在安全隐患,因此我们应该使用 ThreadPoolExecutor 来创建线程池,那么应该如何配置其参数呢?

这需要我们区分不同类型的业务场景:CPU密集型和IO密集型

CPU密集型

CPU密集型指的是,任务需要大量的运算而没有阻塞(如IO阻塞),CPU一直在全力运算。

CPU密集型任务只有在正在的多核CPU上才能得到加锁。

可以通过Runtime.getRuntime().availableProcessors()拿到CPU核心数。

CPU密集型任务配置尽可能少的线程数量:线程数=CPU核数+1

IO密集型

由于IO密集型,线程常常是在等待IO操作而阻塞,因此应该配置尽量多的线程。

线程数量=CPU核心数*2

8、参考

评论