本节目标:给 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 相同 | 队列满后才可能继续扩容的上限 |
keepAliveTime | 0 | 非核心线程空闲多久回收;core=max 时基本不参与 |
unit | 毫秒 | 配合 keepAliveTime |
workQueue | ArrayBlockingQueue(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 个线程。实际顺序是:
- 线程数小于 core,创建新线程。
- 否则尝试把任务放进队列。
- 队列放不下,且线程数小于 max,创建新线程。
- 否则执行拒绝策略。
用一个 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 里替换平台线程 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。