前面我们通过以下问题梳理了线程池相关内容:
本篇会通过下面几个问题去进行补充:
这两个是在面试中经常被问到的问题,如果想要回答好它们,首先需要了解,我们调整线程池中的线程数量的核心目的是为了充分“压榨”CPU 和内存等资源,从而最大限度地提高程序的性能。在实际工作中,我们需要根据任务类型的不同选择对应的策略。
首先来看 CPU 密集型任务,比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。对于这样的任务最佳的线程数为 CPU 核心数的 1~2 倍,如果设置过多的线程数,实际上并不会起到很好的效果。此时假设我们设置的线程数量是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。
针对这种情况,我们最好还要同时考虑在同一台机器上还有哪些其他会占用过多CPU 资源的程序在运行,然后对资源使用做整体的平衡。
第二种任务是耗时 IO 型,比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。对于这种任务最大线程数一般会大于 CPU 核心数很多倍,因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在任务队列中等待的任务就会减少,可以更好地利用资源。
《Java并发编程实战》的作者 Brain Goetz 推荐的计算方法:
线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)
通过这个公式,我们可以计算出一个合理的线程数量,如果任务的平均等待时间长,线程数就随之增加,而如果平均工作时间长,也就是对于我们上面的 CPU 密集型任务,线程数就随之减少。
太少的线程数会使得程序整体性能降低,而过多的线程也会消耗内存等其他资源,在实际工作中,最常见的,还是我在深入HDFS里面提到的那个思路——梳理清楚核心需求,做正确而快速的取舍决策。
所以想要准确配置,最好的做法是先进行压测,监控 JVM 的线程情况以及 CPU 的负载情况,根据业务需求和生产环境的实际情况,去衡量应该创建的线程数,才能做到合理并充分利用资源。
核心思路
- 线程的平均工作时间所占比例越高,就需要越少的线程;
- 线程的平均等待时间所占比例越高,就需要越多的线程;
- 针对不同的程序,进行对应的实际测试就可以得到最合适的选择。
这个问题是对上一个问题的补充,其本质就是问如何根据自己的实际需求,去设置线程池的各个参数来定制线程池。
定制自己的线程池和我们的业务是强相关的,首先我们需要掌握每个参数的含义,以及常见的选项,然后根据实际需要,比如说并发量、内存大小、是否接受任务被拒绝等一系列因素去定制一个适合自己业务的线程池,这样既不会导致内存不足,同时又可以用合适数量的线程来保障任务执行的效率,并在拒绝任务时有所记录方便日后进行追溯。
第一个需要设置的参数往往是 corePoolSize 核心线程数,合理的线程数量和任务类型,以及 CPU 核心数都有关系,线程的平均工作时间所占比例越高,就需要越少的线程;线程的平均等待时间所占比例越高,就需要越多的线程。
而对于最大线程数而言,如果我们执行的任务类型不是固定的,比如可能一段时间是 CPU 密集型,另一段时间是 IO 密集型,或是同时有两种任务相互混搭。那么在这种情况下,我们可以把最大线程数设置成核心线程数的几倍,以便应对任务突发情况。
当然更好的办法是用不同的线程池执行不同类型的任务,让任务按照类型区分开,而不是混杂在一起。
对于阻塞队列这个参数而言,我们可以选择之前介绍过的 LinkedBlockingQueue 或者SynchronousQueue 或者 DelayedWorkQueue。
还有一种常用的阻塞队列叫ArrayBlockingQueue,它也经常被用于线程池中,这种阻塞队列内部是用数组实现的,在新建对象的时候要求传入容量值,且后期不能扩容,所以ArrayBlockingQueue 的最大的特点就是容量是有限的。这样一来,如果任务队列放满了任务,而且线程数也已经达到了最大值,线程池根据规则就会拒绝新提交的任务,这样就可能会产生一定的数据丢失。但在一些业务场景下,相比于无限增加任务或者线程数导致内存不足,进而导致程序崩溃,数据丢失会要更好一些,如果我们使用了 ArrayBlockingQueue 这种阻塞队列,再加上我们限制了最大线程数量,就可以非常有效地防止资源耗尽的情况发生。此时的队列容量大小和maxPoolSize 是一个trade-off(权衡),如果我们使用容量更大的队列和更小的最大线程数,就可以减少上下文切换带来的开销,但也可能因此降低整体的吞吐量;如果我们的任务是 IO 密集型,则可以选择稍小容量的队列和更大的最大线程数,这样整体的效率就会更高,不过也会带来更多的上下文切换。
对于线程工厂 threadFactory 这个参数,我们可以使用默认的 defaultThreadFactory,也可以传入自定义的有额外能力的线程工厂,因为我们可能有多个线程池,而不同的线程池之间有必要通过不同的名字来进行区分,所以可以传入能根据业务信息进行命名的线程工厂,以便后续可以根据线程名区分不同的业务进而快速定位问题代码。
比如可以通过com.google.common.util.concurrent.ThreadFactoryBuilder 来实现,如下代码所示:
ThreadFactoryBuilder builder = new ThreadFactoryBuilder();
ThreadFactory chaosFactory = builder.setNameFormat("chaos-pool-%d").build();
我们生成了名字为 chaosFactory 的 ThreadFactory,它的 nameFormat 为 "chaos-pool-%d" ,那么它生成的线程的名字是有固定格式的,它生成的线程的名字如下:
Thread Name: chaos-pool-0
Thread Name: chaos-pool-1
Thread Name: chaos-pool-9
Thread Name: chaos-pool-7
Thread Name: chaos-pool-3
Thread Name: chaos-pool-6
Thread Name: chaos-pool-4
Thread Name: chaos-pool-5
Thread Name: chaos-pool-8
Thread Name: chaos-pool-2
Thread Name: chaos-pool-0
最后一个参数是拒绝策略,我们可以根据业务需要,选择合适的拒绝策略来使用:AbortPolicy,DiscardPolicy,DiscardOldestPolicy 或者 CallerRunsPolicy。
除此之外,我们还可以通过实现 RejectedExecutionHandler 接口来实现自己的拒绝策略,在接口中我们需要实现 rejectedExecution 方法,在 rejectedExecution 方法中,执行例如打印日志、暂存任务、重新执行等自定义的拒绝策略,以便满足业务需求。
如下代码所示:
private static class CustomRejectionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
//打印日志、暂存任务、重新执行等拒绝策略
}
}
在 ThreadPoolExecutor 中涉及关闭线程池主要有以下的方法:
可以根据自己的业务需要,选择合适的方法来停止线程池,比如通常可以用 shutdown() 方法来关闭,这样可以让已提交的任务都执行完毕,但是如果情况紧急,那就可以用 shutdownNow 方法来加快线程池“终结”的速度。
第一种方法叫作 shutdown(),它可以安全地关闭一个线程池,调用 shutdown() 方法之后线程池并不是立刻就被关闭,因为这时线程池中可能还有很多任务正在被执行,或是任务队列中有大量正在等待被执行的任务,调用 shutdown() 方法后线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭。但这并不代表 shutdown() 操作是没有任何效果的,调用 shutdown() 方法后如果还有新的任务被提交,线程池则会根据拒绝策略直接拒绝后续新提交的任务。
启动有序的关闭线程池。它不再接受新任务,但会等待已提交的任务(包括正在执行的和在队列中等待的)完成。
第二个方法是 shutdownNow(),也是 5 种方法里功能最强大的,它与第一种 shutdown 方法不同之处在于名字中多了一个单词 Now,也就是表示立刻关闭的意思。
在执行 shutdownNow 方法之后,首先会给所有线程池中的线程发送 interrupt 中断信号,尝试中断这些任务的执行,然后会将任务队列中正在等待的所有任务转移到一个 List 中并返回,我们可以根据返回的任务 List 来进行一些补救的操作,例如记录在案并在后期重试。
尝试立即停止所有正在执行的任务,并返回尚未开始执行的任务列表。
shutdownNow()对应源码如下:
/**
* 尝试停止所有正在执行的任务,暂停等待任务的处理,并返回等待执行的任务列表。
* 这些任务在从该方法返回时将从任务队列中移除。
*
* 此方法不会等待正在执行的任务终止。请使用 {@link #awaitTermination awaitTermination} 来实现这一点。
*
*
除了尽力尝试停止正在执行的任务外,没有其他保证。此实现通过 {@link Thread#interrupt} 取消任务,
* 因此任何无法响应中断的任务可能永远不会终止。
*
* @throws SecurityException {@inheritDoc}
*/
public List shutdownNow() {
// 用于存储等待执行的任务列表
List tasks;
// 获取主锁
final ReentrantLock mainLock = this.mainLock;
// 加锁
mainLock.lock();
try {
// 检查是否有权限关闭线程池
checkShutdownAccess();
// 将线程池状态设置为 STOP
advanceRunState(STOP);
// 中断所有工作线程
interruptWorkers();
// 从任务队列中移除所有任务并返回
tasks = drainQueue();
} finally {
// 释放锁
mainLock.unlock();
}
// 尝试终止线程池
tryTerminate();
// 返回等待执行的任务列表
return tasks;
}
可以看到源码中有一行 interruptWorkers() 代码,这行代码会让每一个已经启动的线程都中断,这样线程就可以在执行任务期间检测到中断信号并进行相应的处理,提前结束任务。这里需要注意的是,由于 Java 中不推荐强行停止线程的机制的限制,即便我们调用了 shutdownNow 方法,如果被中断的线程对于中断信号不理不睬,那么依然有可能导致任务不会停止。所以在开发中落地最佳实践是很重要的,我们自己编写的线程应当具有响应中断信号的能力,正确停止线程的方法我在这篇文章提到过,核心就是应当利用中断信号来协同工作。
第三个方法叫作 isShutdown(),它可以返回 true 或者 false 来判断线程池是否已经开始了关闭工作,也就是是否执行了 shutdown 或者 shutdownNow 方法。
这里需要注意,如果调用isShutdown() 方法的返回的结果为 true 并不代表线程池此时已经彻底关闭了,这仅仅代表线程池开始了关闭的流程,也就是说,此时可能线程池中依然有线程在执行任务,队列里也可能有等待被执行的任务。
如果线程池已启动关闭流程,则返回 true。但这并不意味着线程池已经完全关闭,只是表示关闭流程已经开始。
第四种方法叫作 isTerminated(),这个方法可以检测线程池是否真正“终结”了,这不仅代表线程池已关闭,同时代表线程池中的所有任务都已经都执行完毕了,因为我们刚才说过,调用 shutdown 方法之后,线程池会继续执行里面未完成的任务,不仅包括线程正在执行的任务,还包括正在任务队列中等待的任务。比如此时已经调用了 shutdown 方法,但是有一个线程依然在执行任务,那么此时调用 isShutdown 方法返回的是 true ,而调用 isTerminated 方法返回的便是 false ,因为线程池中还有任务正在在被执行,线程池并没有真正“终结”。直到所有任务都执行完毕了,调用 isTerminated() 方法才会返回 true,这表示线程池已关闭并且线程池内部是空的,所有剩余的任务都执行完毕了。
如果线程池已经完全关闭(所有任务都已完成并且线程都已终止),则返回 true。
最后一个方法叫作 awaitTermination(),它本身并不是用来关闭线程池的,而是主要用来判断线程池状态的。比如我们给 awaitTermination 方法传入的参数是 10 秒,那么它就会陷入 10 秒钟的等待,直到发生以下三种情况之一:
也就是说,调用 awaitTermination 方法后当前线程会尝试等待一段指定的时间,如果在等待时间内,线程池已关闭并且内部的任务都执行完毕了,也就是说线程池真正“终结”了,那么方法就返回 true,否则超时返回 fasle。我们则可以根据 awaitTermination() 返回的布尔值来判断下一步应该执行的操作。
使当前线程阻塞,直到线程池完成关闭(即 isTerminated() 返回 true),或者等待超时。如果在等待期间线程被中断,会抛出 InterruptedException。