Java线程池参数与内存的关系 java线程池参数设定原则 您所在的位置:网站首页 线程池的7大参数 Java线程池参数与内存的关系 java线程池参数设定原则

Java线程池参数与内存的关系 java线程池参数设定原则

2023-06-28 01:45| 来源: 网络整理| 查看: 265

接上一篇《Java并发系列(12)——ForkJoin框架源码解析(下)》

9.5 线程池的选择与参数设置9.5.1 JDK 预定义的线程池9.5.1.1 Executors#newCachedThreadPool

public

只传了 5 个参数,线程工厂和拒绝策略没有传。线程工厂不影响性能,拒绝策略比较重要。

拒绝策略不传就是默认,默认是 AbortPolicy,拒绝任务时抛异常:

/**

特点:

队列使用了 SynchronousQueue,没有空间,不存储任务;没有任务就没有线程;任务并发量增大,线程不够立刻新建线程;任务并发量降低,线程空闲 60 秒销毁;拒绝策略不会触发,在拒绝策略触发前,程序会因为线程过多先挂掉。

适用场景(各条件为“且”的关系,下同):

任务耗时短;非 cpu 密集型(会占用很多 cpu 资源)任务;并发量时高时低;cpu 资源充足;前提:能扛住高峰期最大并发量,程序不会挂掉。

此处,耗时多短算“短”,并发量多高算“高”,后面会提供一种计算思路。

9.5.1.2 Executors#newFixedThreadPool

public

特点:

线程数量固定(除非线程池创建之后又改设置,并且不考虑懒加载的过程);使用了 LinkedBlockingQueue 无界队列,队列容量无限;拒绝策略不会触发,拒绝策略触发前,程序会先因为任务堆积,内存占用过多挂掉。

适用场景:

并发量比较稳定;内存资源充足;前提:偶尔并发激增,能扛住不会挂。9.5.1.3 Executors#newScheduledThreadPool

public

特点:

队列使用 DelayedWorkQueue,无界队列;可执行定时任务;线程数量固定(除非线程池创建后又改设置);拒绝策略触发前,任务堆积,内存溢出,程序先挂(但定时任务其实基本不存在任务堆积的问题)。

适用场景:

有定时任务需求。9.5.1.4 Executors#newSingleThreadExecutor

public

特点:

相当于 Executors#newFixedThreadPool(1);区别是,线程数不会超过一个,因为线程池创建后不可再改设置 。

适用场景:

可用来控制任务有序执行;禁止修改线程池参数。9.5.1.5 Executors#newSingleThreadScheduledExecutor

public

特点:

相当于 Executors#newScheduledThreadPool(1);区别是,线程数不会超过一个,因为线程池创建后不可再改设置。

适用场景:

有定时任务需求;禁止修改线程池参数。9.5.1.6 Executors#newWorkStealingPool

public

特点:

使用了 ForkJoinPool;线程数量最多为 cpu 逻辑核心数(前提是正确使用,在讲 ForkJoin 源码的那一节中,我们分析过导致线程数量瞬间上万的情况);FIFO 模式。

适用场景:

有特殊需求不想跟其它业务共用 ForkJoin 的 common pool;有特殊需求需要用 FIFO 模式(LIFO 模式性能更优)。9.5.1.7 小结

本质上,JDK(8) 里面只有三个线程池:

ThreadPoolExecutor;ScheduledThreadPoolExecutor;ForkJoinPool。

Executors 里面定义的 6 个线程池可以对号入座。

另外,Executors 里面定义的 6 个线程池各自都存在一些问题,一般不建议使用。当然,如果没什么并发量,任务也不复杂,随便怎么都行。

9.5.2 线程池的选择

线程池的选择问题,其实就是 ThreadPoolExecutor,ScheduledThreadPoolExecutor,ForkJoinPool 三选一的问题。

ThreadPoolExecutor 是应用面最广的,能应付大多数情况。下面探讨一下什么情况使用 ScheduledThreadPoolExecutor 或 ForkJoinPool 能带来压倒性优势。

9.5.2.1 ThreadPoolExecutor Vs ScheduledThreadPoolExecutor

这两个线程池的比较很简单,因为 ScheduledThreadPoolExecutor 也就是多了定时调度功能的 ThreadPoolExecutor,所以只有涉及定时调度功能时才会用到 ScheduledThreadPoolExecutor。

9.5.2.2 ThreadPoolExecutor Vs ForkJoinPool

从功能上,ForkJoinPool 比 ThreadPoolExecutor 多了拆分子任务的功能,如果用 ThreadPoolExecutor 需要自己处理任务的拆分与合并,稍微麻烦一些。

从性能上,这个需要测试对比一下。

实现相同的目的:从 1 累加到 1000 亿。

ThreadPoolExecutor 实现:

package

从测试结果看,基本稳定在 7.3 秒多。

thread

再用 ForkJoinPool 来做:

package

测试结果,最快在 6.8 秒,多则 7.0 秒,看上去不太稳定,但都比 ThreadPoolExecutor 效率高。

fork

ForkJoinPool 效率一定比 ThreadPoolExecutor 高吗?修改子任务的 THRESHOLD,继续测试

THRESHOLD = 10_0000,测试结果:

fork

THRESHOLD = 100_0000:

fork

THRESHOLD = 1000_0000:

fork

THRESHOLD = 1_0000_0000:

fork

可以看出,ForkJoinPool 的性能是不确定的,跟怎么拆分任务关系很大。

在上面 ThreadPoolExecutor 的测试中,只拆分成每个线程一个任务,现在把代码中 taskSize 参数改为 16000,再看测试结果:

thread

ThreadPoolExecutor 从 7.3 秒变成现在 5.8 秒,效率获得了质的提升,而且比 ForkJoinPool 更快、更稳定。

所以 ForkJoinPool 更大的意义在于实现分治更方便,如果用 ThreadPoolExecutor 需要代码实现任务拆分与结果合并。至于性能上的优势,不太明显,甚至有时候效率可能还要稍差些。

如果从两者的实现上定性地分析:

ThreadPoolExecutor 所有线程共用一个队列,可能存在竞争;而 ForkJoinPool 每个线程都从自己队列取任务,没有竞争;看似 ForkJoinPool 更占优势,但实际上存在一个问题,究竟需要多少个线程并发从一个阻塞队列取任务,才会出现线程竞争的情况?BlockingQueue 是基于 AQS 实现的,性能还是不错的,就算竞争可能也只是自旋几次。ThreadPoolExecutor 很明确就是去固定的队列取任务;而 ForkJoinPool 有时候会扫描所有队列,寻找任务,反而可能更耗时一些;ThreadPoolExecutor 先执行完的线程不会协助其它线程;而 ForkJoinPool 有任务窃取机制,可能存在一些优势,但如果任务拆分合理,各线程完成任务的耗时应该不会相差太多;ForkJoinPool 要递归,所以要更频繁地创建栈帧、压栈、出栈,这绝对是一个劣势。9.5.3 ThreadPoolExecutor 参数设置

这一节将要探究,对于不同的场景,应该怎样设置线程池参数。

主要的参数只有三个:线程数、任务队列、拒绝策略。其中以线程数最为复杂。

9.5.3.1 线程数

要确定最佳的线程数设置,就必须要清楚线程数的影响因素,比如有:

cpu;内存;操作系统总线程数限制;单用户可创建线程数限制;单进程可创建线程数限制;linux 系统受 pid 最大值限制,每个线程都有一个唯一的 pid;文件句柄;连接池;其它因素。

这里主要考虑 cpu 和内存因素,以及任务自身特性。

9.5.3.1.1 cpu 因素

cpu 对线程数的影响,主要考虑任务的性质:

cpu 密集型:占用大量 cpu 资源,典型的如从 1 累加到 1000 亿;io 密集型:不太占用 cpu 资源,大部分时间在阻塞等待 io 返回;混合型:既有大量耗 cpu 操作,又有 io 操作。

cpu 密集型

对于 cpu 密集型任务,一个 cpu 核心一个线程即可,继续增加线程只会增加上下文切换,不会带来任何性能提升。

测试程序,纯 cpu 计算,从 1 累加到 1000 亿:

package

测试结果:

| 线程数 | 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32 | 64 | 1024 | 8192 | | :-------: | :----: | :----: | :----: | :----: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | 耗时(s) | 48.977 | 26.816 | 16.056 | 10.106 | 8.044 | 7.275 | 7.366 | 7.369 | 7.297 | 7.294 | 7.575 |

可以猜测程序跑在一个 16C 的机器上,在 16 线程以后,已经看不出有明显的性能提升了。

猜测是正确的,测试机为 window 10 系统笔记本,cpu 是 R7 4800U。

至于在大于 16 线程时的上下文切换损耗,需要输出上下文切换信息才能看出来,这里的测试无法得出结论。

io 密集型

对于 io 密集型任务,大部分时间耗在阻塞等待 io 操作返回,这段时间不占用 cpu,所以可以增加线程数,把这部分 cpu 利用起来。

io 密集型任务的测试比较麻烦,主要有两点:

io 耗时影响因素太多,非常不稳定;io 操作如果耗时较短,多线程带来的性能提升就不明显。

所以,这里采用同样不占用 cpu 的 sleep 方法调用来模拟一个耗时恒定的 io 操作。

测试程序:

package

测试结果:

| 线程数 | 16 | 24 | 32 | 64 | 1024 | 2048 | 3072 | 4096 | 6144 | 8192 | | :-------: | :-----: | :----: | :----: | :----: | :---: | :---: | :---: | :---: | :---: | :---: | | 耗时(s) | 128.618 | 85.753 | 64.375 | 32.253 | 2.105 | 1.120 | 0.846 | 0.603 | 0.460 | 0.385 |

上面模拟的这个 io 密集型任务,从 1 累加到 20480,每累加一个数,sleep 100 毫秒。

单线程预计耗时 20480 * 0.1 = 2048 秒,等待时间过长,所以没有测试。

测试结果从 16 线程到 8192 线程,可以发现:

随着线程数增长,效率的提升是线性的;直到 8192 个线程,仍然有提升空间,说明 cpu 性能还没有被完全利用。

这个测试中模拟 io 操作设置为 100 ms,耗时过长,把这个数值减小至 2 ms,同时把累加改到 2048000,继续测试,得到结果:

| 线程数 | 16 | 24 | 32 | 64 | 1024 | 2048 | 3072 | 4096 | 6144 | 8192 | | :-------: | :-----: | :-----: | :-----: | :----: | :---: | :---: | :---: | :---: | :---: | :---: | | 耗时(s) | 288.669 | 194.591 | 145.876 | 72.589 | 8.557 | 6.767 | 5.094 | 4.325 | 3.915 | 3.644 |

对比上一个测试结果,本次测试中,很明显当线程数增大到一定程度,效率已经不会线性提升了,说明 cpu 有效利用率在降低。

所以,对于 io 密集型任务,最大 cpu 利用率的线程数与 io 操作耗时有关。理想情况,io 操作阻塞期间,所有线程可以切换一遍。如,io 操作耗时 2 ms,线程上下文切换耗时 2 us,那么设置线程数为 cpu 核心数 * 1000(实际上可能会小很多,如果考虑非 io 操作耗时和其它线程的影响,以及进程间切换耗时长得多等因素),可以获得最大 cpu 利用率。如果少了,所有线程轮一遍,io 还没有完成,cpu 空闲;如果多了,cpu 忙不过来,没有意义。

混合型

对于混合型任务,如:

private

如果 cpu 操作和 io 操作耗时相差不多,可以考虑把任务拆成两部分,io 操作和 cpu 操作分别提交到不同的线程池执行。实际上相当于异步 io 操作。

如果两者耗时相差很大,如 cpu 操作耗时极短,io 操作相对耗时很长,拆分任务并不能获得明显的效率提升,就显得费力不讨好,这种情况可以把整个任务当作 io 操作处理。

9.5.3.1.2 内存因素

内存因素主要考虑:

JVM 可用总内存;堆内存;方法区内存;虚拟机栈内存;任务执行期间占用内存;任务排队占用内存。

忽略本地方法栈、程序计数器的内存占用,可以有:

JVM 总内存 = 堆内存 + 方法区内存 + 虚拟机栈内存 * 线程数。

注:此处不确定堆内存是否包含了元空间内存,方法区虽然被实现在堆中,但不一定占用的是堆内存,如果包含,则方法区内存可以去掉。

此外还有:

堆内存 = 其它业务占用内存 + 单任务执行占用内存 * 线程数 + 单任务排队占用内存 * 排队任务数;

所以,内存涉及到的一些限制因素:

线程太多,栈内存不够用, OOM;线程太多,同时处理太多任务,任务执行占用内存过多,堆内存不够用,OOM;核心线程太少,队列容量过大,大量任务排队,堆内存不够用,OOM。

那么多少线程合适?还要结合任务耗时与并发量来考虑。

9.5.3.1.3 任务耗时、并发量与最大响应时间

由任务耗时,可以计算出单线程每秒能处理多少个任务。如:单个任务单线程处理耗时 100 ms,那么可知单线程每秒可处理 10 个任务。

并发量不是一个数值,它应该是一个分布。比如:

半夜三更没什么业务,可能在长达数小时的时间里,平均并发不超过 10 个任务每秒;工作时间里,可能 80% 的时间,并发在 100/s 左右,10% 的时间 10/s 左右,10% 的时间 1000/s 左右;峰值并发 1000/s,最长持续 10 s。

最大响应时间,这里指的是一个任务提交之后要求最长多少时间内必须被处理完,由最大响应时间可以得到一个队列容量与线程数的关系:

任务响应时间 = 任务排队时间 + 任务处理耗时 =(前面排队的任务数量 /(单线程每秒处理任务数量 * 核心线程数))+ 任务处理耗时



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有