手写线程池并封装 Spring Boot Starter:深入理解池化思想
从池化思想出发,用 LinkedList、synchronized、wait/notify 和 Worker 手写一个固定大小线程池,再把它封装成 Spring Boot Starter,并记录毒丸关闭、自动配置注册和排错过程。
手写线程池并封装 Spring Boot Starter:深入理解池化思想
这次练习没有直接调用 JDK 的 ThreadPoolExecutor,而是从最基础的线程协作机制开始,手写一个固定大小线程池,再把它封装成 Spring Boot Starter。这样做的重点不是生产环境替代 JDK 线程池,而是把“资源复用、任务排队、线程等待、优雅关闭和自动配置”这些概念串成一条完整链路。
文章按照这次实践的顺序展开:先理解池化思想,再实现线程池,最后封装 Starter 并记录排错过程。
一、什么是池化思想?
如果每次都创建资源,会发生什么?
传统模式是“即用即创,用完即弃”。例如每来一个任务就创建一个线程,任务完成后再销毁线程。线程创建和销毁都需要操作系统参与,频繁进行会带来明显开销;如果请求突然增多,还可能创建出过多线程,导致 CPU 上下文切换、内存占用和调度压力一起上升。
数据库连接也有同样的问题。每次查询都重新建立连接,时间和资源成本都很高,而且数据库本身也需要限制并发连接数。
池化的意义是什么?
池化就是提前准备一批可以复用的资源:
- 借用:需要时从池中取出一个已经准备好的资源
- 使用:用它完成当前任务
- 归还:任务完成后放回池中,而不是销毁
所以池化通常是在用空间换时间,也是在用预先规划换取运行时效率。线程池还额外承担了限流职责:任务先进入队列,工作线程按照固定数量逐步消费任务,避免任务数量直接转化成线程数量。
二、线程池的四个核心组件
这个手写版本可以拆成四个组件:
| 组件 | 对应实现 | 职责 |
|---|---|---|
| 任务 | Runnable | 描述要执行的工作 |
| 任务队列 | LinkedList + synchronized + wait/notify | 暂存还没有执行的任务 |
| 工作线程 | Worker 内部类 | 循环取任务并执行 |
| 管理器 | SimpleThreadPool | 创建线程、提交任务和关闭线程池 |
需要提前说明:这里的 LinkedList 没有容量上限,因此它能演示排队机制,但还不具备完整生产线程池所需的拒绝策略和有界队列能力。真正的业务代码优先使用 JDK 的 ThreadPoolExecutor,手写版本更适合学习底层机制。
三、手写线程池核心实现
1. 字段和构造器
package com.takeout.threadpool_starter;
import java.util.LinkedList;
import java.util.List;
public class SimpleThreadPool {
private final List<Runnable> taskQueue = new LinkedList<>();
private final Worker[] workers;
private volatile boolean isShutdown = false;
public SimpleThreadPool(int poolSize) {
if (poolSize <= 0) {
throw new IllegalArgumentException("线程数必须大于 0");
}
workers = new Worker[poolSize];
for (int i = 0; i < poolSize; i++) {
workers[i] = new Worker();
workers[i].start();
}
}
}
任务队列用 LinkedList 保存,所有访问都围绕 taskQueue 这把锁进行。workers 保存固定数量的工作线程。isShutdown 使用 volatile 修饰,保证一个线程修改关闭标志后,其他线程能够及时看到最新值。
2. 提交任务
public void execute(Runnable task) {
if (task == null) {
throw new NullPointerException("任务不能为 null");
}
synchronized (taskQueue) {
if (isShutdown) {
throw new IllegalStateException("线程池已关闭,无法接受新任务");
}
taskQueue.add(task);
taskQueue.notify();
}
}
提交任务时先抢占队列锁,检查线程池是否已经关闭,再把任务放入队列。notify 唤醒一个正在等待任务的 Worker。锁在这里很重要:入队和判断关闭状态必须处于同一个临界区,否则关闭线程和提交线程可能在边界条件下交错执行。
3. 工作线程循环
private class Worker extends Thread {
@Override
public void run() {
while (true) {
Runnable task;
synchronized (taskQueue) {
while (taskQueue.isEmpty() && !isShutdown) {
try {
taskQueue.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
if (taskQueue.isEmpty() && isShutdown) {
return;
}
task = taskQueue.remove(0);
}
try {
task.run();
} catch (Exception e) {
System.err.println("任务执行异常: " + e.getMessage());
}
}
}
}
这里最关键的是 while(true)。Worker 执行完一个任务后不会退出,而是回到循环顶部继续取下一个任务,这就是线程复用。
取任务时只在 synchronized 块内操作队列,真正执行 task.run() 时已经释放队列锁。这样一个慢任务不会阻塞其他 Worker 抢占队列,也不会阻塞主线程提交新任务。
任务异常被捕获后,当前 Worker 不会因为一个普通任务失败而直接死亡。但示例只打印了异常消息,生产环境应使用应用日志框架记录完整堆栈,并根据需要接入监控、告警或失败重试。
四、wait/notify 到底怎样协作?
wait 为什么必须释放锁?
假设 Worker 发现队列为空,于是进入等待。如果它睡觉时仍然占有 taskQueue 的锁,提交任务的线程就无法进入 synchronized 块,也就无法入队和唤醒 Worker,最终形成互相等待。
wait 的设计正好解决了这个问题:调用 wait 后,线程会原子地释放锁并进入等待状态。
完整生命周期如下:
| 操作 | 线程状态和锁状态 |
|---|---|
| 进入 synchronized | 抢到 taskQueue 的锁 |
| 调用 wait | 释放锁并进入等待 |
| 被 notify 唤醒 | 重新竞争 taskQueue 的锁 |
| 从 wait 下一行继续 | 重新拿到锁后继续检查条件 |
| 离开 synchronized | 释放锁 |
为什么要用 while,而不是 if?
被 notify 唤醒只代表“可能有条件变化”,不代表当前线程拿到锁时队列一定有任务。多个 Worker 可能同时被唤醒,也可能存在虚假唤醒,因此醒来后必须重新检查条件:
while (taskQueue.isEmpty() && !isShutdown) {
taskQueue.wait();
}
这是一条通用规则:等待某个条件时使用 while 循环,唤醒后重新判断条件。
五、优雅关闭与毒丸模式
为什么不能只让线程一直运行?
Worker 里的 while(true) 保证了线程复用,但也意味着线程不会自行结束。应用关闭时,如果线程池没有关闭机制,工作线程可能继续存活,或者任务没有按照预期完成。
一种简单方案是毒丸模式:向队列放入特殊信号,Worker 取到这个信号后退出。这个版本用 null 表示关闭信号:
public void shutdown() {
isShutdown = true;
synchronized (taskQueue) {
for (int i = 0; i < workers.length; i++) {
taskQueue.add(null);
}
taskQueue.notifyAll();
}
}
Worker 取出任务后检查:
if (task == null) {
return;
}
这里要明确:null 不是 Runnable 任务,而是队列内部约定的控制信号。为了避免普通任务和控制信号混淆,另一种更清晰的做法是定义专门的 PoisonPill 对象或使用独立的关闭状态。
关闭流程
- 设置 isShutdown=true,拒绝后续 execute 调用。
- 向队列加入与 Worker 数量相同的关闭信号。
- notifyAll 唤醒正在等待的 Worker。
- Worker 按队列顺序取任务,执行完已有任务后取到关闭信号并退出。
- 调用 awaitTermination 等待所有 Worker 结束。
public void awaitTermination() {
for (Worker worker : workers) {
try {
worker.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
毒丸模式成立的前提是队列遵循 FIFO,并且关闭信号是在拒绝新任务之后加入的。当前实现会先处理关闭前已经入队的任务,再处理毒丸,因此属于“尽量完成已提交任务”的优雅关闭。
六、封装成 Spring Boot Starter
线程池核心实现与 Spring 解耦后,可以通过 Starter 让其他应用只引入依赖和配置,就自动得到 SimpleThreadPool Bean。
1. 配置属性类
package com.takeout.threadpool_starter;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "mythreadpool")
public class ThreadPoolProperties {
private int coreSize = 5;
}
@ConfigurationProperties 的职责是把 mythreadpool 前缀下的配置绑定到 Java 对象。它负责“配置绑定”,不等于自动注册 Bean。要让这个属性类进入 Spring 容器,还需要 @EnableConfigurationProperties 或 @ConfigurationPropertiesScan 等注册方式。
2. 自动配置类
package com.takeout.threadpool_starter;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
@EnableConfigurationProperties(ThreadPoolProperties.class)
public class ThreadPoolAutoConfiguration {
@Bean
public SimpleThreadPool simpleThreadPool(ThreadPoolProperties properties) {
return new SimpleThreadPool(properties.getCoreSize());
}
@Bean
public Object threadPoolShutdownHook(SimpleThreadPool pool) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
pool.shutdown();
pool.awaitTermination();
}));
return new Object();
}
}
这里有三层职责:
- @ConfigurationProperties 负责读取配置
- @EnableConfigurationProperties 负责注册属性 Bean
- @AutoConfiguration 负责让 Spring Boot 发现并加载自动配置
关闭钩子里同时调用 shutdown 和 awaitTermination,才是“发出关闭请求并等待 Worker 退出”。如果只调用 shutdown,JVM 关闭流程不一定会等待这些 Worker 完成最后的任务。
生产 Starter 还应该继续补充 @ConditionalOnMissingBean,给使用者覆盖默认线程池实现的机会;也应考虑 @ConditionalOnProperty、参数校验、容量限制、拒绝策略以及 Spring 生命周期接口等工程能力。
3. 注册自动配置
Spring Boot 3 使用以下文件注册自动配置:
路径:
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件内容:
com.takeout.threadpool_starter.ThreadPoolAutoConfiguration
这里的 spring 是目录名,不是文件名的一部分。把路径误写成 META-INF.spring 会导致 Spring Boot 找不到自动配置类,Starter 看起来像“没有生效”。Spring Boot 2 时代常见的是 META-INF/spring.factories,不能把两种机制的路径混用。
七、在 Spring Boot 项目中使用
application.yml
mythreadpool:
core-size: 5
Spring Boot 的宽松绑定会把 core-size 绑定到 Java 属性 coreSize。
Controller 中提交任务
@RestController
@RequestMapping("/starter")
public class StarterController {
private final SimpleThreadPool simpleThreadPool;
public StarterController(SimpleThreadPool simpleThreadPool) {
this.simpleThreadPool = simpleThreadPool;
}
@GetMapping("/thread")
public String threadDemo() {
for (int i = 1; i <= 10; i++) {
final int taskId = i;
simpleThreadPool.execute(() -> {
System.out.println("任务 " + taskId + " 由 "
+ Thread.currentThread().getName() + " 执行");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
return "10 个任务已提交,查看控制台输出";
}
}
当线程数配置为 5、任务数量为 10 时,前 5 个任务会被 5 个 Worker 执行,后 5 个任务会在前面的任务完成后继续复用这些线程。线程名称可能是 Thread-0、Thread-1,也可能因实现调整而不同,所以验证重点应该是线程数量固定且线程被重复使用,而不是依赖具体名称。
八、排错记录
踩坑 1:自动配置文件路径写错
错误路径:
META-INF.spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
正确路径:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
一个字符的差异就会让自动配置完全不加载。排查 Starter 时,应该先检查文件是否确实位于最终 jar 包的 META-INF/spring 目录,再检查文件内容和类的全限定名。
踩坑 2:把配置属性当成构造器普通参数
错误写法:
public StarterController(DemoStarterService demoService,
ThreadPoolProperties threadPoolProperties,
Integer coreSize) {
}
Spring 会把未标注的构造器参数当作依赖去容器里寻找,而不是自动从配置文件读取整数值。若确实只想注入一个配置值,需要显式使用 @Value;如果是一组配置,则更推荐直接注入已经绑定好的 ThreadPoolProperties。
public StarterController(
ThreadPoolProperties threadPoolProperties,
@Value("${mythreadpool.core-size}") Integer coreSize) {
}
但同一配置同时使用 @ConfigurationProperties 和 @Value 往往会造成重复读取。实际项目中,相关配置较多时应优先统一使用 ThreadPoolProperties。
踩坑 3:${} 和 #{} 混淆
- ${...}:读取 Environment 或配置文件中的属性值
- #{...}:解析 SpEL 表达式,可以引用 Spring 容器中的 Bean 或执行表达式
例如读取配置值使用 @Value("${mythreadpool.core-size}"),不能把它写成 #{}。
踩坑 4:IDEA 报无法自动装配
自动配置注册正确时,IDEA 仍可能暂时提示无法自动装配,尤其是在依赖没有重新加载或索引没有更新时。判断 Starter 是否真正生效,应以应用启动日志、运行时 Bean 获取结果和实际请求执行结果为准,同时确认自动配置类是否出现在最终依赖包中。
九、这个 Demo 与生产线程池的差距
手写版本适合理解机制,但不能直接等同于生产实现。至少还存在这些工程边界:
- 队列是无界 LinkedList,任务提交过快时可能持续占用内存。
- 没有最大线程数、核心线程数、空闲回收时间和拒绝策略。
- 没有提供任务完成结果,execute 只有提交语义,不像 Future 那样可以获取返回值。
- 任务异常只输出消息,缺少统一日志、监控和告警。
- 关闭流程还需要处理重复 shutdown、关闭超时和中断策略。
- 线程名称、守护线程属性和异常处理器都没有进行工程化配置。
因此,这个 Demo 的价值在于把 JDK 线程池封装的关键思想拆开观察;真正的业务系统一般使用经过长期验证的 ThreadPoolExecutor,并通过 ThreadPoolTaskExecutor 等 Spring 组件接入生命周期和监控体系。
十、总结
这次实践完成了从概念到工程的完整闭环:
- 通过池化思想理解预创建、复用和限流
- 用固定数量 Worker 和 while(true) 实现线程复用
- 用 synchronized、wait/notify 完成任务队列协作
- 用毒丸信号和关闭钩子实现优雅停止
- 用 @ConfigurationProperties 绑定 Starter 配置
- 用 @AutoConfiguration 和 AutoConfiguration.imports 完成自动配置
- 通过路径、注入注解和配置表达式排查 Starter 不生效问题
手写线程池最重要的收获,不是记住几段代码,而是理解一条运行链路:任务进入队列,空闲 Worker 被唤醒,Worker 取出任务并执行,执行完成后回到循环继续复用;应用关闭时,线程池拒绝新任务,处理已有任务,最后按约定退出。
把一个 Demo 封装成可复用 Starter 后,才会真正遇到配置绑定、Bean 注册、自动配置发现、生命周期管理和覆盖扩展这些工程问题。这也是从“会写功能”走向“会设计可复用组件”的一次练习。