04-线程池容量与异步任务

O泡李华 3

线程池为什么越调越慢?队列、拒绝与异步任务设计

接口变慢后,把最大线程数调大,结果响应没有改善,内存和下游压力反而升高。线程池是在有限资源下安排工作,不是把所有排队都变成并行执行。理解任务如何进入队列,才能解释参数为什么没有按预期起作用。

线程池提交路径

1. 先看任务流转,而不是先填参数

线程、队列与拒绝共同形成容量边界。

线程池管理哪些资源?

ThreadPoolExecutor 管理工作线程和等待执行的任务,主要参数包括:

参数 作用
corePoolSize 核心线程数量,默认也可能按需创建
maximumPoolSize 允许的最大线程数量
workQueue 存放等待执行的任务
keepAliveTime 线程空闲后的保留时间,默认主要作用于非核心线程
threadFactory 创建和命名工作线程
handler 无法接收任务时的处理策略

这些参数共同决定行为,不能只讨论一个“合适的线程数”。ThreadPoolExecutor API

一个新任务如何被接收?

在线程池正常运行、配置未动态修改的典型情况下,可以理解为:

线程数少于 corePoolSize
    → 尝试创建工作线程执行任务

否则,队列能够接收
    → 任务进入等待队列

否则,线程数少于 maximumPoolSize
    → 尝试创建更多线程执行任务

再无法接收
    → 执行拒绝策略

源码还会检查运行状态等竞争条件,这个流程用于理解主要策略,不是省略状态检查的生产实现。

若使用近似无界的队列,队列通常不会因为普通负载而立即拒绝入队,最大线程数也就难以发挥预期作用。代价可能转为排队延迟和内存占用。

排队不是免费的缓冲

假设任务到达速度持续超过处理速度,任何有限队列最终都可能装满。增加队列只能延迟暴露问题,不能增加真实处理能力。

排队还会消耗请求的超时预算。例如客户端只愿等待 2 秒,任务已排队 1.9 秒,再调用一个需要 1 秒的下游,执行本身成功也可能错过客户端期限。

大队列还保留着任务引用以及任务捕获的数据,可能增加堆占用和 GC 压力。

队列长度要结合排队时限、任务大小和过载行为设计,而不是默认越大越安全。

2. 用一个有边界的程序观察饱和

示例把线程、队列和拒绝数固定,观察机制而非制造性能排名。

完整示例:两个线程、一个排队任务、一次拒绝

下面使用核心线程 1、最大线程 2、队列容量 1。同步器让执行中的任务暂停,避免任务提前完成干扰观察。复制到 Java 17 的 Main.java 即可运行。

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    static void await(CountDownLatch latch) throws InterruptedException {
        if (!latch.await(5, TimeUnit.SECONDS)) {
            throw new IllegalStateException("wait timeout");
        }
    }

    static Runnable task(int id, CountDownLatch firstStarted,
                         CountDownLatch twoRunning, CountDownLatch release,
                         ConcurrentLinkedQueue<Integer> completed,
                         ConcurrentLinkedQueue<Throwable> failures) {
        return () -> {
            firstStarted.countDown();
            twoRunning.countDown();
            try {
                await(release);
                completed.add(id);
            } catch (InterruptedException exception) {
                Thread.currentThread().interrupt();
                failures.add(exception);
            } catch (RuntimeException exception) {
                failures.add(exception);
            }
        };
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicInteger threadId = new AtomicInteger();
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                1, 2, 30, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                action -> new Thread(action, "demo-worker-" + threadId.incrementAndGet()),
                new ThreadPoolExecutor.AbortPolicy());

        CountDownLatch firstStarted = new CountDownLatch(1);
        CountDownLatch twoRunning = new CountDownLatch(2);
        CountDownLatch release = new CountDownLatch(1);
        ConcurrentLinkedQueue<Integer> completed = new ConcurrentLinkedQueue<>();
        ConcurrentLinkedQueue<Throwable> failures = new ConcurrentLinkedQueue<>();

        try {
            executor.execute(task(1, firstStarted, twoRunning, release, completed, failures));
            await(firstStarted);
            executor.execute(task(2, firstStarted, twoRunning, release, completed, failures));
            executor.execute(task(3, firstStarted, twoRunning, release, completed, failures));
            await(twoRunning);

            System.out.println("workers = " + executor.getPoolSize());
            System.out.println("queued = " + executor.getQueue().size());

            try {
                executor.execute(task(4, firstStarted, twoRunning, release, completed, failures));
            } catch (RejectedExecutionException exception) {
                System.out.println("task 4 = rejected");
            }
        } finally {
            release.countDown();
            executor.shutdown();
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                executor.shutdownNow();
                throw new IllegalStateException("shutdown timeout");
            }
        }

        if (!failures.isEmpty()) {
            throw new IllegalStateException("task failed", failures.peek());
        }
        List<Integer> result = new ArrayList<>(completed);
        Collections.sort(result);
        System.out.println("completed tasks = " + result);
    }
}

运行输出:

workers = 2
queued = 1
task 4 = rejected
completed tasks = [1, 2, 3]

任务 1 使用核心线程,任务 2 入队,任务 3 因队列已满而创建第二个线程。任务 4 无法入队,也无法继续增加线程,因此被拒绝。

完成列表经过排序,只表示这三个任务完成了,不表示执行顺序是 1、2、3。实际上任务 3 可以在任务 2 之前开始。

拒绝策略是业务语义的一部分

策略 行为 需要考虑的问题
AbortPolicy 抛 RejectedExecutionException 调用方如何响应或补偿
CallerRunsPolicy 正常运行状态下由提交者执行 可能阻塞请求线程或事件线程
DiscardPolicy 丢弃当前任务 是否允许无声丢失
DiscardOldestPolicy 尝试移除队首任务再重新提交 老任务被丢弃后的状态如何维护

线程池关闭时也会发生拒绝,不能把拒绝一律解释成流量太大。

CallerRunsPolicy 可以形成提交侧的减速,但不保证适合所有线程模型;在线程池已经关闭时,它也不会执行任务。

如果使用 submit 获得 Future,拒绝处理器又静默丢弃任务,Future 可能长期不完成。因此“没有抛异常”不等于任务最终会执行。

3. 聚合任务还有完成与取消语义

并发调用需要明确依赖、执行器、异常及底层取消。

它表示结果,也表示结果之间的关系

Future 通常用于等待任务结果。CompletableFuture 还可以表达“结果完成以后继续转换”“两个结果都完成以后合并”“根据前一个结果启动下一阶段”。

用户资料 ─┐
          ├── thenCombine → 页面结果
订单数量 ─┘

创建一个 CompletableFuture 不等于任务已经在某个新线程执行。任务由哪个线程运行,需要看具体构造和组合方法。CompletableFuture API

thenApply、thenCompose 与 thenCombine

方法 关系 例子
thenApply 对一个完成值做普通转换 User 转 DTO
thenCompose 后续操作也返回完成阶段,展平嵌套关系 得到用户后异步查询详情
thenCombine 合并两个独立阶段 资料与订单数量生成页面

如果用 thenApply 返回另一个 Future,就得到嵌套结构;thenCompose 用于表达这种异步依赖。

allOf 返回一个完成信号,不会自动返回所有阶段的结果列表。它也不是所有任务失败情况的完整报告机制,需要按业务收集各阶段结果和异常。

Async 后缀不等于独立业务线程池

非 Async 的后续动作可能在完成前一阶段的线程或相关调用线程执行,不能保证一定是调用者线程。

未指定执行器的 Async 方法通常使用公共执行资源;具体规则以 API 为准。需要隔离外部调用时,显式传入配置好的执行器,避免把慢任务和其他公共工作混在一起。

此外,阻塞的 HTTP 调用放进 supplyAsync,只是把阻塞转移到另一个线程,并没有把该 HTTP 客户端自动变成非阻塞客户端。

超时不会自动停止底层任务

get 的超时仅限制当前等待;orTimeout 则可以让该 CompletableFuture 在期限后异常完成,completeOnTimeout 提供默认完成值。后两者作用于原 CompletableFuture,不是自动创建完全独立的副本。

这些动作不保证正在执行的网络请求已经结束。CompletableFuture 的 cancel 也不能被当成对底层计算的通用中断保证。

因此要设置 HTTP 客户端自身的连接与读取等超时,并把总请求期限分配到排队、执行和聚合阶段。某个阶段超时后,是否还需要等待、取消或忽略其他结果,应显式决定。

避免在线程池里等待同一个池的任务

如果一个单线程执行器上的任务提交另一个任务到同一执行器,然后阻塞等待它,后者无法获得执行机会,可能形成线程饥饿。

复杂依赖优先用完成阶段关系表达,而不是在每个异步任务里调用 join。阻塞等待并非总是错误,但必须检查线程数量、依赖关系和等待边界。

日志、用户身份等 ThreadLocal 上下文也不会因为用了 CompletableFuture 就自动正确传播,应显式设计传递与清理。

4. 把容量设计落到业务入口

接收能力与下游承载能力要一起限制。

参数如何从业务需求推导?

先测量正常与故障负载下的任务耗时,再检查任务主要消耗 CPU,还是等待网络、磁盘、数据库。

需要同步考虑下游限制。数据库连接池只有有限连接时,盲目增加工作线程,可能只是增加等待连接的线程,而不是提高数据库吞吐。

可以从以下问题开始:

  • 任务到达率与完成率各是多少?
  • 执行耗时和排队耗时的分布如何?
  • 下游可以承受多少并发,连接池容量是多少?
  • 哪些任务必须完成,哪些可以直接拒绝?
  • 请求取消后,任务是否仍值得执行?
  • 不同业务是否需要独立执行器,防止互相拖慢?

然后在预期负载和下游变慢的场景下验证线程数、队列容量、超时与拒绝。单一经验公式只能作为起点,不能代替这些约束。

超时、异常与关闭要一起设计

Future.get 超时只表示调用方不再等待,不会自动停止正在执行的任务。cancel(true) 或 shutdownNow 通常依靠中断请求,任务仍需配合,中断也不一定能取消所有外部调用。

submit 会把任务异常保存进 Future;只提交却不观察完成结果,异常就可能无人处理。execute 的异常传播行为不同,应建立明确的任务错误记录机制。ExecutorService API

正常关闭可先 shutdown,等待已接收任务结束;超过时限再尝试 shutdownNow,并检查未开始的任务和业务状态。

运行中要观察排队时间、执行时间、队列长度、拒绝次数和下游延迟。线程池的线程数看起来稳定,不代表服务运行健康。

5. 设计判断与适用边界

参数调整前同时观察排队时间、执行时间、拒绝数和下游饱和度。如果工作线程主要等待一个已经拥堵的数据库,增加线程往往增加连接争用。队列越大也不一定更好:超过用户等待预算的任务继续积压,消耗资源却无法提供有效响应。

在线请求与后台任务可以采用不同容量和拒绝策略。重要后台工作先持久化再异步处理;在线工作在入口明确失败或限流。不同故障域尽量隔离执行器,避免慢模型请求占满普通业务线程。

美团的线程池实践文章提供了从执行机制到业务容量治理的阅读路径。本文配置仍以 JDK 17 与具体任务约束为准。 美团:Java 线程池实现原理及其实践。

6. 问答

问:为什么设了很大的 maximumPoolSize,却没有更多线程?

可能任务一直成功入队,尚未进入需要额外线程的分支。应同时检查队列类型与容量。

问:队列越大越好吗?

不是。它增加排队和内存成本,不能解决持续处理能力不足。

问:thenApply 与 thenCompose 的区别是什么?

前者做普通值转换,后者连接返回异步阶段的操作,避免嵌套 Future。

问:allOf 会返回所有结果吗?

不会,它返回完成信号;需要自己读取和组织各阶段结果。

参考资料