你知道怎么合理设置线程池参数吗?
1.背景
在当下互联网时代中,硬件资源配置不再是系统瓶颈,因此为了最大程度化利用服务器CPU的多核性能,并行执行任务大势所趋。通过线程池管理线程实现异步操作和并发执行是一个非常常见的方式。线程池就是利用"池化资源"的技术思想降低资源消耗,提高响应速度。池化资源技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
2.线程池的创建方式
Java中的线程池核心实现类是ThreadPoolExecutor
,ThreadPoolExecutor
实现的顶层接口是Executor
,它定义了一种将任务提交与任务运行机制(包括线程使用、调度等的详细信息)分离的思想机制。
平时在系统开发实战中线程池的使用示例:
private ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("letter-pool-%d").build();private ExecutorService threadPoolExecutor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors()*2,Runtime.getRuntime().availableProcessors() * 20,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(Runtime.getRuntime().availableProcessors() * 100),namedThreadFactory);
Runtime.getRuntime().availableProcessors()
是当前系统cpu的核心数
namedThreadFactory
是创建线程的工厂,只能线程池的名称前缀
3.线程池参数
上面我们知道了怎么创建一个线程池,接下来我们就来详解看看线程池的参数,源码定义如下:
/*** Creates a new {@code ThreadPoolExecutor} with the given initial* parameters.** @param corePoolSize the number of threads to keep in the pool, even* if they are idle, unless {@code allowCoreThreadTimeOut} is set* @param maximumPoolSize the maximum number of threads to allow in the* pool* @param keepAliveTime when the number of threads is greater than* the core, this is the maximum time that excess idle threads* will wait for new tasks before terminating.* @param unit the time unit for the {@code keepAliveTime} argument* @param workQueue the queue to use for holding tasks before they are* executed. This queue will hold only the {@code Runnable}* tasks submitted by the {@code execute} method.* @param threadFactory the factory to use when the executor* creates a new thread* @param handler the handler to use when execution is blocked* because the thread bounds and queue capacities are reached* @throws IllegalArgumentException if one of the following holds:<br>* {@code corePoolSize < 0}<br>* {@code keepAliveTime < 0}<br>* {@code maximumPoolSize <= 0}<br>* {@code maximumPoolSize < corePoolSize}* @throws NullPointerException if {@code workQueue}* or {@code threadFactory} or {@code handler} is null*/public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler) {if (corePoolSize < 0 ||maximumPoolSize <= 0 ||maximumPoolSize < corePoolSize ||keepAliveTime < 0)throw new IllegalArgumentException();if (workQueue == null || threadFactory == null || handler == null)throw new NullPointerException();this.acc = System.getSecurityManager() == null ?null :AccessController.getContext();this.corePoolSize = corePoolSize;this.maximumPoolSize = maximumPoolSize;this.workQueue = workQueue;this.keepAliveTime = unit.toNanos(keepAliveTime);this.threadFactory = threadFactory;this.handler = handler;}
这里特意把注释也复制了一下,因为写的非常清楚明了,我们结合注释和源码浅析来看看每一个参数的含义所在:
-
corePoolSize
: 核心线程数大小。提交一个任务,线程池会判断当前线程数是否小于核心线程数,如果是则会立即创建一个工作线程执行任务,即使当前线程池中有空闲线程(ps:之前提交任务所创建的线程执行完任务之后就空闲下来了)可以用来执行当前提交的任务,也会创建一个新的线程去执行,概括来说就是如果没有达到核心线程数,不管当前线程池中有没有空闲线程,都会立即新建一个工作线程去执行当前任务。除非当前线程数达到核心线程数,这时候才会把任务放到任务队列中。当然了,线程池也可以预先创建好核心线程,不用等到每次提交任务时再创建线程
调用
prestartCoreThread()
:启动一个线程,等待任务,如果已达到核心线程数,这个方法返回 false,否则返回 true;调用
prestartAllCoreThreads()
:启动所有的核心线程,并返回启动成功的核心线程数ThreadPoolExecutor
默认不会回收核心线程,即使它们已经空闲了,这是为了减少创建线程的开销。如果非要回收空闲的核心线程,可以将线程池的allowCoreThreadTimeOut(boolean value)
方法的参数设置为true
,这样就会回收空闲(时间间隔由keepAliveTime
指定)的核心线程了 -
maximumPoolSize
: 最大线程数。线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如果使用了无界的任务队列存储任务的话这个参数就没什么效果,因为提交的任务都会无限地放到任务队列中。 -
workQueue
: 任务队列。用于存储等待执行的任务的阻塞队列。可以选择以下几个阻塞队列。ArrayBlockingQueue
:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。LinkedBlockingQueue
:一个基于链表结构的有界阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue
。SynchronousQueue
:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue
。PriorityBlockingQueue
:一个具有优先级的无界阻塞队列
-
keepAliveTime
: 当线程池中的线程数量大于corePoolSize
,即有非核心线程(线程池中核心线程以外的线程)时,这些非核心线程空闲后不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁。 -
unit
:keepAliveTime
参数的时间单位。 -
threadFactory
: 用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字。使用开源框架guava提供的ThreadFactoryBuilder
可以快速给线程池里的线程设置有意义的名字,代码如下:new ThreadFactoryBuilder().setNameFormat("XX-task-%d").build();
-
handler
: 拒绝策略。AbortPolicy
:直接抛出RejectedExecutionException
异常拒绝任务处理。CallerRunsPolicy
:使用提交任务的调用者所在线程来运行任务。DiscardOldestPolicy
:丢弃队列里最早未处理的任务。DiscardPolicy
:不做任何处理,直接丢弃掉。
4.线程池任务执行流程
执行任务是线程池ThreadPoolExecutor
的主要入口,当我们提交了一个任务之后,线程池将通过如下流程执行任务:
- 线程池会判断当前运行线程数是否小于核心线程数。如果是,则立即创建一个新的工作线程来执行任务。如果不是则进入下个流程。
- 线程池判断任务队列是否已经满。如果任务队列没有满,则将新提交的任务放入这个任务队列中,等待工作线程执行。如果满了,则进入下个流程。
- 线程池判断当前运行的线程数是否小于最大线程数。如果是,则创建一个新的工作线程来执行任务。如果不是,则进入下个流程
- 最后交给拒绝策略
RejectedExecutionHandler
来处理这个任务,拒绝策略请看上面介绍
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址:https://github.com/plasticene/plasticene-boot-starter-parent
Gitee地址:https://gitee.com/plasticene3/plasticene-boot-starter-parent
微信公众号:Shepherd进阶笔记
交流探讨qun:Shepherd_126
5.为啥不建议使用Executors创建线程池
内置工具类Executors
提供的线程池(如 newFixedThreadPool
、newCachedThreadPool
、newSingleThreadExecutor
)使用了一些默认配置(如最大线程数、队列类型等),这就可能任务积压,线程无限创建从而导致内存资源耗尽产生OOM。阿里巴巴Java开发手册强调了严禁使用Executors
创建线程池。
FixedThreadPool
和 SingleThreadExecutor
:使用的是有界阻塞队列是 LinkedBlockingQueue
,其任务队列的最大长度为 Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
CachedThreadPool
:使用的是同步队列 SynchronousQueue
, 允许创建的线程数量为 Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
ScheduledThreadPool
和 SingleThreadScheduledExecutor
:使用的无界的延迟阻塞队列 DelayedWorkQueue
,任务队列最大长度为 Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
6.线程池参数大小设置多少才合理
Java线程池的核心数设置应根据实际应用场景、服务器硬件配置以及工作负载的特性进行合理配置。合理设置线程池的核心数可以提升应用程序的性能、减少资源浪费,同时防止系统因线程过多而出现性能瓶颈。
线程池核心数的合理设置取决于任务的类型,即任务是 CPU密集型 还是 IO密集型。
CPU密集型任务
CPU密集型任务是指需要大量计算资源的任务,如数学计算、数据处理、加密等。这类任务主要消耗CPU资源,通常不需要等待外部资源(如网络、磁盘I/O等)。
- 最佳线程数:对于CPU密集型任务,最佳线程数通常设置为 CPU核心数 + 1。
- 原因:线程数不需要过多,因为CPU核心数决定了并行计算的上限。多出的1个线程是为了在偶尔出现线程切换、I/O等待时利用CPU资源。过多的线程数会造成上下文切换开销,性能反而下降。上下文切换是指多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换
- 公式:核心线程数 = CPU核心数 + 1
IO密集型任务
IO密集型任务是指需要频繁等待外部资源的任务,如数据库访问、文件读写、网络请求等。这类任务在运行过程中,线程大部分时间处于等待状态,因此CPU负载不高。
- 最佳线程数:对于IO密集型任务,线程数应该大于CPU核心数,通常设置为 2倍的CPU核心数 或 (CPU核心数 / 期望的线程阻塞比)。
- 原因:IO密集型任务频繁等待,因此可以通过增加线程数来提高CPU的利用率,确保在等待I/O操作时其他线程能够充分利用CPU进行其他任务。
- 公式:核心线程数 = CPU核心数 * (1 + (I/O等待时间 / 计算时间)) 或者简单设置为:核心线程数 = CPU核心数 * 2
在Java中可以通过以下方式获取系统的CPU核心数:
int cpuCores = Runtime.getRuntime().availableProcessors();
注意:当今服务器硬件资源配置有1个cpu核心2个线程的说法,比如说“4核8线程 8核16线程…”,这是不是意味着我们cpu密集型的线程池核心线程数大小应该设置为2*cpuCores + 1
了呢?其实不然
在 4核8线程 的 CPU 上,Java 线程池核心数的设置取决于任务的性质(CPU密集型 vs IO密集型)以及 CPU 资源的实际利用。推荐设置为 4 + 1 = 5 而不是 8 + 1 = 9 是因为 CPU密集型任务 最有效利用的是 物理核心,而不是逻辑线程。下面详细说明原因。
物理核心 vs 逻辑线程
- 物理核心 是 CPU 的实际处理单元,每个核心可以独立执行一个任务。
- 逻辑线程(或虚拟核心) 是通过 超线程技术 实现的,超线程允许每个物理核心同时处理两个线程的任务,但这并不意味着两个线程同时得到完整的 CPU 资源。超线程只是通过利用 CPU 空闲时间片提高并发性,并不能提高每个线程的计算性能。
物理核心数决定了 CPU 密集型任务的效率:在 CPU 密集型任务中,线程的执行时间完全依赖 CPU 的运算能力。一个物理核心同时只能执行一个计算密集型线程。如果超出了物理核心的数量,多个线程会开始竞争 CPU 资源,导致上下文切换,增加了不必要的开销和延迟。因此,4 个物理核心 正好可以处理 4 个计算密集型任务,推荐加 1 是为了应对偶尔的任务阻塞或线程等待情况,这样即使一个线程在等待,CPU 仍然能继续执行其他线程的计算任务。
超线程的作用:超线程技术 主要用于提高 CPU 利用率,在某些线程发生等待(例如 I/O 等待)时,让物理核心在其他空闲时间执行另一个线程的任务。然而,对于 CPU密集型 任务,由于每个线程始终需要完全占用物理核心进行计算,超线程的作用有限。超线程的两个逻辑线程并不会提高 CPU 计算能力,只是提高了任务调度的并行度。在 CPU 密集型场景下,超线程不会显著提升性能,过多线程反而可能导致性能下降