ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java线程池详解:ThreadPoolExecutor参数、阻塞队列与拒绝策略实战

Java线程池详解:ThreadPoolExecutor参数、阻塞队列与拒绝策略实战 1. 线程池到底解决了什么问题先说个我早年踩过的坑。那会儿刚工作没多久接手一个内部报表系统每次请求进来我都是 new Thread(...).start() 这种最朴素的写法。单机并发量不大二三十个请求同时进来系统也就撑住了。直到后来接了一个定时批量导出的需求每天凌晨要同时跑几十个导出任务再加上白天的正常请求JVM 直接频繁 Full GC线程数飙到几百个CPU 被打满整个服务像死了一样。后来排查才发现罪魁祸首就是无节制地创建线程。线程池这个东西项目标题写的是“基础”但我觉得它其实是 Java 并发编程里最实用、最值得先搞明白的一块。它解决的痛点非常朴素线程的创建和销毁是有代价的不能每次要用时就现场造一个。造一个线程要分配栈内存。默认栈大小通常 512KB 到 1MB1000 个线程就是几百 MB 的虚拟内存。而且线程多了之后CPU 大量时间花在线程切换上而不是干活上系统吞吐量不升反降。线程池的核心思路就一句话把线程作为一个可复用的资源提前备好任务来了直接分配线程去执行执行完线程不销毁继续等下一个任务。这个“线程复用”的机制省掉的不只是创建销毁的耗时更是让系统的线程总数可控。那这篇内容适合谁看我的建议是刚接触并发编程的初学者可以先建立整体概念工作一两年的开发者可以照着参数配置的思路去优化现有项目哪怕你是做架构设计的理解线程池背后的“池化思想”对你设计其他资源管理组件也有帮助。我尽量用大白话加实际场景来讲不搞晦涩的理论堆砌。2. ThreadPoolExecutor 七个参数一个都不能糊弄2.1 参数总览与直觉理解Java 里边最核心的线程池实现就是 ThreadPoolExecutor。我们平时用的 Executors.newFixedThreadPool()、newCachedThreadPool() 这些工具方法底层都是包了 ThreadPoolExecutor。直接用工具方法虽然方便但在生产环境里我基本不推荐原因后面会说。ThreadPoolExecutor 构造方法有七个参数我先把它们列出来再逐个拆解参数含义类比corePoolSize核心线程数正式员工人数maximumPoolSize最大线程数正式员工加临时工上限keepAliveTime非核心线程空闲存活时间临时工干完活后等多久走人unit存活时间单位等多久的时间单位workQueue任务队列客户等待区threadFactory线程工厂招聘渠道handler拒绝策略客户太多时怎么办我用的类比是“银行网点”。corePoolSize 就是柜台正式柜员处理业务高峰期时正式柜员不够用了客户就到等待区排队这个等待区就是 workQueue。排队区满了银行决定开临时窗口叫一波临时柜员顶上这就是 maximumPoolSize 里多出来的那部分。等客户少了临时柜员超过 keepAliveTime 没活干就被请走了。如果连临时窗口都满了等待区也满了再来客户就直接拒之门外这就是 handler 的工作。这个类比能帮你建立直觉但实际配置的时候有几个容易搞混的点我专门展开讲。2.2 核心线程数和最大线程数的关系很多人以为线程池一开始就会创建 corePoolSize 个线程其实不是。默认情况下线程池是懒创建来一个任务才创建一个核心线程直到数量达到 corePoolSize。当然可以用 prestartAllCoreThreads() 强制预热。核心线程数和最大线程数的关系最关键的是搞清楚“什么时候创建非核心线程”。这里有一个非常经典的问题任务来了是先创建新线程还是先丢进队列答案是当运行线程数小于 corePoolSize 时来任务直接创建线程执行根本不走队列当运行线程数等于 corePoolSize 时后续任务先进队列当队列满了并且运行线程数小于 maximumPoolSize 时才会创建非核心线程去执行新来的任务。这个顺序很重要。有一个常见误区就是“队列满了就丢给非核心线程执行”听起来对但你要注意是队列满了之后新来的任务直接交给非核心线程执行而队列里的任务还是继续排队。这会导致一个有意思的现象后来的任务可能比先来的任务先执行。虽然线程池的默认执行模型不是严格按任务顺序来保证的但理解这一点能帮你解释很多“任务执行顺序诡异”的问题。再往深说一层corePoolSize 和 maximumPoolSize 之差就是你允许系统在极端情况下使用的“弹性”资源。这个差值设得越大系统应对突刺并发的能力越强但代价是线程数剧增上下文切换开销变大。2.3 keepAliveTime 与线程回收keepAliveTime 只针对非核心线程。核心线程默认一辈子都待在池子里哪怕空闲也不销毁。但有一个例外如果设置了 allowCoreThreadTimeOut(true)核心线程也会在空闲超过 keepAliveTime 后被回收此时线程池的运行线程数可能降为 0。我建议对绝大多数服务端应用把 allowCoreThreadTimeOut 设置为 false也就是保留核心线程。因为线程池本来就是想避免重复创建销毁线程的核心线程几十个常驻内存开销很小换来的是响应速度更快。除非你的场景是“偶发任务 长时间空闲”否则没必要开启。还有个细节任务全部执行完后线程池里的线程不会自动消失如果项目停了一定要调 shutdown() 或 shutdownNow()否则 JVM 会因为线程未结束而无法正常退出。我见过很多测试环境启动的线程池没有关闭导致服务重启后老线程还挂在那边日志文件被占用非常头大。2.4 线程工厂的隐藏作用threadFactory 这个参数看起来不起眼很多人直接忽略。但生产环境排查问题的时候线程名字太重要了。默认的线程名是 pool-1-thread-1 这种你在 dump 线程栈的时候看到几十个 pool-x-thread-y根本分不清是哪条业务链路创建的。我强烈建议自定义 ThreadFactory用业务名称来命名线程。比如从订单系统拉的线程池线程名就叫 order-process-pool-1-thread-1。实现起来很简单ThreadFactory threadFactory new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(order-process-pool- counter.getAndIncrement()); t.setDaemon(false); return t; } };还可以顺手在 ThreadFactory 里设置异常处理器UncaughtExceptionHandler把线程抛出未捕获异常时打印关键信息。这算是低成本高收益的小动作线上排查问题的时候能省大量时间。2.5 拒绝策略的选择不能拍脑袋当线程池的线程都在跑、队列也满了再提交新任务就会触发拒绝策略。Java 内置了四种拒绝策略行为适用场景AbortPolicy直接抛 RejectedExecutionException默认策略任务不能丢时使用CallerRunsPolicy由提交任务的线程自己执行不想丢任务但也不在乎谁去执行DiscardPolicy静默丢弃允许丢任务的非核心场景DiscardOldestPolicy丢弃队列里最老的任务再尝试提交只关心最新任务的场景我见过不少项目直接默认使用 AbortPolicy结果高并发下一旦触发拒绝请求直接报错用户看到页面 500然后又触发重试再把线程池压得更满恶性循环。实际项目中像异步写日志这种允许丢弃的任务可以用 DiscardPolicy但对于核心交易链路拒绝策略最好是自定义的比如把被拒绝的任务持久化到本地文件或者发消息到告警渠道之后再捞回来补处理。别嫌麻烦生产环境不出问题则已一出就是大事。3. 阻塞队列的选择直接决定线程池行为3.1 队列种类和适用场景谈到“线程池的阻塞队列选择”这里面的门道比大多数人想象的多。队列选错了线程池的参数配得再好也白搭。因为队列和线程数是互相制约的队列容量越大线程池需要扩线程的机会就越少队列容量越小线程数容易被打满拒绝策略更容易触发。Java 里常见的阻塞队列有这些队列特点适用场景ArrayBlockingQueue有界数组队列必须指定容量需要严格控制任务积压量LinkedBlockingQueue链表队列默认无界可设容量任务流量波动大的缓冲场景SynchronousQueue不存储任务直接交给线程超高吞吐、不排队场景PriorityBlockingQueue优先级排序无界任务需要按优先级处理DelayQueue延迟出队延时任务、定时调度3.2 SynchronousQueue 和 CachedThreadPool 的爱恨情仇SynchronousQueue 是里面比较特殊的它不是一个真正的容器队列它内部不存储任务而是直接把任务交给一个等待取任务的线程。生产者和消费者之间直接握手机制。这个队列配合 Executors.newCachedThreadPool() 使用就是天生一对newCachedThreadPool 的核心线程数为 0最大线程数是 Integer.MAX_VALUEkeepAliveTime 是 60 秒。这导致一个后果来一个任务就必须创建一个线程来执行因为队列不缓存线程空闲 60 秒就被回收。这种线程池适合短平快的任务、任务数量不稳定但每个任务执行时间很短的场景。但是如果任务执行时间长它就会疯狂创建线程直到把系统资源耗尽。newCachedThreadPool 的“缓存”两个字容易误导人实际上的语义是“每来必建闲置即焚”。3.3 有界队列和线程数该怎么联动设置业界比较推崇的做法是用有界队列 有限最大线程数。LinkedBlockingQueue 默认无界是个大坑Executors.newFixedThreadPool() 底层用的就是无界 LinkedBlockingQueue如果任务生产速度远超消费速度队列里的任务会无限堆积直接内存溢出。那有人问既然无界队列有风险为什么 FixedThreadPool 还这么设计因为它在语义上保证“线程数固定绝不扩增”任务再多的也是排队。适合那种对并发资源有严格上限控制、并且任务量可预测的场景。对于绝大多数外部流量不可控的服务我不会用无界队列。更好的组合是一种“弹性策略”corePoolSize 设小一点队列容量设到一个合理水位maximumPoolSize 设一个稍高的值。队列没满之前任务排队让线程平稳消费队列满了以后扩线程吸收突发流量再不行就触发拒绝策略将任务引导到降级方案。这个组合的思想是让系统有一个缓冲带而不是在队列满和拒绝之间硬碰硬。具体容量怎么算可以粗略按“高峰期 QPS x 任务平均耗时 x 容忍排队秒数”框一个量级。比如高峰期每秒提交 200 个任务每个任务平均执行 0.2 秒你希望任务最多等待 5 秒那队列容量大约就是 200 x 5 1000。这是个估算口径真实环境还要配合压测调优。3.4 优先级队列和延迟队列的小切面PriorityBlockingQueue 是无界队列最大线程数这个参数基本失去了意义因为队列永远不会满线程数达到 corePoolSize 后就不再扩展了。它适合那种任务本身有明确优先级且任务总量可控的场景。注意它的排序基于 Comparator如果元素不实现 Comparable 且不传 Comparator会直接报 ClassCastException。DelayQueue 更小众一些经典使用场景是定时任务调度。比如一个订单超过 30 分钟未支付就自动关闭可以把任务放入 DelayQueue延迟到期了才出队被线程执行。网上很多循环扫描数据库的实现性能和实时性都不好用 DelayQueue 内存里做一版效果立竿见影。不过要注意应用重启后延迟队列里的任务全部丢失所以它只能作为短时近实时调度不能完全替代持久化任务系统。4. 从配置到落地线程池的完整实战4.1 手写一套靠谱的线程池配置先给出一份可以直接借鉴的代码。假设场景是一个外卖平台的订单推送服务每个推送请求需要经过鉴权、消息组装、渠道发送三个步骤单次任务平均 50ms高峰期每秒约 300 个推送请求系统要求任务不能丢失尽量不阻塞主流程。我给的初始配置是这样的ExecutorService pushPool new ThreadPoolExecutor( 40, 80, 60L, TimeUnit.SECONDS, new LinkedBlockingQueueRunnable(1500), new NamedThreadFactory(order-push), new CallerRunsPolicy() );为什么这么配我来解释一下计算思路。核心线程数 40基于“每秒 300 个任务单任务 50ms”来毛估一个线程每秒能处理约 20 个任务1000ms / 50ms那 40 个线程每秒能处理 800 个任务已经覆盖峰值流量。留有一定余量但也不至于线程数过多。队列容量 1500按照“高峰期排队不超过 5 秒”的目标来定300 x 5 1500。这 5 秒是给突发流量预留的缓冲如果持续打满队列还继续超过 5 秒那就说明下游发送渠道有性能问题应该触发降级而不是无限等。最大线程数 80是为了在队列塞满后快速扩线程覆盖多出来的流量。多出的 40 个线程就相当于临时应急力量配合 60 秒的 keepAliveTime流量高峰过去后它们会被回收。拒绝策略选 CallerRunsPolicy这里是基于业务诉求的推送任务不能丢那么当线程池饱和时由提交任务的线程自己执行。代价是可能阻塞调用方的主流程但在“不能丢任务”和“不能被阻塞”之间只能二选一这里我们先保“不丢”。如果你连阻塞也不能接受那就要把被拒绝的任务持久化到自己封装的重试队列里这就不是单纯线程池能解决的问题了。4.2 核心参数的调整路径与压测方法配置不是一次定死的我习惯的做法是先按估算定初值再用压测验证最后观察线上指标调整。压测的时候重点关注四个数据QPS每秒处理任务数、任务平均耗时、线程池活跃线程数、队列积压量。用 JMeter 或者自写脚本从低并发逐步往上加观察队列积压是否持续增长。如果 QPS 增长到某个点后不再上升、队列积压直线上升说明瓶颈在线程池下游要去看下游依赖的资源水位。线上运行后给自己配一个监控看板。我常用的是 Spring Boot 的 Actuator 暴露线程池指标或者自己在代码里定时打点输出核心指标像这样private void printPoolMetrics(ThreadPoolExecutor pool) { logger.info(pool size{}, active{}, queued{}, completed{}, taskCount{}, pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size(), pool.getCompletedTaskCount(), pool.getTaskCount()); }一个靠谱的判断标准是持续运行下活跃线程数不应该长期贴近 maximumPoolSize队列积压量应该保持在一个可控水位会自行回落。如果活跃线程数长期封顶说明你的扩展能力已经耗尽要么扩容要么治理任务本身。4.3 为什么不建议直接使用 Executors 的工具方法把 Executors 单独拿出来说是因为它是新手最容易踩的坑之一。Executors.newFixedThreadPool(10) 用的是无界队列 LinkedBlockingQueue隐患是任务无限堆积。Executors.newCachedThreadPool() 用 SynchronousQueue 无界线程数隐患是高并发下线程数暴涨。Executors.newScheduledThreadPool() 用于定时调度场景确实方便但同样要注意异常吞掉的问题。写业务代码的时候工具方法确实省事但生产环境不可控因素太多我的原则是手写 ThreadPoolExecutor 并为每个参数赋予明确含义。这样不管是做容量评估、故障排查还是后续调整参数都有一个清晰的参照系。多写十行配置代码换来的是更好的可维护性这笔账怎么算都不亏。4.4 线程池在使用中的更多细节线程池提交任务有两种方式execute() 和 submit()。execute 没有返回值任务抛出的异常会直接打到线程的异常处理器submit 会返回一个 Future异常被封装在 Future 里你调用 get() 时才会抛出。很多线上日志里看不到错误就是因为用了 submit 之后没有拿 get() 结果异常被静默吞掉了。这是个非常隐蔽的坑。解决方式一个是任务内部自己 try/catch明确记录日志另一个是如果不需要返回值就统一用 execute需要用 submit就一定要对 Future 做异常消费。别在提交处只写一句 pool.submit(...) 就完事异常迟早会咬你一口。ThreadPoolExecutor 还支持钩子方法beforeExecute、afterExecute、terminated。可以在 afterExecute 里统计任务执行耗时自动记录慢任务日志。这个对定位“哪类任务拖垮线程池”很有帮助我维护的系统中就加了一个 afterExecute 的慢任务告警逻辑效果很明显。再说一下多线程池的隔离策略。如果你的应用里有多个不同的业务链路比如一个是订单处理、一个是消息推送、一个是日志上报不要把它们共用一个线程池。一个任务积压会把所有业务拖垮。正确的做法是不同业务用不同的线程池甚至给核心链路的线程池单独设定较高的优先级。这就是所谓的线程池隔离。链路 A 堵死只影响 A不至于整个应用瘫痪。5. 几个真实故障复盘与问题速查5.1 场景一队列无界导致内存暴涨我有个朋友维护过一套文件转码服务用 Executors.newFixedThreadPool(20) 写的每来一个转码任务就 submit 进去。平时任务量不大没出过问题。直到某天业务方搞了一次大批量历史文件补转码一次推了几十万个任务进来。队列无界任务对象全部积压在内存里每个任务还带着原始文件路径和元数据JVM 老年代直接被打满Full GC 频繁到服务失去响应。复盘结论线程池的“缓冲能力”不是无限的无界队列在流量洪峰下就是一颗定时炸弹。解决方案就是把队列改成有界并且设置合理的 maximumPoolSize 和拒绝策略把压力挡在系统能承受的范围之外。5.2 场景二核心线程数配置过大导致资源浪费另一个案例是一个内部管理平台峰值并发只有一二十结果运维照着网上的模板把 corePoolSize 配了 100。100 个常驻线程每个分配 1MB 栈空间保守估计光线程栈就占了 100MB 内存。虽然不至于宕机但白白浪费了很多资源。而且线程池还有锁竞争的成本线程越多上下文切换越明显。复盘结论corePoolSize 的确定要基于业务流量的真实水位而不是按“宁可多配不可少配”的思路来。毕竟这些线程是长期驻留的配多了就是天天交房租却不用房间。5.3 场景三拒绝策略触发后任务悄然消失某数据同步项目用的 DiscardPolicy当时觉得反正是异步同步偶尔丢几条数据也没关系。结果某次上游业务调整同步任务量翻了几倍线程池频繁触发拒绝策略几千条数据被静默丢弃。直到下游报表数据对不上翻代码才发现是拒绝策略吞了任务。复盘结论对“允许丢弃”的判定必须非常谨慎。即使允许丢弃也最好在丢弃策略里打一条告警日志或者做一个计数器上报到监控平台。不要让任务“消失”得无声无息。5.4 常见问题速查表问题现象可能原因处理建议任务一直排队不执行队列过大或核心线程数不足检查队列容量与任务提交速率调整队列或核心线程数线程数持续逼近 maximumPoolSize任务执行慢下游性能瓶颈排查任务内部耗时优化下游调用而不是盲目调大线程数线程池拒绝任务但无日志使用了 DiscardPolicy 或 DiscardOldestPolicy自研拒绝策略加入告警和日志应用停止时卡住不退线程池未调用 shutdown注册 JVM 关闭钩子统一 shutdown任务异常未捕获也没日志使用 submit 后未消费 Future用 execute 或对 Future 做异常处理线程名全是 pool-x-thread-y未自定义 ThreadFactory自定义业务线程名便于定位6. 我对线程池配置的个人经验总结这些年我配过很多线程池从最初的照抄模板到后来能按业务场景自己算参数最大的体会是线程池的参数不是孤立数学题每一个数字背后都应该对应一个业务指标。你问自己三个问题配置就不会跑偏高峰期每秒多少任务单个任务要跑多久系统允许任务排多久队其实最后说个比较容易被忽略的点线程池的配置不是“配完就不管了”。它像一个小型交通枢纽线上流量一变它的行为就要重新评估。所以我习惯在每个迭代版本里顺手看一眼线程池的监控指标重点观察队列水位和活跃线程数养成这个习惯之后很多并发问题都在萌芽阶段就被发现了。如果你想在并发编程上深入一步建议自己动手写几个不同组合参数的小实验观察线程创建时机、任务执行顺序、拒绝行为实测一遍比看十篇文章都管用。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表