《Java 编程实战》9.1 ExecutorService:线程池参数与队列

libraryd 的阻塞查询不能直接跑在 Jetty 请求线程上。本节用 ThreadPoolExecutor 把核心线程数、最大线程数、有界队列和拒绝策略讲清楚,并用 2 个线程、3 个队列位的饱和实验展示第 5 个任务之后会发生什么,最后把线程池接进 AppMain 与优雅停机。

本节目标:给 libraryd 一个参数可解释、队列有上限、拒绝可观测的工作线程池。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。

承接的问题

第 8 章结束时,libraryd 已经能把异常稳定地映射成 Problem Details,但所有请求仍然跑在 Jetty 的请求线程上。借阅、归还、搜索这些操作都要访问 PostgreSQL,JDBC 是阻塞 API:线程在等数据库返回时什么也做不了。Jetty 的线程数有上限,如果每个请求都占着一个线程等 SQL,吞吐会先被线程数卡住,而不是被数据库卡住。

一个自然的反应是「多开线程」。但线程不是免费的:每个平台线程要预留栈空间,创建和上下文切换都有成本;更危险的是没有上限的线程会把数据库连接池、CPU 和内存一起拖垮。HikariCP 的连接数是 10,应用却创建 200 个线程去抢这 10 个连接,结果只是把排队位置从连接池挪到了线程池。

所以这一节要回答三个问题:

  • 线程池的七个构造参数分别控制什么,哪些在阻塞任务里真正重要。
  • 有界队列满了以后,任务会去哪里,调用方会看到什么。
  • 线程池如何接进 AppMain 的生命周期,停机时不丢任务,也不无限等待。

HikariCP 的 maximumPoolSize 管的是数据库连接,ThreadPoolExecutor 管的是执行任务的线程。两者要一起看:线程池再大,也变不出更多连接。

线程池的七个参数

ThreadPoolExecutor 的构造器有七个参数,先看一个适合 libraryd 的写法:

package com.plume.library.app;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class ExecutorConfig {

    public static ThreadPoolExecutor newWorkerPool(int threads, int queueCapacity, String name) {
        if (threads < 1) {
            throw new IllegalArgumentException("threads 必须大于 0: " + threads);
        }
        if (queueCapacity < 1) {
            throw new IllegalArgumentException("queueCapacity 必须大于 0: " + queueCapacity);
        }
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                threads,
                threads,
                0L,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(queueCapacity),
                new NamedThreadFactory(name),
                new RejectingHandler(name));
        pool.allowCoreThreadTimeOut(false);
        return pool;
    }
}

七个参数依次是:

参数libraryd 的取值作用
corePoolSize工作线程数常驻线程数,任务来了优先用这些线程
maximumPoolSize与 core 相同队列满后才可能继续扩容的上限
keepAliveTime0非核心线程空闲多久回收;core=max 时基本不参与
unit毫秒配合 keepAliveTime
workQueueArrayBlockingQueue(200)等待执行的任务队列,必须有界
threadFactory带名字的工厂线程名进入日志、线程转储和监控
handler自定义拒绝处理器队列和线程都满时怎么处理

核心线程数和最大线程数取同一个值,是阻塞型任务的有意选择。JDBC 调用大部分时间在等网络和数据库,线程数超过数据库连接数没有意义;固定大小可以让池的形状稳定,便于和 Hikari 的 10 个连接对齐。keepAliveTime = 0 配合 allowCoreThreadTimeOut(false),表示核心线程不回收,避免流量低谷后反复创建线程。

队列选 ArrayBlockingQueue 而不是 LinkedBlockingQueue,是因为前者必须给出容量。这个容量就是「最多允许多少个任务在排队」,是一个可以写进配置、可以监控、可以在压测里调的数。LinkedBlockingQueue 不传容量时是 Integer.MAX_VALUE,等于把内存当队列。

线程工厂只做一件事:给线程一个稳定的名字。默认名字是 pool-1-thread-3,在线程转储里很难和业务对应;libraryd-worker-7 至少能看出是哪个池。

队列决定饱和形状

很多人以为「核心 2、最大 4」表示一上来就能开到 4 个线程。实际顺序是:

  1. 线程数小于 core,创建新线程。
  2. 否则尝试把任务放进队列。
  3. 队列放不下,且线程数小于 max,创建新线程。
  4. 否则执行拒绝策略。

用一个 core=2, max=4, queue=3 的池提交 8 个会阻塞的任务,本机输出如下:

submit 0 pool=1 active=1 queue=0
submit 1 pool=2 active=2 queue=0
submit 2 pool=2 active=2 queue=1
submit 3 pool=2 active=2 queue=2
submit 4 pool=2 active=2 queue=3
submit 5 pool=3 active=3 queue=3
submit 6 pool=4 active=4 queue=3
submit 7 REJECTED pool=4 queue=3

前两个任务各占一个核心线程,接下来三个进入队列。第 6 个任务发现队列满了,才创建第 3 个线程;第 7 个任务创建第 4 个线程;第 8 个任务既没有线程也没有队列位置,被拒绝。maximumPoolSize 不是「平时就能用到的并发度」,而是队列满之后的应急扩容。

再把池改成 core=max=2、队列 3,提交 10 个每个睡 300ms 的任务:

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        2, 2, 0L, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(3),
        new ThreadPoolExecutor.AbortPolicy());

for (int i = 0; i < 10; i++) {
    try {
        pool.execute(task);
    } catch (RejectedExecutionException e) {
        rejected.incrementAndGet();
    }
}

本机输出:

submit 0 accepted  pool=1 active=1 queue=0
submit 1 accepted  pool=2 active=2 queue=0
submit 2 accepted  pool=2 active=2 queue=1
submit 3 accepted  pool=2 active=2 queue=2
submit 4 accepted  pool=2 active=2 queue=3
submit 5 REJECTED pool=2 active=2 queue=3
submit 6 REJECTED pool=2 active=2 queue=3
submit 7 REJECTED pool=2 active=2 queue=3
submit 8 REJECTED pool=2 active=2 queue=3
submit 9 REJECTED pool=2 active=2 queue=3
started=5 rejected=5

2 个执行中的任务加 3 个排队任务,一共只接住 5 个,剩下 5 个在提交时就被拒绝。这个结果不是 bug,而是有界队列的定义:容量 3 就是容量 3,不会因为调用方着急就变长。

把队列换成 new LinkedBlockingQueue<>(),同样提交 1000 个任务,队列里会安静地躺下 998 个:

queued=998 pool=2

池还是 2 个线程,队列却可以一直涨。任务不会立刻被拒绝,但延迟会越来越长,内存会越来越高,最后可能在某个高峰 OOM。无界队列不是「更宽容」,而是把拒绝推迟到更不可控的时刻。

拒绝策略不是开关

JDK 自带四种拒绝策略,语义差别很大:

策略行为调用方感受
AbortPolicy抛 RejectedExecutionException立刻知道失败,可映射成 503
CallerRunsPolicy在提交任务的线程里直接执行请求变慢,但任务不丢
DiscardPolicy静默丢弃没有异常,也没有结果
DiscardOldestPolicy丢掉队列里最老的任务再提交老任务无声消失

AbortPolicy 是默认值,也是 libraryd 工作池的默认选择。它让过载变成一个明确的异常,Web 层可以把它翻译成 503,而不是让请求悬在那里。代价是调用方必须处理这个异常,不能让它冒泡成 500。

CallerRunsPolicy 看起来更友好:任务不丢,压力回到提交者。用 1 个线程、队列 1、提交 4 个任务,本机输出:

task 2 ran on main
task 0 ran on pool-2-thread-1
task 1 ran on pool-2-thread-1
task 3 ran on pool-2-thread-1
last runner=pool-2-thread-1

第 3 个任务(编号 2)在 main 线程上执行,因为池和队列都满了。放到 libraryd 里,这个「main」就是 Jetty 的请求线程:请求线程本来应该尽快返回响应,现在却要等一个可能几百毫秒的任务跑完。它确实提供了背压,但背压直接变成了用户可见的延迟;如果任务里还有数据库访问,请求线程会被占得更久。

DiscardPolicy 和 DiscardOldestPolicy 在 libraryd 里基本不用。审计日志被静默丢掉,比抛异常更难排查;丢弃最老的任务则可能让先到的请求永远没有结果。拒绝策略的第一要求是「让调用方知道」,而不是「让提交永不失败」。

在 libraryd 里落地

先补两个基础类。NamedThreadFactory 负责给线程命名,并兜住未捕获异常:

package com.plume.library.app;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public final class NamedThreadFactory implements ThreadFactory {

    private final String prefix;
    private final AtomicInteger sequence = new AtomicInteger();

    public NamedThreadFactory(String prefix) {
        this.prefix = prefix;
    }

    @Override
    public Thread newThread(Runnable task) {
        Thread thread = new Thread(task, prefix + sequence.incrementAndGet());
        thread.setDaemon(false);
        thread.setUncaughtExceptionHandler((t, error) ->
                System.getLogger("libraryd.executor").log(
                        System.Logger.Level.ERROR,
                        "uncaught exception in " + t.getName(), error));
        return thread;
    }
}

RejectingHandler 在拒绝时记录池的状态,然后继续抛异常。它不吞掉异常,也不自己重试:

package com.plume.library.app;

import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.LongAdder;

public final class RejectingHandler implements RejectedExecutionHandler {

    private static final System.Logger LOG = System.getLogger("libraryd.executor");

    private final String poolName;
    private final LongAdder rejected = new LongAdder();

    public RejectingHandler(String poolName) {
        this.poolName = poolName;
    }

    @Override
    public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
        rejected.increment();
        int capacity = executor.getQueue().size() + executor.getQueue().remainingCapacity();
        LOG.log(System.Logger.Level.WARNING,
                "pool={0} active={1} queue={2}/{3} rejected={4}",
                poolName, executor.getActiveCount(),
                executor.getQueue().size(), capacity, rejected.sum());
        throw new RejectedExecutionException(poolName + " queue is full");
    }

    public long rejectedCount() {
        return rejected.sum();
    }
}

AppConfig 在原有数据库、端口、日志级别之外增加两个分量:

int workerThreads = envInt(env, "LIBRARYD_WORKER_THREADS",
        Math.max(4, Runtime.getRuntime().availableProcessors()));
int workerQueueCapacity = envInt(env, "LIBRARYD_WORKER_QUEUE", 200);

默认线程数取 max(4, CPU),是为了让 2 核开发机和 16 核服务器都能跑,但上限仍由 Hikari 的连接数约束。队列默认 200,是「允许短暂突发,但不允许无限堆积」的折中;真正上线时要按压测结果调,不是照抄。

AppMain 里创建池,并把停机顺序接上:

ThreadPoolExecutor workers = ExecutorConfig.newWorkerPool(
        config.workerThreads(), config.workerQueueCapacity(), "libraryd-worker-");

AuditService auditService = new AuditService(auditRepository, workers);

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    app.stop();
    workers.shutdown();
    try {
        if (!workers.awaitTermination(10, TimeUnit.SECONDS)) {
            workers.shutdownNow();
        }
    } catch (InterruptedException e) {
        workers.shutdownNow();
        Thread.currentThread().interrupt();
    }
}, "libraryd-shutdown"));

顺序是先停 HTTP,再停工作池。HTTP 停了以后不会再有新任务提交;shutdown() 让队列里已有的任务跑完;10 秒还没结束就 shutdownNow() 中断。注意 shutdownNow() 只是给线程发中断,JDBC 驱动不一定响应,所以真正的兜底还要靠数据库侧的查询超时和连接池的回收。

工作池的第一个使用者是审计服务。借阅成功后写一条审计记录,但不希望它拖慢响应:

package com.plume.library.app;

import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;

public final class AuditService {

    private static final System.Logger LOG = System.getLogger("libraryd.audit");

    private final AuditRepository repository;
    private final ThreadPoolExecutor workers;

    public AuditService(AuditRepository repository, ThreadPoolExecutor workers) {
        this.repository = repository;
        this.workers = workers;
    }

    public void record(LoanAuditEvent event) {
        try {
            workers.execute(() -> repository.append(event));
        } catch (RejectedExecutionException rejected) {
            LOG.log(System.Logger.Level.WARNING,
                    "audit queue full, falling back to synchronous write for loan {0}",
                    event.loanId());
            repository.append(event);
        }
    }
}

这里选择「拒绝后同步写」,而不是丢弃或无限重试。审计记录不能丢,同步写让请求变慢,但至少保住了数据;如果审计可以丢,就应该明确记录丢弃计数,而不是用 DiscardPolicy 静默处理。这个取舍要在代码里写出来,不能藏在线程池配置里。

常见错误与边界

「用 Executors.newFixedThreadPool 就够了。」 它内部是 LinkedBlockingQueue,没有容量上限。流量突增时任务会在队列里堆积,延迟和内存一起上涨。libraryd 需要知道队列有多长,所以显式构造 ThreadPoolExecutor。

「core=0 就能按需创建线程。」 把 core 设为 0、队列设为无界,再提交 20 个阻塞任务,本机看到的是:

pool=1 active=1 queue=19

因为队列永远能接收任务,池不会扩容到 max,只有一个线程在跑。core=0 只适合配合 SynchronousQueue 这类直接交接的队列,不适合阻塞任务加无界队列的组合。

「线程池开大一点,吞吐就会上去。」 工作线程 32、Hikari 连接 10,多出来的 22 个线程只会阻塞在 getConnection() 上。更糟的是它们还占着内存和线程栈。线程池大小要和下游连接数、数据库并发能力一起定,不能只看 CPU 核数。

「用 submit 提交任务,异常会被线程池处理。」 submit 返回 Future,任务里的异常被包在 Future 里;如果没人调用 get(),异常就消失了。要么用 execute 并在任务内部 try/catch,要么保留 Future 并检查结果。libraryd 的审计任务在 record 内部捕获异常并记录,不依赖调用方。

「shutdown() 会立刻停掉所有线程。」 它只是不再接收新任务,已提交的任务会继续跑。真正要中断,需要 shutdownNow(),而且还要处理「线程不响应中断」的情况。停机的顺序、等待时间和兜底动作都要写进 AppMain,不能只调一个 shutdown()。

小结

  • 线程池解决的是「谁来执行阻塞任务」,连接池解决的是「同时能有多少数据库连接」,两者不能互相替代。
  • 七个参数里,阻塞任务最关键的是固定大小的核心线程、有界队列和明确的拒绝策略。
  • maximumPoolSize 只在队列满后才生效;队列容量才是平时真正约束并发的数。
  • 无界队列会把拒绝推迟到内存耗尽,CallerRunsPolicy 会把压力传回请求线程,两者都要慎用。
  • libraryd 用 libraryd-worker-* 命名线程、用 RejectedExecutionException 暴露过载、用「同步写」兜住不能丢的审计记录。
  • 停机顺序是先停 HTTP,再 shutdown() 等待,最后 shutdownNow() 中断。

线程池只能决定「最多有多少任务在跑」,不能决定「队列满了以后调用方看到什么」。下一节先把请求线程本身换成虚拟线程,再回来处理拒绝如何变成可解释的 HTTP 响应。

阅读导航:上一节:8.3 异常映射:从领域错误到 HTTP 响应 · 下一节:9.2 虚拟线程:在 libraryd 里替换平台线程 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发