Spring Boot 虚拟线程实战:ThreadLocal 串数据、连接池打爆、synchronized 钉住线程,三个坑及解决方案
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 个 500
4.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 后端开发者。
更多推荐




所有评论(0)