跳到正文
Joeplover
学习笔记·2026-08-02·约 11 分钟阅读

手写线程池并封装 Spring Boot Starter:深入理解池化思想

从池化思想出发,用 LinkedList、synchronized、wait/notify 和 Worker 手写一个固定大小线程池,再把它封装成 Spring Boot Starter,并记录毒丸关闭、自动配置注册和排错过程。

代码编辑器中的 Java 线程池与 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 对象或使用独立的关闭状态。

关闭流程

  1. 设置 isShutdown=true,拒绝后续 execute 调用。
  2. 向队列加入与 Worker 数量相同的关闭信号。
  3. notifyAll 唤醒正在等待的 Worker。
  4. Worker 按队列顺序取任务,执行完已有任务后取到关闭信号并退出。
  5. 调用 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 与生产线程池的差距

手写版本适合理解机制,但不能直接等同于生产实现。至少还存在这些工程边界:

  1. 队列是无界 LinkedList,任务提交过快时可能持续占用内存。
  2. 没有最大线程数、核心线程数、空闲回收时间和拒绝策略。
  3. 没有提供任务完成结果,execute 只有提交语义,不像 Future 那样可以获取返回值。
  4. 任务异常只输出消息,缺少统一日志、监控和告警。
  5. 关闭流程还需要处理重复 shutdown、关闭超时和中断策略。
  6. 线程名称、守护线程属性和异常处理器都没有进行工程化配置。

因此,这个 Demo 的价值在于把 JDK 线程池封装的关键思想拆开观察;真正的业务系统一般使用经过长期验证的 ThreadPoolExecutor,并通过 ThreadPoolTaskExecutor 等 Spring 组件接入生命周期和监控体系。

十、总结

这次实践完成了从概念到工程的完整闭环:

  • 通过池化思想理解预创建、复用和限流
  • 用固定数量 Worker 和 while(true) 实现线程复用
  • 用 synchronized、wait/notify 完成任务队列协作
  • 用毒丸信号和关闭钩子实现优雅停止
  • 用 @ConfigurationProperties 绑定 Starter 配置
  • 用 @AutoConfiguration 和 AutoConfiguration.imports 完成自动配置
  • 通过路径、注入注解和配置表达式排查 Starter 不生效问题

手写线程池最重要的收获,不是记住几段代码,而是理解一条运行链路:任务进入队列,空闲 Worker 被唤醒,Worker 取出任务并执行,执行完成后回到循环继续复用;应用关闭时,线程池拒绝新任务,处理已有任务,最后按约定退出。

把一个 Demo 封装成可复用 Starter 后,才会真正遇到配置绑定、Bean 注册、自动配置发现、生命周期管理和覆盖扩展这些工程问题。这也是从“会写功能”走向“会设计可复用组件”的一次练习。