线程池大小设置

候选人小钱在面试字节 P7 时,面试官问道:

"线程池的线程数应该怎么设置?有什么公式吗?"

小钱说:"可以设为 CPU 核心数..."面试官追问:"CPU 密集型和 I/O 密集型的配置有什么区别?"

小钱答不上来。面试官继续:"如果线程等待 I/O 的时间是计算时间的 3 倍,线程数应该是多少?"

小钱彻底卡住了...

一、核心问题:线程池大小设置 🔴

1.1 问题拆解

第一层:基本公式(怎么算?)
  "线程池大小的计算公式是什么?"
  考察点:CPU 核心数、I/O 等待时间、期望 CPU 利用率

第二层:CPU vs I/O(怎么分?)
  "CPU 密集型和 I/O 密集型应该怎么配置?"
  考察点:任务特性、阻塞时间、线程数差异

第三层:生产配置(怎么选?)
  "生产环境中线程池大小的经验值是什么?"
  考察点:分业务配置、监控调优

1.2 ❌ 错误示范

候选人原话 A:"线程数设为核心数加 1,比如 8 核设 9。"

问题诊断:这个公式来自《Java Concurrency in Practice》,适用于 CPU 密集型任务。但 I/O 密集型任务需要更多的线程。

候选人原话 B:"线程池越大越好,并发度越高。"

问题诊断:线程数过多会导致:

  1. 上下文切换开销增加
  2. 内存消耗增加
  3. 竞争加剧(锁、缓存等)

1.3 标准回答

P5 级别:基本公式

核心公式

线程数 = CPU 核心数 × 期望 CPU 利用率 × (1 + I/O 等待时间 / 计算时间)

简化版本:

  • CPU 密集型线程数 = CPU 核心数 + 1
  • I/O 密集型线程数 = CPU 核心数 × 2线程数 = CPU 核心数 × (1 + I/O 等待时间 / 计算时间)

为什么 CPU 密集型要加 1?

因为 CPU 密集型任务在等待 CPU 时间片时,可能被 OS 调度切换出去。加 1 可以利用这个等待时间处理其他任务。

P6 级别:CPU vs I/O 密集型

CPU 密集型(CPU-bound)

任务主要是 CPU 计算,如图像处理、科学计算、加密解密。

  • 特征:计算量大、很少阻塞 I/O
  • 配置corePoolSize = CPU 核心数 + 1(至多)
  • 原因:CPU 已饱和,增加更多线程无济于事,反而增加上下文切换开销
int cores = Runtime.getRuntime().availableProcessors();
int corePoolSize = cores + 1;  // CPU 密集型

I/O 密集型(I/O-bound)

任务大部分时间在等待 I/O,如数据库查询、文件读写、网络请求。

  • 特征:大量阻塞时间,CPU 利用率低
  • 配置corePoolSize = CPU 核心数 × (1 + 等待时间 / 计算时间)
int cores = Runtime.getRuntime().availableProcessors();
// 假设 I/O 等待时间是计算时间的 3 倍
int corePoolSize = cores * (1 + 3);  // 4 × cores

// 或者使用 2 倍作为经验值
int corePoolSize = cores * 2;  // I/O 密集型

P7 级别:高级配置与生产实践

超线程(Hyper-Threading)的影响

现代 CPU 的超线程技术使得一个物理核心可以执行两个线程。在 8 核 CPU(开启超线程)上,实际是 8 个物理核心、16 个逻辑核心。

// availableProcessors() 返回的是逻辑核心数
int processors = Runtime.getRuntime().availableProcessors();  // 16(开启超线程)

// CPU 密集型:每个逻辑核心可以运行一个线程
int cpuBoundThreads = processors;  // 或 processors - 1(留一个给 OS)

// 但超线程的效率不是 100%,物理核心共享执行单元
// 实际建议:CPU 密集型用物理核心数,I/O 密集型可以充分利用逻辑核心

分业务配置策略

业务类型线程池配置说明
计算密集型(编解码、加密)cores * 2I/O 等待时间短
混合型(Web 请求处理)cores * (1 + 阻塞比例)根据实测调整
纯 I/O 型(数据库访问)cores * 3 ~ cores * 5大量等待
RPC 调用cores * 3网络 I/O
批量导入cores * 2通常 I/O bound

生产配置经验值

  • Tomcat:默认 maxThreads=200(线程池)
  • 数据库连接池:HikariCP 默认 maximumPoolSize=10
  • Redis 连接池:JedisPool 默认 maxTotal=8

这些经验值背后都是对任务特性的考量。

【面试官心理】 这道题我能问到 P7 级别,是因为线程池大小配置涉及了 CPU 架构、I/O 特性、系统资源规划的多个维度。能说出超线程影响和分业务配置策略的候选人说明他有系统级思考能力。

1.4 追问升级

追问 1:为什么 Tomcat 默认 maxThreads=200?

Tomcat 的默认配置是历史经验值。200 线程对于大多数 Web 应用是合理的——既能处理并发,又不会因为线程过多导致上下文切换开销过大。但实际上应该根据业务特征(CPU vs I/O)调整。

追问 2:线程数和连接池大小的关系?

如果线程等待数据库连接,线程数 × 每线程连接数 连接池大小。如果线程数远大于连接池大小,大量线程会阻塞在等待连接上(浪费线程资源)。理想情况下:连接池大小 线程数 × 每线程需要的连接数。

二、生产调优方法 🟡

2.1 监控调整法

// 1. 初始配置
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;

// 2. 上线后监控
// - 活跃线程数:pool.getActiveCount()
// - 队列长度:pool.getQueue().size()
// - CPU 使用率:jvm metrics

// 3. 根据监控调整
// - 活跃线程数持续等于 corePoolSize → 增加线程数
// - CPU 利用率 < 50% → 增加线程数
// - CPU 利用率 > 80% → 减少线程数(I/O 等待不充分)

2.2 队列大小的配合

// 线程数少 + 队列大 = 低并发,但队列堆积风险
// 线程数多 + 队列小 = 高并发,快速拒绝

// 经验:队列大小 / 线程数 ≈ 期望的缓冲时间(秒)
// 4 线程 × 30 秒缓冲 = 120 的队列大小

三、特殊情况 🟢

3.1 单线程池的适用场景

ExecutorService pool = Executors.newSingleThreadExecutor();
// - 任务需要严格串行执行
// - 任务需要按顺序完成
// - 作为其他线程池的前置/后置处理器

3.2 虚拟线程(JDK 21+)

JDK 21 引入的虚拟线程使得线程数的配置哲学需要重新思考——虚拟线程的栈是堆内存分配的,创建和销毁成本极低,可以使用 一个任务一个虚拟线程 的模式。

try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        final int id = i;
        pool.submit(() -> handle(id));
    }
}
💡

面试加分点:能说出"Dubbo 的线程池配置(DubboProtocol.serverPoolSize)默认为 200,且 Dubbo 区分了 IO 线程和业务线程池",说明他对 RPC 框架的线程模型有了解。

⚠️

面试陷阱:被问到"队列大小和线程数谁更重要",很多人会说"线程数"。准确答案是:两者配合更重要。如果队列太小,线程数再多也无法缓冲任务(快速触发拒绝策略)。如果队列太大,任务堆积风险增加。