踩坑记:Spring Boot 3.3 虚拟线程上线后 ThreadLocal 全部失效

Pitfall Report: All ThreadLocal Values Lost After Enabling Virtual Threads in Spring Boot 3.3

| Ryan Zhang | 2026-08-02T12:31:08

兴冲冲上了虚拟线程,结果用户信息全丢了。这篇文章记录虚拟线程和 ThreadLocal 的兼容性陷阱以及我们的修复方案。

Documenting the ThreadLocal compatibility trap with virtual threads and our fix after user context was completely lost in production.

## 事故回顾 Spring Boot 3.3 终于原生支持虚拟线程了,我们团队第一时间就上了。改动非常简单,`application.yml` 里加一行: ```yaml spring: threads: virtual: enabled: true ``` 本地测试一切正常,灰度发布也没问题。全量上线后半小时,客服开始接到大量投诉:**用户登录后看到的是别人的数据**。 紧急回滚后开始排查。 ## 根因分析 我们的用户上下文是通过 `ThreadLocal` 存储的: ```java public class UserContext { private static final ThreadLocal HOLDER = new ThreadLocal(); public static void set(UserInfo user) { HOLDER.set(user); } public static UserInfo get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } } ``` 在 Filter 里设置,Controller 里读取。这套方案在传统线程池模式下运行了两年,一直没问题。 但虚拟线程改变了游戏规则: 1. **虚拟线程是轻量级的**,JVM 可能在一个平台线程上调度成千上万个虚拟线程 2. **虚拟线程会频繁挂起和恢复**,在 I/O 等待时会从平台线程上卸载 3. **ThreadLocal 的清理时机变了**,虚拟线程的生命周期管理和传统线程完全不同 关键问题:虚拟线程虽然每个有自己的 ThreadLocal,但因为创建和销毁极快,`ThreadLocal` 如果在 finally 里没有被正确清理,**值会泄露到下一个使用同一虚拟线程的请求中**。 我们的 Filter 确实有 `finally { UserContext.clear(); }`,但问题出在一个异步操作上: ```java @Async public void sendNotification(Long userId) { UserInfo user = UserContext.get(); // 在异步线程里读取 — 拿到的是错误的值! // ... } ``` 虚拟线程模式下,`@Async` 方法在另一个虚拟线程中执行,而那个虚拟线程的 ThreadLocal 可能残留了之前请求的用户信息。 ## 解决方案 ### 方案一:ScopedValue(推荐) Java 21 引入的 `ScopedValue` 就是为了替代虚拟线程场景下的 ThreadLocal: ```java public class UserContext { public static final ScopedValue CURRENT_USER = ScopedValue.newInstance(); } // Filter 里 ScopedValue.where(UserContext.CURRENT_USER, userInfo) .run(() -> chain.doFilter(request, response)); // 使用 UserInfo user = UserContext.CURRENT_USER.get(); ``` `ScopedValue` 的作用域是明确的——只在 `run()` 或 `call()` 的 lambda 内有效,不存在泄露问题。而且它天然支持结构化并发,子线程自动继承父线程的值。 ### 方案二:请求属性传递 如果暂时不想改成 ScopedValue,可以通过 `HttpServletRequest` 的 attribute 传递: ```java // Filter 里 request.setAttribute("currentUser", userInfo); // Controller 里 @GetMapping("/api/profile") public Result profile(HttpServletRequest request) { UserInfo user = (UserInfo) request.getAttribute("currentUser"); // ... } ``` 这种方式完全不依赖线程模型,但代码侵入性大,不够优雅。 ### 方案三:Context 传播框架 使用 Micrometer 的 Context Propagation 库: ```java ContextRegistry.getInstance().registerThreadLocalAccessor( "user", UserContext::get, UserContext::set, UserContext::clear ); ``` 这样在虚拟线程切换时,框架会自动保存和恢复 ThreadLocal 的值。Spring Boot 3.3 已经内置了对 Micrometer Context Propagation 的支持。 ## 我们的选择 最终选了**方案一**(ScopedValue),因为: 1. 语义最清晰,作用域明确 2. 性能最好(ScopedValue 内部做了缓存优化) 3. 天然支持结构化并发 4. 这是 Java 官方推荐的方向 改造大概花了三天,主要工作是替换所有 `UserContext.get()` 的调用点(全局搜索替换就行)和修改 Filter 的写法。 ## 教训 1. **虚拟线程不是免费的性能提升**,它改变了线程模型的基本假设 2. **ThreadLocal 在虚拟线程下是定时炸弹**,尤其是在异步和跨线程场景 3. **上线前一定要做并发压测**,我们的灰度流量太小,没暴露出并发下的 ThreadLocal 泄露 4. **ScopedValue 应该成为新项目的默认选择** 希望这篇踩坑记能帮大家避免同样的问题。


## Incident After enabling virtual threads in Spring Boot 3.3, users started seeing other users' data due to ThreadLocal value leakage across virtual thread reuse. ## Root Cause ThreadLocal values in async methods could retain data from previous requests when virtual threads are rapidly created and destroyed. The issue manifested specifically in @Async methods reading stale ThreadLocal values. ## Solution Migrated from ThreadLocal to Java 21's ScopedValue, which provides explicit scoping and automatic structured concurrency support. ScopedValue eliminates leakage by design since values only exist within the run()/call() lambda scope. ## Key Takeaway Virtual threads change fundamental threading assumptions. ThreadLocal is a ticking bomb in virtual thread environments - prefer ScopedValue for new projects.

← Back to News