ThreadPoolExecutor参数设置
回顾一下ThreadPoolExecutor的参数
ThreadPoolExecutor有很多构造方法以及构造方式,最后都会调用一个七个参数的方法,参数的含义分别是:
- int corePoolSize: 核心线程数
- int maximumPoolSize: 最大线程数
- long keepAliveTime & TimeUnit unit: 最大空闲时间
- BlockingQueue workQueue: 工作队列,是一个阻塞队列
- ThreadFactory threadFactory: 线程工厂,可以给线程起一个有意义的名字,也可以对线程进行扩展,比如给线程设置UncaughtExceptionHandler;
- RejectedExecutionHandler handler: 拒绝策略,当上面的工作队列,是一个有界队列的时候,如果队列已经满了的场景的执行策略
那么这些参数该如何设置呢?
一、corePoolSize & maximumPoolSize
1.1 计算密集型 vs IO密集型
首先,我们需要识别该线程池运行的任务类型
计算密集型
计算密集型:加解密、压缩等耗费CPU资源的任务。
这种任务,CPU的核数为任务的瓶颈,线程数设置过多,只会导致线程的频繁上下文切换,进而导致性能下降。因此线程数量可以设置为CPU核数+1,这个+1,可以保证,即使线程由于偶尔出现缺页等原因导致暂停,也可以由这个“额外”的线程,保证CPU的时钟不会被浪费。
IO密集型
IO密集型:文件读写、网络通信、RPC调用(网络通信的一种)等任务。
这种任务,大部分时间阻塞在IO上,线程只是在等待,并没有真正的被利用起来,因此线程数可以设置的更大,一个经验值为2 * CPU核数
。不过这只是一个简单的经验值,理论上可以按照以下公式,计算线程数。
整理了这份Java面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记【点击此处】即可免费获取
代码解读
复制代码
线程数 = CPU核心数 * 线程运行总时间/线程计算时间
这个公式的核心思想就是,充分利用CPU,不让CPU空闲。这里线程运行总时间=线程等待时间+线程计算时间
,经过转换,可以得到一个网上更常见的公式。
scss
代码解读
复制代码
线程数 = CPU核心数 * ( 1 + 线程等待时间/线程计算时间)
有时,我们并不希望CPU被一种类型的任务全部占满,因此可能会有个CPU期望利用率,则有以下公式,这就是《JAVA 并发实战》中的理论线程数量:
scss
代码解读
复制代码
线程数 = CPU核心数 * CPU期望利用率 ( 1 + 线程等待时间/线程运行总时间)
🤔CPU核心数如何获取
java
代码解读
复制代码
int cupNumbers = Runtime.getRuntime().availableProcessors();
1.2 其他瓶颈
以上指定线程数的策略,主要是考虑了CPU的制约,然而,实际上任务的瓶颈,还收到其他因素的影响。比如内存、数据库连接池等因素。
- 比如我们数据库连接池的设置为10,那么我们即使线程池设置为100,也没什么意义,即使100个线程并发,也最多有10个线程真正执行,其他线程都在获取数据库连接处阻塞等待。
- 又或者,一个查询任务,会有大量的数据返回,占用大量内存,这时也需要控制并发数量,防止JVM内存被打爆,而产生OOM异常。
除此之外,我们的JVM上,一般也不会只有一个线程池在运行,如果按照以上的理论值配置,可能把所有的CPU资源都占满。另外还要结合线上的真实QPS,如果QPS很低,而线程数设置很高,也是一种浪费。因此,线程池的数量,是一个考虑多方面因素制约,取一个合理的值。
1.3 压测
真实的运行环境,往往比较复杂,我们可以采用压测的手段,不断调整参数,并结合业务特点,得到一个合理的线程数量。
- 对于线上的QPS/TPS很高任务,我们可以采用与压测结果比较接近的线程数,比如压测出500线程数效果较好,600也还可以,那么可以把核心&最大线程数,分别设置为500和600。
- 对于线上QPS较低的业务,可以设置较少的线程数量,如果非常低,一天甚至一个月都没有几次访问,又不是核心业务,甚至可以调用
allowCoreThreadTimeOut
方法,设置核心线也可以销毁,以免浪费线程资源。
二、keepAliveTime & TimeUnit unit
keepAliveTime & TimeUnit unit是最大空闲时间,如果一个线程在线程池中空闲时间,超过了指定值,则会触发判断,当前线程数量是否超过核心线程数量,超过,则尝试销毁线程资源,避免浪费。
最大线程数这个参数,就是为了抗住线上的峰值流量,如果核心线程处理不过来,并且用于缓冲的阻塞队列已经占满,则启动额外的线程(如果最大线程数大于核心线程数),处理流量。那么,这个空闲时间,需要基于线上的流量判断,找到线上高QPS的周期性。假设线上9点到12点,每半个小时有一次高峰,那么建议空闲时间大于这个周期值,防止非核心线程刚销毁,就迎来了另一波高峰。
三、workQueue
当所有的核心线程都被创建后,新提交的任务,会被提交到这个参数中提供的阻塞队列中。线程池有点类似于生产者、消费者模型,而这个队列就是生产者和消费者之间的缓冲区。
3.1 队列类型
我们先来看看有哪些队列类型,供我们使用:
- ArrayBlockingQueue : 基于数组结构的有界阻塞队列;
- LinkedBlockingQueue : 基于链表结构的阻塞队列,可以根据参数控制有界或者无界;
- SynchronousQueue : 一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue
- PriorityBlockingQueue : 具有优先级的无界阻塞队列;
- DelayedWorkQueue : 延迟队列,可以用于运行定时任务的线程池,比如
Executors.newScheduledThreadPool
就使用的DelayedWorkQueue。
先对比下我们比较常见的ArrayBlockingQueue和LinkedBlockingQueue:
对比项 | ArrayBlockingQueue | LinkedBlockingQueue |
---|---|---|
实现 | 基于数组,生产者消费者共用一把ReentrantLock | 基于链表,生产者消费者分别使用自己的ReentrantLock |
队列长度 | 有界,且必须构造时指定长度,无法动态修改 | 可以有界,也可以无界,支持动态改变队列长度 |
公平性 | 支持 | 不支持 |
内存 | 初始化时,就需要初始化整个数组 | 使用时才会初始化节点,但存储节点需要额外的引用 |
性能 | 生产者消费者使用一把锁,锁竞争更加激烈,但数组内存的cpu亲和性更好(更容易被L1/L2缓存) | 生产者消费者分别使用锁,在锁的角度上竞争没有那么激烈,但链表内存亲和性很差 |
根据以上对比,我们可以得出什么样的场景选择哪种阻塞队列
- 需要无界队列或者动态改变队列长度,选择LinkedBlockingQueue;
- 需要一个非常大的队列,建议LinkedBlockingQueue,ArrayBlockingQueue需要预先初始化,占用较大内存;
- 需要严格的先进先出,也就是公平性,使用ArrayBlockingQueue;
- 较高的QPS下,锁竞争会非常激烈,建议使用LinkedBlockingQueue。
相较于ArrayBlockingQueue和LinkedBlockingQueue,其他的队列有着比较明显的使用场景。
- SynchronousQueue : 适用于需要较高的吞吐量;
- PriorityBlockingQueue : 需要实现任务的优先级;
- DelayedWorkQueue :需要延迟执行,比如定时任务;
3.2 队列长度
不建议使用无界队列,因为如果消费者出现问题(挂了或者延迟很大),会导致阻塞队列不断膨胀,占用大量内存,进而影响进程中的其他任务。
四、ThreadFactory
线程池工厂,这里建议自定义线程池工厂
- 可以给线程起一个与线程池运行的任务相关的名字,打印日志的时候,一般会带上线程名字,这样就方便后续问题的排查。
- 可以自定义一个UncaughtExceptionHandler,用于处理线程抛出的异常。
五、RejectedExecutionHandler
当线程池阻塞队列打满,并且已经没有额外的非核心线程用于处理这些任务时,线程池根据指定的拒绝策略来执行,官方提供了四种策略
- AbortPolicy:默认的拒绝策略,会throws RejectedExecutionException。
- CallerRunsPolicy:提交任务的线程自己去执行该任务。
- DiscardPolicy:直接丢弃任务,没有任何异常抛出。
- DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
使用哪种,可以根据业务特点,自行选择。如果都不满足需求,可以自行实现RejectedExecutionHandler。比如:记录日志或者持久化后再抛出异常,用于后续补偿。