大话并发 – 线程池

内容纲要

图解线程池的工作原理

为什么需要线程池?

在解释为什么需要线程池这个问题之前,我们可以先来回顾下 TCP 的长短连接:

  • TCP 短连接:客户端和服务端之间每进行一次通话,就进行三次握手建立连接,四次握手断开连接
  • TCP 长连接:客户端和服务端之间进行一次通话后,在某一段时间内不断开连接,这样在这期间发生的通话就不用经过三次握手和四次握手的过程,大大缩短了响应时间并减少了资源损耗

其实就是池化思想,资源复用,线程池也差不多就是这个道理,它将多个线程预先存储在一个 “池子” 内,当有新的任务出现时可以避免重新创建和销毁线程所带来性能开销,只需要复用 “池子” 内的线程执行对应的任务即可。

线程池好处有三:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行
  • 提高线程的可管理性:这一点需要着重解释下。我们都知道,系统的资源是有限的,所以线程作为一个消耗系统资源的东西,就不可能无限制的创建。这样,我们通过引入线程池,对线程进行进行统一地分配和监控,降低手动管理每个线程的复杂度

简单来说,线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能由于系统创建大量同类线程而导致消耗完内存或者 “过度切换” 的问题。在阿里巴巴的《Java 开发手册》中也强制规定了:线程资源必须通过线程池提供,不允许在应用中自行显式创建线程

当然,尽管有了这样一个工具可以帮助我们便捷地管理线程,并且屏蔽了底层的复杂逻辑,但是只有深入了解这个工具的工作原理,才能更合理地使用这个工具。

线程池的工作原理

最开始的时候我们就讲过创建线程的三种基本方法,那现在学了线程池之后,就是创建线程的第四种方法了。

众所周知,线程创建出来自然是需要执行一些特定的任务的,那么,当我们向线程池提交一个任务之后,线程池是如何处理这个任务的呢?

首先,我们需要知道,线程池里的两个大佬:

  • 核心线程池(存储线程)
  • 工作队列(存储任务)

image.png

当提交一个新任务到线程池时,线程池的处理流程分如下三步走

  1. 判断核心线程池里的线程是否都在执行任务(核心线程池是否已满):
    • 如果不是,则创建一个新的工作线程来执行任务
    • 如果核心线程池里的线程都在执行任务,则进入下一步
  2. 判断工作队列是否已满:
    • 如果工作队列没有满,则将新提交的任务存储在这个工作队列里
    • 如果工作队列满了,则进入下一步
  3. 判断线程池中的所有线程是否都处于工作状态(线程池是否已满):
    • 如果没有,则创建一个新的工作线程来执行任务
    • 如果已经满了,则交给饱和策略来处理这个任务

所谓饱和,顾名思义,当队列和线程池都满了,线程池就没有能力再去处理新提交的任务,也即处于饱和状态了

那怎么办呀,有以下四种策略:

  • AbortPolicy(默认):无法处理新任务时直接抛出异常
  • CallerRunsPolicy:使用调用者所在的线程来运行新任务(这个很好理解,一般我们都是主线程提交任务,然后扔进线程池执行,对吧。当线程池满了后,如果使用这个策略,就会调用主线程来执行新任务)
  • DiscardOldestPolicy:丢弃队列里最近的一个任务,并将新任务加入队列
  • DiscardPolicy:不做任何处理,直接将新任务丢弃掉,粗暴!

以上就是线程池的处理流程,咱画个流程图就清楚了:

image.png

再来张更清楚的图加深理解~

image.png

还有一个问题,那就是工作队列中的任务什么时候能够被取出来被线程执行呢?

事实上,线程池中的线程执行任务分两种情况:

  • 一种就是正常的,创建一个线程并执行当前任务
  • 然后,在这个线程执行完该任务后,会循环从工作队列中获取任务来执行

为什么要设计这样复杂呢?

可以看出来,创建新线程永远不是最优先的选择,而是尽可能地复用已存在的线程。因为创建一个新线程需要调用全局锁来确定新线程的正确创建(会带来很大的性能瓶颈),而且线程的创建和销毁需要消耗资源,所以这种设计思路是在最大努力地避免这种情况的发生。

简单来说,在完成预热之后,也即核心线程池已经满了后,后续大部分的新任务都会走到工作队列中去,而不需要去创建新线程。这样就尽可能地避免了创建和销毁线程。

小结

这篇文章其实还是屏蔽了一些细节的,主要为了让大家消除对线程池的恐惧感,下篇文章会解释下创建线程池的两种方法

线程池的两种创建方法

线程池的创建方法总体来说可分为 2 大类:

  • 一种是通过 Executors 创建的线程池
  • 另一种是通过 ThreadPoolExecutor 创建的线程池

尽管我们常用的是第二种方法,但是不知道第一种方法你试试面试能过不(哭了,好难)

Executors

先来看 Executors 如何来创建线程池

Executors 封装了 6 种方法,对应创建 6 种不同的线程池:

  • FixedThreadPool
  • CachedThreadPool
  • SingleThreadExecutor
  • WorkStealingPool
  • ScheduledThreadPool
  • SingleThreadScheduledExecutor

1)Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待

image.png

举个例子:

// 创建包含 2 个线程的线程池
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(2);

// 创建任务
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 执行任务");
    }
};

我们创建了一个包含 2 个线程的线程池,以及 1 个任务。这样,我们把这个任务复制 4 份扔到线程池里面去,看看是什么结果。线程池执行任务的方法是 execute 或者 submit,具体工作原理在上篇文章已经解释过了:

fixedThreadPool.submit(runnable);
fixedThreadPool.submit(runnable);
fixedThreadPool.execute(runnable);
fixedThreadPool.execute(runnable);

execute 和 submit 的不同之处大伙应该也能猜到:execute 用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。submit 用于提交需要返回值的任务,线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成。

很常见的套路,之前的文章中也提到过一些类似的处理方式。

image.png

OK,接着上面的代码说,线程池中扔进来了 4 个任务,线程池会自动给每个线程分配任务,结果如下:

pool-1-thread-1 执行任务
pool-1-thread-2 执行任务
pool-1-thread-2 执行任务
pool-1-thread-1 执行任务

2)Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理任务所需(供 > 求),多出来的线程缓存一段时间后会被回收掉;而如果线程数不够(供 < 求),则线程池会新建一些线程出来

image.png

举个例子:

ExecutorService cachedThreadPool = Executors.newCachedThreadPool();

// 执行任务
for (int i = 0; i < 10; i++) {
    cachedThreadPool.execute(() -> {
        System.out.println(Thread.currentThread().getName() + " 执行任务");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
        }
    });
}

可以看到,我们把一个任务复制了 10 份扔到可缓存的线程池里面去,这个线程池会新建 10 个线程用来处理任务,结果如下:

pool-1-thread-1 执行任务
pool-1-thread-5 执行任务
pool-1-thread-3 执行任务
pool-1-thread-4 执行任务
pool-1-thread-2 执行任务
pool-1-thread-7 执行任务
pool-1-thread-6 执行任务
pool-1-thread-8 执行任务
pool-1-thread-9 执行任务
pool-1-thread-10 执行任务

3)Executors.newSingleThreadExecutor:创建只包含一个线程的线程池,它可以保证任务先进先出的执行顺序。也就说,先被扔进线程池的任务,就会被先执行

image.png

举个例子:

ExecutorService singleThreadPool = Executors.newSingleThreadExecutor();

// 执行任务
for (int i = 0; i < 10; i++) {
    singleThreadPool.execute(() -> {
        System.out.println("任务 " + i + " 被执行");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
        }
    });
}

结果如下:

任务 0 被执行
任务 1 被执行
任务 2 被执行
任务 3 被执行
任务 4 被执行
任务 5 被执行
任务 6 被执行
任务 7 被执行
任务 8 被执行
任务 9 被执行

4)Executors.newWorkStealingPool:和 SingleThreadExecutor 相反,WorkStealingPool 创建的是一个抢占式执行的线程池,也即任务执行顺序不确定。另外,从名字上各位应该也能看出,WorkStealingPool 创建的是包含多个线程的线程池,而 SingleThreadExecutor 创建的是仅包含 1 个线程的线程池

image.png

5)Executors.newScheduledThreadPool:创建一个可以执行延迟/定时任务的线程池。

image.png

这里我们要用到的方法就不是 execute/submit 了,而是 schedule

image.png

其中 参数 command 表示要执行的任务,delay 表示从现在开始延迟执行的时间,unit 表示延迟参数的时间单位

举个例子:

// 创建线程池
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);

// 创建任务
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("执行任务");
    }
};

System.out.println("3 秒后开始执行线程池服务" + new Date());
scheduledThreadPool.schedule(runnable, 3, TimeUnit.SECONDS);

6)Executors.newSingleThreadScheduledExecutor:同样的,从名字可以看出,这个方法创建的是仅包含 1 个线程线程池,并且它可以执行延迟任务

image.png

ThreadPoolExecutor

事实上,上述 Executors 的 6 种方法,其底层最终调用的都是 ThreadPoolExecutor 的构造函数,只不过参数不同罢了。

可以简单理解为,ThreadPoolExecutor 是最基本的创建线程池的方式,Executors 对其做了一定的封装。

那么是不是做了封装的东西就比较好呢?

其实不然,在阿里巴巴的《Java 开发手册》上,明确规定了:

【强制要求】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

为什么这么说呢,我们先来把 ThreadPoolExecutor 构造函数拥有的 7 个参数搞懂:

image.png

corePoolSize 核心线程数、maximumPoolSize 最大线程数以及 workQueue 阻塞队列应该不用说了

参数 keepAliveTime 也很好理解,表示最大线程数可以存活的时间,当线程中没有任务执行时,最大线程就会销毁一部分,最终保持核心线程数量的线程。参数 unitkeepAliveTime 的单位

参数 threadFactory 线程工厂,主要用来创建线程,默认为正常优先级、非守护线程。

参数 handler 拒绝策略,上篇文章也提过一嘴,即线程池中线程数量不够时拒绝处理任务的策略,提供了 4 种方案:

  • AbortPolicy (默认策略):拒绝执行并抛出异常
  • CallerRunsPolicy:使用当前调用的线程来执行此任务
  • DiscardOldestPolicy:抛弃阻塞队列头部(最旧)的一个任务,并执行当前任务
  • DiscardPolicy:忽略并抛弃当前任务

当然了,通常来说,我们一般用不到这么多参数,常用的还是 5 个参数的构造函数版本:

image.png

了解了这些参数后,我们来解释下,为什么不要使用 Executors 创建线程池(面试常考点,其实很简单):

1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM

image.png

2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM

image.png

所以呢,简单来说,最好不要使用 Executors 创建线程池的其主要原因,就是这些参数设置的不合理。

萌新的梦靥:Executor 与 Executors

ExecutorExecutors,我刚开始学并发这块的时候,这两个东西放一起我头都大了

小伙伴们刚开始学的时候对这两个东西肯定会懵逼,所以我也是放到现在才开始对比他俩,尽量让大伙儿的学习曲线可以平滑一点。

Executor

Executor 是个大人物。你似乎看不见他,但他时刻在你身边

为什么说 Executor 是个大人物,因为这东西不仅仅是一个接口这么简单,他代表着一个庞大的体系,一个庞大的框架

此处应有咆哮声:学线程池就 TM 离不开 Executor

在应用层,Java 多线程程序把一个大应用分解为若干个小任务,然后使用用户级的调度器(也就是 Executor 框架)将这些任务分配给固定数量的线程;在底层,操作系统内核将这些线程映射到硬件处理器上。这种 两级调度模型 如图所示:

image.png

Executor 框架分为三大部分:

  • 任务:包括被执行任务需要实现的接口:Runnable / Callable 接口
  • 任务的执行:包括任务执行机制的核心接口 Executor,以及继承自 ExecutorExecutorService 接口。Executor 框架有两个关键类实现了 ExecutorService 接口,这个在《线程池的两种创建方法》我们提到过,即 ThreadPoolExecutorScheduledThreadPoolExecutor
  • 任务执行的结果:包括接口 Future 和实现 Future 接口的 FutureTask

Executor 框架的重要成员可以看下图:

image.png

关于 Executor 这三大部分是如何互相合作正常运转的,我来给大伙儿总结下:

1)首先,创建一个或多个实现了 Runnable / Callable 接口的任务对象

class Task implements Callable<String >{
    @Override
    public String call() {
        // do something
    }
}

2)然后把任务对象交给 ExecutorService 进行执行(execute 或者 submit 方法)

3)submit 方法会将任务的执行结果封装在一个实现了 Future/FutureTask 接口的对象中并返回,可以通过 FutureTask.get() 方法来获取任务的执行结果(这个时候就从异步调用变成同步调用了,因为 get 方法会一直阻塞直到任务返回),当然,也可以通过 FutureTask.cancel() 来取消这个任务的执行

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(xxxxxxxxx);
Future<String> future = threadPool.submit(new Task());

try {
    System.out.println(future.get());
} catch(Exception e){
    e.printStackTrace();
} finally{
    threadPool.shutdown();
}

记住下面这张图,也就掌握了 Executor 框架的运转逻辑了:

image.png

这里再多说一句,可能很多萌新会被 Executor 接口和 Thread 类搞懵,咱不是学多线程吗,怎么学到后面都看不见 Thread 这个东西了?Thread 属于 Executor 体系吗?

这么说吧,Executor 跟线程池有关,Thread 跟单个线程有关。业务开发中我们一般很少使用 Thread 单独创建线程,都是直接用 Executor 创建线程池来使用。

有时候你可能会遇到某些面试官问的坑爹问题,比如让你比较下 Executornew Thread(),其实就是比较线程池和单线程的区别而已。

Executor vs. ExecutorService

这个其实没有什么好说的,`ExecutorService接口继承了Executor 接口,仅此而已,不过为了防止某些坑爹的面试官,这里还是做一下对比,ExecuotrExecutorServceExecutors 这三个东西放一起还是很容易让小白晕头撞向的。

  1. execute() 方法是在 Executor 接口中定义的,可以接收一个 Runnable 接口的对象,无返回值;而 submit() 方法是在 ExecutorService 接口中定义的,可以接受 RunnableCallable 接口的对象,并且拥有 Future 类型的返回值。
  2. 另外,不同于 Executor 接口只定义了一个 execute 方法,ExecutorService 提供了更多用于控制线程池的方法,比如 shutdown 用于终止线程池

image.png

Executors

Executors 是一个小人物。你似乎天天都能看见他,其实不过是 Executor 大佬安排的(滑稽)。

Executors 也属于 Executor 框架体系的一员,可能会有小伙伴从类关系图上看不出来这俩有啥关系,其实上篇文章我们说过,Executors 提供了一些静态方法用于快速创建线程池嘛(这也是为啥 Executors 被称为工厂类的原因),它们的返回值都是 Executor 的子类 ExecutorService

不过由于一些参数配置的不合理,Executors 这个工厂类我们不推荐使用了。

如何合理地配置线程池的参数

前文提到过,我们创建线程池的时候,最后不要使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

那么问题就来了,ThreadPoolExectutor 构造函数一共有 7 个参数,如何合理地去配置这些参数,从而获得更好的性能,更利于处理当前的任务呢?这就是线程池的使用面临的核心问题。

image.png

这三个参数:corePoolSize 核心线程数、maximumPoolSize 最大线程数和 workQueue 阻塞队列,就是我们在创建线程池时应该关注的重点。

先来看两个典型的使用线程池的场景:

  1. 线上 - 响应速度优先:需要快速响应用户的请求

    对于一个线上应用,如果一个页面半天都刷不出来,用户大概率就放弃查看这个页面了,所以这个时候快速响应用户的请求是最重要的。此时应该不设置阻塞队列去缓冲并发任务,并且调高 corePoolSizemaxPoolSize 去创造尽可能多的线程快速执行任务

  2. 线下 - 吞吐量优先:需要尽可能快地批量处理任务

    对于需要执行大量线下任务的场景,我们当然希望任务执行的越快越好。这种情况下,由于是线下场景,不涉及用户,所以并不需要太追求速度,而是关注如何使用有限的资源,尽可能地在单位时间内去处理更多的任务,也就是吞吐量优先的问题。所以这里应该设置阻塞队列去缓冲并发任务,调整合适的 corePoolSize 去设置处理任务的核心线程数

事实上,线程池执行的情况和任务类型相关性较大,所以,在配置线程池之前,我们有必要了解下,这个线程池要处理的任务,是 CPU 密集型任务 还是 IO 密集型任务 还是 混合型任务。解释一下:

  • CPU 密集型任务(CPU Bound):也即计算密集型任务。简单来说,CPU 要处理大量的计算任务,而对于读写磁盘/内存/网络的操作相对较少或者可以在很短时间内完成
  • IO 密集型任务(IO Bound):读写磁盘/内存/网络的操作相对较多或者需要耗费很长时间,这样的话,CPU 大部分时间都是在等待读写操作完成,CPU 的使用率不高。对于做 Web 开发的同学来说,其实遇到的大部分都是网络读写频繁的 IO 密集型任务。

OK,我们先来看 corePoolSize 的配置,如果 corePoolSize 配置不当,会造成哪些问题:

  1. corePoolSize 设置过小,这样流量突增的时候就需要先去创建线程,导致请求响应时间变长,用户体验变差
  2. corePoolSize 设置过大,空闲线程太多,白白地占用着系统资源却啥也不干

对于 CPU 密集型任务来说,显然,我们应该充分利用当前设备拥有的 CPU,比如说 16 核的 CPU,你可以直接将核心线程数配置成 16 个。

可以通过代码 Runtime.getRuntime().availableProcessors() 获取设备的 CPU 核数

不过呢,《Java 并发编程的艺术》书中给了我们一个建议,那就是将核心线程数设置成 (CPU 个数 + 1) 也就是 17 个。

这个多添加的一个核心线程,你可以理解为一个备份,有备无患

具体来说,即使当 CPU 密集型的线程偶尔由于缺页故障或者其他原因而被迫暂停时,这个 “额外” 的线程也能够确保 CPU 不会傻傻地等在那里啥也不干。

那有同学就会问了,为啥不配置个远大于 CPU 个数的核心线程数呢?多些备份不也挺好的。

答案很简单,因为频繁的线程上下文切换反而会降低任务执行的效率,降低吞吐量。

当然了,其实这里并不绝对,书中给出的仍然是一个比较理论的值,我们还是得根据具体的场景来进行调整。

比如说,你的设备上部署的不止一个应用,你就得考虑其他的应用的线程池配置情况,不然几个应用之间互相抢夺 CPU 资源,那这核心线程池个数设置成 (CPU 个数 + 1) 就跟没配一样。

对于 IO 密集型任务来说,CPU 大部分时间都是在等待读写操作完成(闲着没事干),但事实上,一个线程在等待 IO 操作结束的时候,其他线程还可以在 CPU 里面跑的,所以此时我们应该配置尽可能多的核心线程数,比如当前 CPU 个数的两倍。

尴尬的是,书中也只给出了核心线程数 corePoolSize 配置方法,业内目前似乎还没有一些成熟的方案或者公式供我们参考,所以线程池的参数的配置经验性比较强,对于 maximumPoolSize 最大线程数和 workQueue 阻塞队列,我只能说举几个参数配置不合理的场景,来给大伙儿做个参考:

  1. maximumPoolSize 设置偏小,workQueue 大小设置偏小,导致拒绝策略频繁被调用
  2. maximumPoolSize 设置偏小,workQueue 大小设置过大,导致很多的任务都被堆积起来,相应的,接口的响应时间就会变长
  3. maximumPoolSize 设置过大,导致线程上下文切换频繁发生,处理速度反而下降

1 Comment

Leave a Comment

您的电子邮箱地址不会被公开。 必填项已用*标注

close
arrow_upward