)
前言在日常工作可能我们会看到一些Java线程池常见的配置方式CPU密集型线程数设为CPU核数加1IO密集型线程数设为CPU核数的2倍拒绝策略使用CallerRunsPolicy避免丢任务。队列长度先填1000不够再调大这些数字可以作为讨论的起点却不能直接拿到生产环境使用。因为不同的业务场景或使用场景所产生的结果是截然不同的。纯计算任务、调用数据库的任务和同时请求多个下游接口的任务需要的线程数可能相差数倍。并且线程池还会受到数据库连接池、HTTP连接池、容器CPU配额、接口延迟目标和流量波动的限制。所以说线程池参数没有脱离业务负载的“标准答案”。比较可靠的做法是先测量任务再计算初始值通过压测和监控修正。本文就以ThreadPoolExecutor为主给大家简单聊一聊。一、看懂ThreadPoolExecutor如何处理任务ThreadPoolExecutor常用构造方法public ThreadPoolExecutor( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)提交一个新任务后线程池按下面的顺序处理这个顺序很重要。线程数达到corePoolSize后线程池会优先入队而不是立即继续创建线程。只有队列放不下时线程数才会从核心线程数增长到最大线程数。二、参数控制详解1corePoolSize常态并发能力是线程池希望保留的基本线程数。核心线程数适合覆盖大部分常态流量不宜按极端峰值设置。设置过小会频繁排队设置过大则会增加上下文切换、线程栈内存和下游资源竞争。2maximumPoolSize短时峰值上限限制线程池最多拥有多少工作线程。它不是越大越好还要受到这些资源的约束容器或机器可用CPU数据库连接池容量HTTP客户端连接数下游接口的并发限制和限流策略JVM可用内存任务内部使用的锁、信号量等共享资源。3keepAliveTime空闲线程保留时间当线程数超过corePoolSize后多出来的线程如果空闲时间超过keepAliveTime会被回收。突发流量频繁出现时可以适当延长保留时间减少线程反复创建流量峰谷明显、低谷持续较久时可以缩短。4workQueue排队能力工作队列决定任务来不及执行时如何等待。常见选择如下队列特点适用情况ArrayBlockingQueue有界、容量固定、内存占用较容易估算大多数需要背压的业务线程池LinkedBlockingQueue可以有界不传容量时近似无界明确传入容量时可用SynchronousQueue不保存任务提交者直接与工作线程交接希望优先扩容线程、任务短且最大并发受控PriorityBlockingQueue按优先级取任务默认无界确实需要优先级调度并另做容量保护注意⚠️不建议默认使用无界队列。流入速度长期高于处理速度时任务会不断堆积最终表现为请求超时、堆内存上涨甚至OutOfMemoryError。5threadFactory线程命名和异常记录默认线程名类似pool-1-thread-1线上出现多个线程池后很难判断线程属于哪块业务。线程工厂至少应设置有含义的名称public final class NamedThreadFactory implements ThreadFactory { private final AtomicInteger sequence new AtomicInteger(1); private final String prefix; public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable task) { Thread thread new Thread(task); thread.setName(prefix sequence.getAndIncrement()); thread.setUncaughtExceptionHandler((t, e) - System.err.println(Uncaught exception in t.getName() : e)); return thread; } }6handler过载时如何处理JDK提供4种内置拒绝策略策略行为使用建议AbortPolicy抛出RejectedExecutionException默认选择适合快速失败并由上层降级CallerRunsPolicy由提交任务的线程同步执行可以减慢生产者形成简单背压DiscardPolicy直接丢弃不抛异常仅用于明确允许丢失的任务DiscardOldestPolicy丢弃队列头部任务再尝试提交很少适合普通业务需要确认任务时序和优先级名词解释“背压”指的是当下游处理不过来时让上游减慢生产或提交速度避免任务无限堆积。7unit时间单位unit只负责解释keepAliveTime。建议显式使用TimeUnit.SECONDS或TimeUnit.MILLISECONDS不要在代码里依赖读者猜测时间单位。三、线程数不能只看CPU核数设置线程数前先回答四个问题任务有多少时间在使用CPU有多少时间在等待数据库、网络、磁盘或锁下游最多允许多少并发这个应用在容器中实际能使用多少CPU四、CPU密集型任务怎么设置典型的CPU密集型任务包括加密、压缩、图片处理、复杂规则计算和大对象序列化。这类任务大部分时间都在计算增加过多线程不会增加CPU只会产生更多调度和上下文切换。1、初始线程数可以设在“有效CPU核数附近”线程数 ≈ 有效CPU核数2、如果任务偶尔发生短暂锁等待可以多留1个线程如果需要给GC、Web容器或其他线程池预留CPU则应少于核数。例如一个容器限制为4核任务几乎全是计算corePoolSize 3或4maximumPoolSize 4或53、最终取值要看吞吐量和延迟曲线。把线程数从4提高到8后如果吞吐量没有上升延迟和上下文切换反而增加就应该回退。五、IO密集型任务怎么设置IO任务在等待期间不占用CPU因此线程数通常可以高于CPU核数。一个常用的估算公式是N Ncpu × Ucpu × (1 W / C)N建议线程数线程池允许的目标并发线程数maximumPoolSizeNcpu有效CPU核数Ucpu期望的CPU利用率取值为0到1W任务平均等待时间C任务平均使用CPU计算的时间。假设容器有8核希望业务任务最多使用约75%的CPU。一次任务平均执行100ms其中20ms在计算80ms在等待N 8 × 0.75 × (1 80 / 20) 30。30只是候选值还要继续受下游容量约束。假设数据库连接池总容量为40其他业务至少要预留10个连接那么该线程池最多只能稳定使用30个数据库连接。六、用吞吐量反推常态线程数用Little定律估算维持目标吞吐量所需的并发数平均并发数 平均到达速率 × 平均处理时间如果线程池每秒接收180个任务每个任务从开始执行到完成平均耗时100ms平均并发数 180 × 0.1 18。这表示在负载稳定、任务模型相近的情况下大约需要18个线程持续工作。考虑正常波动后可以把corePoolSize初始设为18到20而不是直接设成前面算出的最大值30。名词解释Little定律是一个描述排队系统中平均任务数量的等式即LλW其中L是平均任务数λ是平均到达率W是平均处理时间。在软件开发中这个定律可用于理解项目的WIP工作进度和吞吐量帮助优化流程效率。七、队列长度怎么计算队列的作用是吸收短时波动不是保存无限积压。可以从允许的排队时间反推一个初始容量队列容量 ≈ 线程池稳定处理速率 × 可接受的排队时间假设线程池稳定处理速率约为每秒180个任务业务最多允许任务在队列中等待100ms队列容量 ≈ 180 × 0.1 18。可以先使用容量为20或32的有界队列进行压测。队列越大延迟越高假设20个线程处理单个耗时100ms的任务队列中已有1000个任务。粗略来看排在尾部的任务可能需要等待1000 ÷ 20 × 100ms 5s八、一个完整的参数计算示例容器CPU8核常态任务速率180个/秒短时峰值250个/秒平均任务耗时100ms其中CPU计算20ms其中IO等待80ms目标CPU利用率75%下游可分配并发30允许排队时间100ms任务不允许静默丢失1、先计算最大线程数候选值8 × 0.75 × (1 80 / 20) 30其中(1 80 / 20)表示任务总耗时相对于 CPU 计算时间的倍数。2、下游并发上限也是30因此maximumPoolSize 303、计算常态并发180 × 0.1 18但需给正常波动留出少量空间corePoolSize 204、按100ms排队预算估算队列180 × 0.1 18取一个便于观察的整数queueCapacity 20综上计算和预估后的配置int corePoolSize 20; int maximumPoolSize 30; int queueCapacity 20; ThreadPoolExecutor executor new ThreadPoolExecutor( corePoolSize, maximumPoolSize, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(queueCapacity), new NamedThreadFactory(order-query-), new ThreadPoolExecutor.AbortPolicy() );九、常见误区1、使用无界队列同时设置很大的maximumPoolSize无界队列总能接收新任务线程数通常不会超过corePoolSize。此时maximumPoolSize基本失去作用。2、队列和最大线程数一起设得很大大队列掩盖过载大量线程又增加资源竞争。下游故障时这种配置容易同时出现任务堆积、线程阻塞和超时重试。3、只调线程池不看下游连接池线程池最大线程数为100数据库连接池只有20。最终会有大量线程等待连接吞吐量由20个数据库连接决定额外线程只增加等待和内存占用。十、总结Java线程池参数可以按下面的思路确定用有效CPU核数和任务的等待/计算比例估算最大并发候选值用目标吞吐量乘以任务耗时估算常态并发并设置核心线程数用稳定处理速率乘以允许排队时间得到有界队列的初始容量再校验突发流量用数据库连接、HTTP连接和下游限流进一步约束最大线程数根据业务是否允许失败、阻塞或丢失选择拒绝策略持续监控排队时间、拒绝数和下游资源再动态修正。几个典型现象可以快速帮助定位方向现象可能原因调整方向CPU长期很低活动线程满任务大量排队任务在等待IO或下游连接检查下游容量确认安全后增加并发CPU接近上限增加线程后吞吐量不升已经是CPU瓶颈减少线程或优化计算队列持续增长从不下降流入长期大于处理能力限流、扩容或降低提交速率拒绝数偶发增加延迟仍稳定短时突发超过容量检查是否符合快速失败预期再评估小幅扩容数据库连接等待高CPU不高数据库连接或SQL成为瓶颈优化SQL、检查连接容量不能只加线程活动线程很少队列却有任务核心线程异常、任务阻塞或线程池状态异常查看线程转储和线程池生命周期欢迎各位大佬交流学习