在后台开发这块,线程池几乎是绕不开的一个基础组件。不管是Java的ThreadPoolExecutor还是C++手写的线程池,核心思路都是同一套:提前创建一批线程,把任务丢进队列,让这批线程循环取任务执行。这么做的好处非常直接,第一是省掉了频繁创建和销毁线程的开销,第二是能限制并发数量,避免系统被突发流量打垮。
但很多人在实际用的时候,往往只停留在“new一个ThreadPoolExecutor然后execute提交任务”这个层面。真到了线上,线程池怎么配置、队列选哪种、拒绝策略怎么定、参数调优怎么下手,处处是坑。我见过太多因为线程池配置不合理导致的线上事故,有队列无限堆积把内存撑爆的,也有核心线程数设置过大直接把CPU打满的。这篇博文就把线程池从原理到实践完整拆一遍,结合Java和C++两个方向,把阻塞队列的选择、参数调优思路、踩坑经验都讲清楚,希望能帮你少走些弯路。
适用范围比较广:刚接触并发编程的初学者可以通过这篇把线程池的原理彻底搞懂,有一定经验的开发者可以对照检查自己的配置是否合理,需要用C++实现线程池的朋友也能找到可落地的设计参考。整个内容按“设计思路 -> 核心参数 -> 阻塞队列选型 -> C++实现要点 -> 问题排查”这条线展开。
1. 线程池的核心设计思路
1.1 为什么要用线程池:省的不只是创建线程的开销
很多人对线程池的理解停留在“创建线程太贵了所以要复用”,这个说法对,但不完整。创建一条线程确实有开销,涉及操作系统分配内核资源、初始化栈空间(默认1MB左右的虚拟内存)、建立线程控制块等,这些操作在高频场景下累积起来非常可观。但线程池真正解决的核心问题,是资源的可控性。
举个例子,假设你的服务里有一个接口需要调用外部RPC,单次调用耗时50ms。如果不用线程池,而是每个请求都new一个线程去处理,在每秒1000次请求的流量下,系统并发线程数会瞬间飙升到50条甚至更多。线程一多,CPU就要花大量时间在上下文切换上,真正执行任务的时间被严重压缩,系统吞吐量反而下降。更危险的是,如果下游服务出现故障导致响应变慢,线程会越积越多,最终把内存耗尽,整个进程挂掉。
线程池的本质,是对“线程数量”这个稀缺资源做统一管理。它维护一个线程集合和一个任务队列,线程从队列里取任务执行,执行完再取下一个。这样一来,同一时刻最多只有N个线程在运行,无论流量怎么波动,系统资源消耗都是可控的。这个思想其实和数据库连接池、HTTP连接池一脉相承:池化的本质是复用有限资源,隔离外部波动。
1.2 线程池的整体架构和运行流程
无论是Java的ThreadPoolExecutor还是C++里自研的线程池,架构上都遵循同一个模型,拆开来看就是三个要素:线程集合、任务队列、调度策略。
- 线程集合:一组已经创建好、处于等待状态的线程,它们阻塞在队列的take操作上,等待任务到来。
- 任务队列:存放提交但尚未执行的任务,起到缓冲削峰的作用。
- 调度策略:决定何时创建新线程、何时把任务放入队列、任务满了之后怎么办。
完整的任务处理流程,可以简化成这么几步。提交一个任务时,线程池先判断当前运行的线程数是否小于核心线程数,如果小于就直接创建新线程执行任务;如果已经达到核心线程数,就把任务放进队列等待;如果队列也满了,再判断是否还能创建非核心线程,能就创建临时线程执行;如果线程数已经到了最大上限,那就只能走拒绝策略了。
这个流程要牢记,因为很多参数调优的问题,本质上都是在问“现在处于这个流程的哪一步”。
2. 线程池核心参数详解与配置逻辑
2.1 七个参数各管什么事
以Java的ThreadPoolExecutor为例,构造函数一共有七个参数:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、空闲存活时间(keepAliveTime)、时间单位(unit)、阻塞队列(workQueue)、线程工厂(threadFactory)、拒绝策略(handler)。
很多人对这七个参数的理解是割裂的,记了后面忘了前面。我的建议是抓主要矛盾,核心线程数、最大线程数、阻塞队列、拒绝策略这四个是必须吃透的,剩下三个相对次要。
- 核心线程数:线程池的“常驻员工数”,即使空闲也不会被回收。提交任务时,只要还没达到这个数,就优先创建新线程。
- 最大线程数:线程池能容纳的“极限员工数”。当队列满了之后,允许额外创建的线程数上限。
- 空闲存活时间:非核心线程空闲超过这个时间就会被回收,直到线程数回到核心线程数。
- 阻塞队列:核心线程忙不过来时,任务排队等候的地方。
- 线程工厂:用于创建线程,可以在这里统一设置线程名、是否守护线程、优先级等。
- 拒绝策略:队列满了且线程数到上限时,新任务的处理方式。
- 时间单位:存活时间的单位,这个没什么好说的。
2.2 核心线程数和最大线程数该怎么定
线程池参数配置网上有各种各样的“标准答案”,比如CPU密集型的设置为核心线程数+1,IO密集型的设置为核心线程数的两倍。这些说法有一定参考价值,但实际工程里远没那么简单。
先理解这两个数的含义。核心线程数决定了系统在常态流量下能并行处理多少任务,最大线程数决定了系统在突发流量下能扛到的极限。当任务量超过核心线程处理能力时,任务会先进入队列缓冲;当队列也放不下时,才会动用最大线程数里的“临时工”。
关于核心线程数的计算,有一个比较好用的估算方式。假设任务平均耗时是T,期望的QPS是Q,那么需要的并发线程数大约是 T × Q 除以1000(如果T的单位是毫秒)。打个比方,一个任务平均执行200ms,你期望每秒处理500个任务,那么需要的并发数就是200 × 500 / 1000 = 100个线程。这个估算值可以作为核心线程数的出发点,然后再结合压测结果进行调整。
最大线程数的设置则要看你对突发流量的容忍度。设得太大,突发流量虽然能扛住,但可能会导致线程过多、上下文切换加剧,反而拖垮系统;设得太小,流量洪峰一来就会触发拒绝策略,直接丢掉任务。一般做法是在核心线程数基础上,预留30%到50%的余量作为最大线程数。
2.3 keepAliveTime和allowCoreThreadTimeOut的用法
keepAliveTime的作用范围是非核心线程。当线程池中的线程数超过核心线程数时,那些多出来的线程空闲时间达到keepAliveTime就会被回收,直到线程数回到核心线程数。
这里有一个容易被忽略的点:核心线程默认是不会被回收的。如果你的系统有明显的忙闲周期,比如白天流量大、夜里流量小,那夜里这批核心线程会一直空转,白白占用内存资源。针对这种情况,Java的ThreadPoolExecutor提供了一个allowCoreThreadTimeOut方法,设置为true后,核心线程空闲超过keepAliveTime也会被回收。不过在C++里实现时,要自己加这个逻辑,核心线程和非核心线程的回收策略建议做成可配置的。
一个值得注意的细节是:当allowCoreThreadTimeOut为true时,线程池的核心线程数实际上会退化为0。此时如果同时设置了线程工厂的守护线程属性,会导致线程池在没有任务时一个线程都不留。这就需要谨慎处理,否则任务提交时反而要额外等线程创建的时间。
3. 线程池的阻塞队列选择:一切配置的核心
3.1 几种常见队列的结构差异
阻塞队列的选择直接决定了线程池的“缓冲性格”。Java里最常见的三种队列,内部数据结构各不相同,适用场景也完全不同。
- ArrayBlockingQueue:基于数组实现的有界队列,创建时必须指定容量,底层用一把锁和两个条件变量(notEmpty、notFull)管理线程的阻塞和唤醒。它和SynchronousQueue的区别在于,它允许任务在队列里排队等待,而不是直接交给线程。它的计数器是AtomicInteger类型,保证线程安全。
- LinkedBlockingQueue:基于链表实现的有界队列,如果不指定容量则默认是Integer.MAX_VALUE,也就是“无界”。内部使用两把锁(put锁和take锁)来分离读写,吞吐量通常比ArrayBlockingQueue高。但无界队列有一个致命问题:队列可以无限增长,直到内存耗尽。即便设置了容量,也需要正确使用才能避免OOM。
- SynchronousQueue:一个特殊的队列,它内部不存储任何元素。每个put操作必须等待一个take操作与之配对,所以它本质上是一个“直接交接”的模式。这个队列的容量是0,意味着任务不会被缓冲,要么立即交给线程执行,要么就触发新的线程创建。
3.2 队列选择对线程池行为的决定性影响
选择不同的队列,不是说“用哪种队列更高级”,而是决定了线程池在不同负载下的行为路径。
使用无界队列(比如默认的LinkedBlockingQueue),当核心线程数都在忙时,新任务会不断堆积在队列里,队列永远不会满,因此永远不会创建非核心线程,最大线程数这个参数在这种配置下完全不起作用。这种方式对突发流量的缓冲能力强,任务不会被拒绝,但代价是任务延迟会越来越大,队列堆积过多时内存也可能被压垮。
使用有界队列(比如ArrayBlockingQueue或者指定容量的LinkedBlockingQueue),当队列满时,线程池才会尝试创建非核心线程。这个模型更接近生产环境的真实需求:有界队列相当于一个“缓冲池”,最大线程数相当于“备用产能”,两者配合才能应对各种复杂的负载情况。
使用SynchronousQueue,任务提交和执行是同步的,要求每次提交都必须有一个线程能立即接收。这种方式的好处是响应延迟极低,不会有排队等待的时间损耗,坏处是对线程数量的要求很高,容易频繁创建和销毁线程。
我个人的实践经验是:如果没有特殊理由,优先使用有界队列。无界队列看似省心,实际上把风险转移给了内存,线上出问题的概率反而更高。
3.3 C++线程池中的队列选择:从互斥锁到无锁队列
C++实现线程池时,队列选择同样关键。最简单的方式就是用一个std::queue配合std::mutex和std::condition_variable,这个方案在大多数场景下已经够用,而且代码简洁,不容易出错。
但随着队列长度增长,互斥锁的竞争会成为性能瓶颈。当多个生产者线程同时往队列里丢任务、多个消费者线程同时从队列里取任务时,锁的竞争会加剧,导致线程频繁阻塞在锁上。此时可以考虑无锁队列(比如基于CAS的循环数组实现),吞吐量会有明显提升。
无锁队列的引入需要权衡:代码复杂度大幅上升,需要处理ABA问题、内存序问题、队满队空判断等边界情况,调试难度也比较高。我的建议是,先压测,确认互斥锁确实是瓶颈之后再做无锁化改造,不要一上来就追求无锁,否则容易在细节上翻车。
队列的容量设置也同样重要。在C++中如果队列是无界的,任务堆积就会导致内存无限膨胀。即使是有界队列,容量设置也需要结合任务平均大小和峰值流量来估算:容量过低会导致线程频繁创建销毁,容量过高则会增加内存消耗和任务延迟。一般建议默认容量为核心线程数的两倍到四倍,再根据实际情况调整。
4. 线程池配置实战:从参数计算到场景化调优
4.1 用公式估算初始参数
前面提到了一个估算公式:核心线程数约等于任务耗时(毫秒)× 目标QPS / 1000。这里把完整计算过程走一遍,方便直接套用。
假设有一个订单处理任务,平均耗时300ms,目标吞吐量是每秒200个订单。核心线程数 = 300 × 200 / 1000 = 60。如果任务耗时波动大,比如有的任务只要10ms,有的要500ms,建议用P99耗时来估算,否则线程数可能不够。
再假设同一时间最多会有500个订单同时涌过来,而系统最多能承受150个线程并行。那么可以设置最大线程数为150,队列容量为核心线程数 × 2 = 120左右。这个容量可以从200开始调整,具体压测后再确定。这样配置的逻辑是:常态下60个线程处理常规流量,队列缓冲一定量的排队任务;突发流量超过60个线程的处理能力时,先让任务排队,队列满了之后,线程池逐步扩容到150个线程,直到系统极限。
4.2 两种典型业务场景的配置参考
举两个具体的场景,配置思路完全不同。
场景一:CPU密集型任务(比如图片压缩、数据解析)
这种任务的特点是:线程一旦运行,基本都在消耗CPU,几乎不会等待。如果线程数超过CPU核心数,多余的线程只会增加上下文切换开销。配置策略非常保守:核心线程数约为CPU核数+1(也可以直接用CPU核数),最大线程数为CPU核数的2倍以内,队列用一个有界队列,容量不需要太大,200左右足够。
需要注意,线程数的上限不能设置得过高。在CPU密集场景下,任务本身不阻塞太久,线程多反而不划算。最大线程数设为CPU核数的两倍,除非系统有多个CPU,否则溢出线程反而会拖慢整体执行速度。
场景二:IO密集型任务(比如调用RPC、读写数据库)
这种任务的共同特点是:线程大量时间在等待网络响应或磁盘IO,CPU利用率低。在这种情况下,可以创建更多的线程来提高吞吐量,因为线程在等待IO时CPU可以干别的事。配置策略激进一些:核心线程数通常设置为CPU核数的4到8倍,最大线程数甚至可以达到CPU核数的16倍左右。队列需要更大的缓冲空间,建议容量设在1000到2000之间,防止瞬时流量冲击导致大量任务被拒绝。
这里的“4到8倍”是一个经验值,不是精确公式。更严谨的做法是结合压测数据来调整:先设置一个初始值,然后记录系统在目标负载下的CPU利用率、线程等待时间、队列长度等指标,再逐步调整。目标是在系统稳定运行的前提下,让CPU利用率保持在合理区间。
4.3 队列容量如何影响线程池的响应速度
很多人只关注核心线程数、最大线程数和拒绝策略,忽略了队列容量对响应速度的直接影响。队列容量设置得过大,任务排队时间就会拉长;设置得过小,则可能导致线程频繁被创建和回收,增加系统开销。
不妨用一个类比:核心线程数是正式的办事窗口,最大线程数是临时加开的窗口,队列就是大厅里的等候区。等你前面有50个人在排队时,你即使走了VIP通道(即被分到临时窗口),也要忍受前面人的办理时间。队列越长,响应越慢。但队列也不能没有,如果完全没有缓冲,一旦临时窗口全部忙完,新的任务就得直接走拒绝策略,什么也办不成。
所以队列容量的设置原则是:让队列在常态流量下基本为空,在峰值流量下能起到削峰作用,不让队列长时间深度堆积。具体数值需要通过压测找到平衡点:如果压测中发现队列经常处于非空状态且平均排队时间超过任务耗时的两三倍,就说明队列容量偏大了,或者线程数偏小了。
5. C++线程池实现要点与关键代码解析
5.1 最小可用的线程池骨架
C++里实现一个线程池,核心代码并不多。最基础的版本就是“一个队列 + 一组线程 + 一个条件变量”,取任务时线程阻塞在条件变量上,提交任务时唤醒一个线程。
class ThreadPool { public: ThreadPool(size_t threads) : stop(false) { for (size_t i = 0; i < threads; ++i) workers.emplace_back([this] { for (;;) { std::function<void()> task; { std::unique_lock<std::mutex> lock(queue_mutex); condition.wait(lock, [this] { return stop || !tasks.empty(); }); if (stop && tasks.empty()) return; task = std::move(tasks.front()); tasks.pop(); } task(); } }); } template<class F> void enqueue(F&& f) { { std::unique_lock<std::mutex> lock(queue_mutex); if (stop) throw std::runtime_error("enqueue on stopped ThreadPool"); tasks.emplace(std::forward<F>(f)); } condition.notify_one(); } ~ThreadPool() { { std::unique_lock<std::mutex> lock(queue_mutex); stop = true; } condition.notify_all(); for (std::thread& worker : workers) worker.join(); } private: std::vector<std::thread> workers; std::queue<std::function<void()>> tasks; std::mutex queue_mutex; std::condition_variable condition; bool stop; };这里有几个要注意的点。析构函数里必须先置stop为true再notify_all,否则如果先唤醒线程再设置stop,可能会出现线程醒来后发现队列为空且stop为false,又回去睡觉了,导致无法退出。wait的条件必须同时判断stop和tasks.empty(),这能避免两种假唤醒:一种是条件变量被唤醒但队列没有任务,另一种是停止时队列中还有残存任务。
这段代码能跑,但在生产环境用还差点意思。缺少动态调整线程数量的能力,也缺少线程异常安全处理。如果任务执行过程中抛出异常,这个线程会直接退出,线程池里就少了一个干活的人。更健壮的做法是在任务执行外层加try-catch,或者把任务本身包一层try-catch。
5.2 让队列有界:替换为带容量限制的阻塞队列
最基本的实现用的是无界队列std::queue,这在C++里和Java默认的无界LinkedBlockingQueue有同样的问题:任务堆积会导致内存膨胀。要给队列加容量限制,可以自封装一个简单的阻塞队列。
template<typename T> class BoundedBlockingQueue { public: explicit BoundedBlockingQueue(size_t capacity) : capacity_(capacity) {} bool push(const T& item) { std::unique_lock<std::mutex> lock(mutex_); not_full_.wait(lock, [this] { return queue_.size() < capacity_ || stop_; }); if (stop_) return false; queue_.push(item); not_empty_.notify_one(); return true; } bool pop(T& item) { std::unique_lock<std::mutex> lock(mutex_); not_empty_.wait(lock, [this] { return !queue_.empty() || stop_; }); if (stop_ && queue_.empty()) return false; item = std::move(queue_.front()); queue_.pop(); not_full_.notify_one(); return true; } void stop() { { std::unique_lock<std::mutex> lock(mutex_); stop_ = true; } not_empty_.notify_all(); not_full_.notify_all(); } private: std::queue<T> queue_; std::mutex mutex_; std::condition_variable not_empty_; std::condition_variable not_full_; size_t capacity_; bool stop_ = false; };push操作在队列已满时会阻塞而不是丢弃任务,这一点和Java线程池的abort策略不同。实际使用中,具体的行为要根据业务场景来定:有的场景希望满了之后立即返回失败,让上层决定怎么处理;有的场景宁可多等一会也不能丢任务。两种策略没有绝对优劣,关键在于想清楚业务对任务可靠性的要求。
队列的容量也不能拍脑袋定。在使用有界队列时,如果生产者提交任务的速度长期大于消费者的处理速度,队列很快就会被填满,然后消费者会在push操作上阻塞,相当于把队列的压力反向传导给生产者。这时如果要保证任务不丢失,就需要生产者也做背压控制,而不是无脑往线程池里丢任务。
5.3 动态调整线程数:把Java的两个参数搬进C++
前面提到了核心线程数和最大线程数,C++基础线程池实现里往往没有这两个概念。如果需要动态扩容能力,必须自己实现。思路是这样的:用一个atomic计数记录当前线程数,用一个核心线程数阈值和一个最大线程数阈值,提交任务时先判断是否需要创建新线程。判断逻辑和Java一样:当前线程数小于核心线程数则创建新线程;否则尝试放队列;队列满了且线程数小于最大线程数则创建新线程。
实现时要注意一个细节:负责创建线程的生产者线程可能被阻塞在push操作上,此时线程池的线程数并没有减少,但等待push的生产者线程也占用了系统资源。这种情况下,扩容逻辑必须考虑线程池内线程和阻塞在队列push上的生产者线程之间的关系——队列满时创建新线程能帮助消费队列,但创建新线程本身也有成本,线程创建后需要一段时间才能开始处理任务,瞬时峰值时这种延迟可能造成任务堆积。
为了避免频繁创建销毁线程,建议引入空闲线程回收机制:一个线程从队列中取不到任务,如果当前线程数大于核心线程数,可以等待一段时间后主动退出。实现方式是在pop操作中传入一个超时时间,超时仍未取到任务且线程数超限,就让这个线程退出。这里要注意线程数统计的原子性,避免多个线程同时决定退出,导致线程数低于预期的核心线程数。
6. 线程池配置全链路拆解:拒绝策略与四种行为细节
6.1 四种拒绝策略分别适合什么场景
当有界队列已满且线程数已经达到最大上限时,线程池必须对新提交的任务做出决定。Java内置了四种拒绝策略:
- AbortPolicy(默认):直接抛出RejectedExecutionException,终止任务提交流程。适合对任务丢失零容忍、且上层有明确异常处理逻辑的场景。
- CallerRunsPolicy:不抛异常,而是让提交任务的线程自己执行这个任务。这是一种天然的背压机制:提交线程忙着执行任务,就没法继续提交新任务了,系统会自动降速。
- DiscardPolicy:静默丢弃任务,不抛异常。适合能容忍任务偶尔丢失、追求极高吞吐的场景。
- DiscardOldestPolicy:丢弃队列里最老的未执行任务,然后重新尝试提交新任务。适合对“新任务比旧任务更重要”的场景。
四种策略的选择,本质上是回答一个问题:当系统满负荷时,你希望新任务怎么处理?抛异常会中断调用方的流程,但也可能是最明确的通知;调用方自己跑是最温和的降级策略;静默丢弃和丢弃最老任务都适合非核心业务场景,比如日志上报、埋点统计这类丢了也无所谓的数据。
踩过坑后才明白:不要一上来就选AbortPolicy,因为默认策略会直接抛异常,如果你的代码里没有捕获处理,一条线程池满的异常可能直接打爆日志,甚至影响主流程逻辑。我一般建议,能接受延迟就让任务回到调用方线程执行(CallerRunsPolicy),不能容忍任务丢失就选有界队列加CallerRunsPolicy的组合。
6.2 拒绝策略在C++中的对应实现
C++线程池标准库没有现成的拒绝策略,需要自己在push逻辑里实现。一种简单的做法是在push返回bool值,调用方根据返回值决定怎么处理。还可以在构造线程池时传入一个拒绝策略回调,由调用方灵活控制。
enum class RejectPolicy { Abort, // 抛出异常 Discard, // 静默丢弃 DiscardOldest, // 丢弃最老任务 CallerRuns // 调用方自己执行 };具体实现时,DiscardOldest需要从队列头部取出最老的任务并丢弃,这要求队列底层支持从头部pop,普通的std::queue可以做到。需要注意,丢弃最老任务时不应该唤醒等待中的线程,因为丢弃的是任务而不是通知线程。
CallerRuns策略在C++里也容易实现,push返回false后调用方直接在提交线程执行函数即可。但这里有个隐藏问题:如果调用方线程本身也是线程池中的线程,就会发生嵌套执行,执行顺序可能错乱,严重情况下可能造成大量线程都去执行被退回的任务,系统负载反而飙升。所以C++实现CallerRuns策略时,要判断调用方是不是线程池内部线程,最好避免嵌套执行。
7. 常见问题与排查技巧实录
7.1 任务堆积内存飙升,怎么定位
一个典型的线上故障:接口响应越来越慢,内存持续增长,直到触发了告警。排查思路可以这样展开。
先查线程池当前状态是线程数还是队列长度异常。可以获得线程池的当前线程数、活跃线程数、队列长度等指标,把这些指标接入监控,然后查看堆积发生在哪个环节。常见的根因有几种:
- 任务执行耗时变长(比如下游依赖变慢),导致消费速度下降,任务在队列里越积越多。
- 核心线程数配置过小,处理能力不足。
- 队列容量设置过大,允许堆积太多任务,内存被任务对象占满。
定位到具体原因后,对应的解法也不同。如果是下游变慢,核心是要做超时和熔断,而不是单纯调大线程池参数。如果核心线程数过小,可以逐步调大,并配合压测验证。如果是队列容量过大,要改成有界队列,配合拒绝策略保护系统。
7.2 线程池线程数一直上升,回收不了
有时会遇到这种情况:明明配置了keepAliveTime和最大线程数,但线程数一直维持在峰值水平,回收不下去。比较常见的原因是任务提交的速度始终大于线程池的处理能力,导致非核心线程始终有事可做,根本没有“空闲”机会。另一种情况是allowCoreThreadTimeOut没有开启,核心线程本身就不会被回收。
还有一种容易被忽略的情况:任务提交方使用了SynchronousQueue,任务提交后必须等待线程接收。当任务量很大时,线程池会频繁创建新线程来满足提交需求,每次创建线程后,如果任务执行得很快,线程就又空闲了,然后被回收。这种频繁创建销毁的现象对性能影响不小,而线程数指标看起反而比较稳定。
排查这类问题,要同时看线程数、任务提交速率、任务执行耗时的趋势图,而不是单看某一个指标。任务提交速度持续高于处理能力,线程数是降不下来的,此时要解决的是任务提交侧的背压问题,或者调大线程池的处理能力。
7.3 线程池配置实战排查速查表
| 现象 | 可能的根因 | 定位思路 | 处理建议 |
|---|---|---|---|
| 任务大量堆积,响应变慢 | 线程数不足或队列过短 | 查看队列长度和线程活跃数 | 逐步提升核心线程数,压测验证增速与耗时 |
| 内存飙升,触发OOM | 使用无界队列或队列容量过大 | 查看队列堆积的任务数量 | 回调为有界队列,配置合理的拒绝策略 |
| 线程一直在创建和销毁 | 任务耗时极短且提交频率高 | 观察线程创建和销毁次数,结合时间维度检查 | 加入空闲存活时间,或改用SynchronousQueue |
| 线程池达到上限后新任务被拒 | 队列容量过小或最大线程数不够 | 检查拒绝策略被触发的次数 | 依据业务调整队列容量,或改用CallerRunsPolicy背压 |
| CPU占用很高但吞吐量上不去 | 上下文切换过于频繁 | 查看活跃线程数和CPU时间片分布 | 降低线程数,优化任务执行效率与锁粒度 |
| 任务提交后延迟但无异常 | 队列排队时间过长 | 查看任务在队列中平均停留时间 | 增加线程数或压缩队列容量,避免长时间排队 |
| 核心线程数不断增长 | 非核心线程未设置回收机制 | 检查allowCoreThreadTimeOut设置 | 按需开启核心线程回收 |
7.4 线程池参数调优的五个经验心得
第一,线程池参数不好一套配置通用所有业务。CPU密集型、IO密集型、混合型任务的配置几乎完全不同,即使同样是IO密集型,对上游RPC和下游数据库的配置也会有很大差别。先把自己的任务类型分清楚,再参考经验公式去定初始值。
第二,依赖监控数据来调整参数,不是拍脑袋。线上环境一定要把线程池的核心指标暴露出来,包括当前线程数、活跃线程数、队列长度、拒绝次数、任务执行耗时分布。没有监控的线程池配置优化基本等于盲人摸象。
第三,重试逻辑不要直接提交到同一个线程池。如果业务代码里对失败任务做了重试,而重试消息又提交到同一个线程池,当线程池满时就会造成重试消息堆积和线程数飙升的死循环。这种情况下,最好给重试任务单独划一个线程池,并配上独立的队列和拒绝策略。
第四,线程池隔离要尽早落地。一个业务出现故障时,如果多个业务共用一个线程池,很容易导致故障扩散。比较典型的做法是:核心链路和非核心链路用不同的线程池,下单和发短信互不影响。线程池隔离的代价是增加一定的维护成本,但换来的稳定性是值得的。
第五,动态更新线程池参数,尽量做成可配置化。ThreadPoolExecutor自带setCorePoolSize和setMaximumPoolSize方法,可以在不重启进程的情况下调整参数。线上流量变化时,这点设计能省下不少时间。
我个人在实际操作中,踩过最深的一个坑是:给一个任务量很大的日志处理系统配了无界队列,当时想着日志丢不得,结果高峰期内存直接被打到90%以上,不得不紧急重启。后来换成了有界队列加CallerRunsPolicy,日志秒级抖动的时候,提交线程自己扛一下,反而把整个系统稳住了。线程池没有万能的配置,只有对自己的业务有了清晰的认知,才能配出稳定又高效的那一套。