Spring Boot 虚拟线程实战:ThreadLocal 串数据、连接池打爆、synchronized 钉住线程,三个坑及解决方案
目录
- 一、虚拟线程是什么
- 二、Spring Boot 如何开启虚拟线程
- 三、坑一:ThreadLocal 数据串了
- 四、坑二:数据库连接池被打爆
- 五、坑三:synchronized 钉住平台线程
- 六、全面检查清单
- 七、总结
一、虚拟线程是什么
Java 21 在 2023 年 9 月正式发布了虚拟线程(Virtual Threads,JEP 444)。它的核心突破在于:一个虚拟线程占用的内存从传统平台线程的约 1MB 降到几百字节,创建和切换的成本极低。
传统平台线程: 虚拟线程: ┌──────────────┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ... (几十万个) │ Thread A │ │v1│ │v2│ │v3│ │v4│ │ - 1MB 内存 │ └─┬┘ └─┬┘ └─┬┘ └─┬┘ │ - OS 调度 │ │ │ │ │ └──────────────┘ ▼ ▼ ▼ ▼ ┌──────────────────────┐ │ 平台线程池(几个线程) │ │ - 只负责执行,不绑定 │ └──────────────────────┘这意味着你可以同时处理几万甚至几十万个并发任务,而不需要庞大的线程池。
二、Spring Boot 如何开启虚拟线程
Spring Boot 从 3.2 版本开始支持虚拟线程。开启方式极其简单:
2.1 配置启用
# application.yml spring: threads: virtual: enabled: true开启后,以下组件会自动使用虚拟线程:
| 组件 | 默认线程模型 | 开启后 |
|---|---|---|
| Tomcat/Jetty 请求处理 | 平台线程池(默认 200) | 虚拟线程 |
@Async任务执行 | SimpleAsyncTaskExecutor | 虚拟线程 |
@Scheduled定时任务 | 单线程调度 | 虚拟线程 |
| RabbitMQ/Kafka 监听器 | SimpleMessageListenerContainer | 虚拟线程(需单独配置) |
2.2 手动创建虚拟线程
// 方式一:Thread.ofVirtual() Thread vt = Thread.ofVirtual() .name("my-virtual-thread") .start(() -> System.out.println("Hello from virtual thread")); // 方式二:Executors.newVirtualThreadPerTaskExecutor() try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> doWork()); }三、坑一:ThreadLocal 数据串了
3.1 问题场景
很多项目用 ThreadLocal 存储请求上下文(当前用户、traceId 等):
public class UserContext { private static final ThreadLocal<String> currentUser = new ThreadLocal<>(); public static void setUser(String userId) { currentUser.set(userId); } public static String getUser() { return currentUser.get(); } public static void clear() { currentUser.remove(); } } // 拦截器中设置 @Override public boolean preHandle(HttpServletRequest request, ...) { UserContext.setUser(request.getHeader("X-User-Id")); return true; } @Override public void afterCompletion(...) { UserContext.clear(); // 传统做法:请求结束清理 }传统线程下:一个请求从头到尾绑定同一个平台线程,setUser("A")后,整个请求期间getUser()都返回 "A",最后clear()清理——没问题。
虚拟线程下:一个虚拟线程可能在执行到一半时被挂起(比如等待数据库响应),此时底层的平台线程被释放去执行另一个虚拟线程。如果另一个虚拟线程也调用了setUser("B"),平台线程上的 ThreadLocal 值就被覆盖了。当虚拟线程 A 恢复执行时,它读到的可能是 "B"。
时间线: t1: 虚拟线程v1 被调度到平台线程P1 → setUser("userA") t2: v1 发起数据库查询,被挂起 → P1 被释放 t3: 虚拟线程v2 被调度到 P1 → setUser("userB") t4: v1 数据库返回,恢复执行 → getUser() = "userB" ← 串了!3.2 解决方案:ScopedValue(Java 21+)
ScopedValue是专门为虚拟线程设计的不可变上下文传递机制:
public class UserContext { private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance(); // 在 ScopedValue 的作用域内执行代码 public static <T> T withUser(String userId, Supplier<T> action) { return ScopedValue.where(CURRENT_USER, userId).call(action::get); } public static String getUser() { return CURRENT_USER.isBound() ? CURRENT_USER.get() : "unknown"; } } // 使用方式 —— 不是 set/get,而是 where().call() @GetMapping("/orders") public List<Order> list(@RequestHeader("X-User-Id") String userId) { return UserContext.withUser(userId, () -> orderService.queryOrders()); }ThreadLocal vs ScopedValue 对比:
| 特性 | ThreadLocal | ScopedValue |
|---|---|---|
| 绑定对象 | 线程 | 任务/作用域 |
| 虚拟线程安全 | ❌ 不安全 | ✅ 安全 |
| 可变性 | 可读写 | 不可变(set 后不可改) |
| 清理 | 需手动 remove() | 作用域结束自动清理 |
| 性能 | 稍快 | 略慢(但有作用域隔离保障) |
3.3 过渡方案:用 InheritableThreadLocal 行吗?
不推荐。InheritableThreadLocal只在创建子线程时复制一次,不适用于虚拟线程的挂起/恢复场景。
四、坑二:数据库连接池被打爆
4.1 问题场景
虚拟线程让 Tomcat 能同时处理几千个请求,但数据库连接池通常只配了 20-50 个连接:
// HikariCP 默认配置 spring.datasource.hikari.maximum-pool-size=20当 1000 个虚拟线程同时到达一个需要数据库查询的接口时:
1000 个请求 → 1000 个虚拟线程 → 1000 个 getConnection() ↓ 1967 只有 20 个连接 ↓ 980 个线程在等连接 ↓ 30 秒后超时 → 980 个 5004.2 解决方案
方案 A:Semaphore 限流(推荐)
@Component public class OrderService { // 限制最多 40 个并发数据库操作 private final Semaphore dbSemaphore = new Semaphore(40); public List<Order> queryOrders(Long userId) { dbSemaphore.acquire(); try { return orderMapper.selectByUserId(userId); } finally { dbSemaphore.release(); } } }方案 B:调整连接池大小
spring: datasource: hikari: maximum-pool-size: 100 # 从 20 调大到 100但这只是推迟问题——请求量再大一些,100 也不够。信号量是治本方案。
方案 C:请求入口限流
// 用 Bucket4j 或 Guava RateLimiter 在 Controller 层限流 @GetMapping("/orders") @RateLimit(permitsPerSecond = 100) public List<Order> list() { ... }4.3 什么资源需要加保护
| 资源 | 典型并发上限 | 是否需要信号量 |
|---|---|---|
| 数据库连接池 | 20-100 | ✅ 必须 |
| Redis 连接池 | 50-200 | ✅ 建议 |
| 下游 HTTP 服务 | 视对方而定 | ✅ 建议 |
| 本地计算 | 无限制 | ❌ 不需要 |
五、坑三:synchronized 钉住平台线程
5.1 问题场景
虚拟线程在执行 I/O 操作时会自动挂起、释放平台线程,让其他虚拟线程运行。但有一个例外:如果虚拟线程在synchronized块内遇到 I/O,它无法挂起——平台线程被「钉住」(pinned)。
public synchronized void processOrder(Order order) { // ↑ 获取了对象锁 orderMapper.insert(order); // DB I/O —— 虚拟线程本应挂起 notificationService.send(order); // HTTP I/O —— 又应挂起 // 但因为 synchronized,虚拟线程被钉在平台线程上, // 这两个 I/O 操作期间,平台线程白白等着 }5.2 解决方案:用 ReentrantLock
private final ReentrantLock lock = new ReentrantLock(); public void processOrder(Order order) { lock.lock(); try { orderMapper.insert(order); // ✅ I/O 期间虚拟线程能挂起 notificationService.send(order); // ✅ 平台线程被释放 } finally { lock.unlock(); } }5.3 锁类型与虚拟线程兼容性
| 锁类型 | 虚拟线程能否在 I/O 时挂起 | 建议 |
|---|---|---|
synchronized | ❌ 不能 | 避免在同步块内做 I/O |
ReentrantLock | ✅ 能 | 替代 synchronized |
Semaphore | ✅ 能 | 限流场景首选 |
ReadWriteLock | ✅ 能 | 读写分离场景 |
StampedLock | ✅ 能 | 高性能读写锁 |
5.4 如何检测 pinned 事件
# 开启 pinned thread 监控 logging: level: jdk: trace # 或通过 JFR 事件 jdk.VirtualThreadPinned启动时加 JVM 参数:
java -Djdk.tracePinnedThreads=full -jar app.jar当虚拟线程被钉住时,会打印完整栈信息到标准输出。
六、全面检查清单
开启虚拟线程前后,逐项检查:
1. ThreadLocal □ 项目里有哪些 ThreadLocal? □ 每个 ThreadLocal 的值在请求生命周期内是否可能被覆盖? □ 是否可以用 ScopedValue 替换? 2. 连接池 / 外部资源 □ 数据库连接池 max-size 是多少?够用吗? □ Redis / Kafka / RabbitMQ 的连接池呢? □ 高并发接口是否有信号量保护? 3. 同步锁 □ 搜索项目里所有 synchronized → 内有 I/O 操作吗? □ 能换成 ReentrantLock 吗? 4. 线程休眠 □ 有没有 Thread.sleep() → 虚拟线程下不需要 □ 有没有 ThreadLocal 依赖线程名称 → 虚拟线程名是动态的 5. 监控 □ 有没有开启 pinned thread 日志? □ 连接池等待队列是否有监控告警?七、总结
虚拟线程带来的不是「免费的性能提升」,而是一次并发模型的切换。三个核心坑都源于同一个事实:虚拟线程打破了「一个任务 = 一个平台线程」的绑定关系。
| 坑 | 根因 | 方案 |
|---|---|---|
| ThreadLocal 串数据 | 虚拟线程挂起/恢复时换平台线程 | 换成 ScopedValue |
| 连接池打爆 | 虚拟线程数 >> 连接池大小 | Semaphore 限流 |
| synchronized 钉住 | synchronized 阻止虚拟线程挂起 | 换成 ReentrantLock |
这三个检查做完,你的 Spring Boot 项目就能放心开启虚拟线程了。带来的好处是实打实的——同等硬件下并发能力提升 5-10 倍。
📋文章摘要
本文系统梳理了 Spring Boot 启用虚拟线程(Java 21+)后最常见的三个坑:ThreadLocal 数据串扰、数据库连接池被海量虚拟线程打爆、synchronized 导致虚拟线程无法挂起(pinned)。每个坑都给出了根因分析、代码示例和解决方案(ScopedValue 替代 ThreadLocal、Semaphore 限流保护连接池、ReentrantLock 替代 synchronized),文末附完整的开启前检查清单。适合正在或计划在生产环境启用虚拟线程的 Java 后端开发者。