在 线程池整理汇总 中介绍了线程池的参数设置,其中涉及三个系统参数:每秒需要处理的任务数量、处理一个任务所需要的时间、系统允许任务最大的响应时间。
其实,总结下来应用场景一般分为两个维度:并发量
、任务执行时长
。一般分为:
1. 高并发、任务执行时间短:
线程池线程数可以设置为少一些(以CPU核数+1为准),减少线程上下文的切换。
2. 高并发、任务执行时间长:
解决这种类型任务的关键不在于线程池而在于整体架构的设计。
3. 并发不高、任务执行时间短
线程池线程数可以设置为少一些(以CPU核数+1为准),减少线程上下文的切换。
4. 并发不高、任务执行时间长
需要按任务区分,IO密集型任务
和CPU密集型任务
。
IO密集型任务
:任务时间长集中在IO操作上,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以加大线程池中的线程数目,让CPU处理更多的业务。可以参考 线程池整理汇总 中介绍了线程池的参数设置CPU密集型任务
:任务时间长集中在CPU计算上,线程池线程数可以设置为少一些(以CPU核数+1为准),减少线程上下文的切换。转自美团Java线程池实现原理及其在美团业务中的实践
场景1:快速响应用户请求(IO密集型任务)
描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。
分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,将调用封装成任务并行的执行,缩短总体响应时间。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高 corePoolSize 和 maxPoolSize 去尽可能创造多的线程快速执行任务。
场景2:快速处理批量任务(CPU密集型任务)
描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。
分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的 corePoolSize 去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。
线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。
我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:
动态化线程池的核心设计包括以下三个方面:
IO密集型任务
)。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。(2)并行执行大批次任务,提升吞吐量(CPU密集型任务
)。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。动态化线程池提供如下功能:
参数动态化
JDK原生线程池ThreadPoolExecutor提供了如下几个 public 的 setter 方法,如下图所示:
JDK 允许线程池使用方通过 ThreadPoolExecutor 的实例来动态设置线程池的核心策略,以setCorePoolSize
为方法例,在运行期线程池使用方调用此方法设置 corePoolSize 之后,线程池会直接覆盖原来的 corePoolSize 值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的 worker 线程,此时会向当前 idle 的 worker 线程发起中断请求以实现回收,多余的 worker 在下次 idel 的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的 worker 线程来执行队列任务。
重点是基于这几个public方法,我们只需要维护ThreadPoolExecutor的实例,并且在需要修改的时候拿到实例修改其参数即可。基于以上的思路,我们实现了线程池参数的动态化、线程池参数在管理平台可配置可修改,其效果图如下图所示:
用户可以在管理平台上通过线程池的名字找到指定的线程池,然后对其参数进行修改,保存后会实时生效。目前支持的动态参数包括核心数、最大值、队列长度等。除此之外,在界面中,我们还能看到用户可以配置是否开启告警、队列等待任务告警阈值、活跃度告警等等。关于监控和告警,我们下面一节会对齐进行介绍。
线程池监控
除了参数动态化之外,为了更好地使用线程池,我们需要对线程池的运行状况有感知,比如当前线程池的负载是怎么样的?分配的资源够不够用?任务的执行情况是怎么样的?是长任务还是短任务?基于对这些问题的思考,动态化线程池提供了多个维度的监控和告警能力,包括:线程池活跃度、任务的执行Transaction(频率、耗时)、Reject异常、线程池内部统计信息等等,既能帮助用户从多个维度分析线程池的使用情况,又能在出现问题第一时间通知到用户,从而避免故障或加速故障恢复。
用户基于JDK原生线程池ThreadPoolExecutor提供的几个public的getter方法,可以读取到当前线程池的运行状态以及参数,如下图所示:
动态化线程池基于这几个接口封装了运行时状态实时查看的功能,用户基于这个功能可以了解线程池的实时状态,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等。
package 线程池;
import java.util.concurrent.*;
public class ThreadPollDemo {
public static void main(String[] args) {
/* 创建线程池 */
int corePoolSize = 5;
int maximumPoolSize = 10;
long keepAliveTime = 60;
TimeUnit unit = TimeUnit.SECONDS;
BlockingQueue<Runnable> workQueue = new SynchronousQueue<>();
ThreadPoolExecutor pool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
/* 创建任务 */
Runnable task = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("+++");
}
};
/* 执行任务 */
pool.submit(task);
/* 打印线程池参数 */
printThreadPollInfo(pool);
System.out.println("+++线程池参数修改后+++");
pool.setCorePoolSize(10);
pool.setMaximumPoolSize(20);
printThreadPollInfo(pool);
}
public static void printThreadPollInfo(ThreadPoolExecutor pool) {
System.out.println(pool.getCorePoolSize());
System.out.println(pool.getMaximumPoolSize());
System.out.println(pool.getQueue());
System.out.println(pool.getActiveCount());
}
}
结果可见,线程池参数动态修改成功。
本实践中只实现了前两个参数的动态修改,因为 JDK 自带的 ThreadPoolExecutor 并没有对外提供设置 workQueue 及其容量大小的方法。
如果确实要实现动态调整工作队列,我们可以通过 ThreadPoolExecutor 提供的getQueue()
方法获取到工作队列对象实例然后修改其容量,但是默认 BlockingQueue 一旦创建就没法修改其容量大小,所以我们需要自己实现一个可以动态调整容量大小的队列即可。
Java线程池实现原理及其在美团业务中的实践
线程池详解(通俗易懂超级好)