如果你维护过后端服务,大概率见过这种魔幻场景:日志里的traceId串台、用户看到别人的购物车。问题常出在ThreadLocal身上,它从JDK 1.2用到现在,官方也看不下去了,于是孵化了ScopedValue来接替它。网上“别再用ThreadLocal了,ScopedValue更香”的说法越来越多,但我想先把话说清楚:ScopedValue确实解决了ThreadLocal最痛的一批问题,但它不是银弹,直接照搬也会踩坑。
这篇文章我会从一次真实的线程池串号事故讲起,拆开ThreadLocal的结构性缺陷,再讲透ScopedValue的核心机制、代码改造方式和避坑清单。适合正在用ThreadLocal传链路ID、用户身份、租户信息,或者被线程池复用导致的脏数据折磨过的后端同学。看完你会明白:这两者真正的区别,不是谁更“新”,而是谁把上下文生命周期放对了位置。
1. 线程池串号事故:ThreadLocal到底是怎么翻车的
1.1 事故现场:购物车里怎么出现了别人的商品
去年我给一个电商中台做性能压测,跑到500QPS左右,产品那边突然喊:“有个账号能看到别人的购物车”。第一反应是缓存Key写错了,或者Redis里的数据串了。查了一圈,发现链路追踪平台上的日志混着两三个traceId,同一个请求内部,前半段是A的traceId,后半段变成B的。这才意识到,跟Redis没关系,是应用内上下文串了。
场景还原一下:网关Filter接收请求时,往ThreadLocal里塞了当前用户的uid和traceId;业务代码里,有一部分操作丢给了一个共享线程池去执行。线程池里的线程是复用的,一个线程处理完上一次任务后,ThreadLocalMap里的值并没有被清掉;下一次任务被分配到同一个线程时,直接get()就拿到了上一个任务残留的uid。压测一旦拉高并发,复用频率上升,串号概率立刻暴露。
这件事儿听起来像经典的“忘记remove”,但我复盘后发现:代码里每个入口都写了finally清理,问题出在异步任务分支的处理上——有一个异步任务的入口包装漏了清理逻辑。也就是说,ThreadLocal这个模型下,只要有一个入口没清理,雷就埋下了。
1.2 排查链路:从日志串台到线程复用
当时排查的顺序是这样:先看业务日志,发现traceId不一致,基本排除数据库和缓存的问题;接着在Filter入口打印当前线程的hashCode,在业务线程池里又打印一次,发现线程hashCode相同,说明是同一个线程在处理多个请求;再在业务代码入口直接打印ThreadLocal.get(),发现拿到的是别的请求的值。到这一步,根因其实就清楚了:值还挂在被复用的线程上。
这个流程本身不复杂,但它说明了一个关键点:ThreadLocal的问题,往往不是“某个人忘了remove”这种偶发事故,而是模型本身把值的生命周期绑在了一个会被反复使用的载体上——线程。只要有一个入口没清理,或者清理时机不对,埋下的雷总会在某个高并发时刻爆掉。压测只是把雷提前引爆了。
1.3 传统三板斧:为什么治标不治本
遇到串号,大家通常会上三招,我都试过,效果都很有限。
第一招是统一在finally里remove,比如过滤器里try-finally保证清理。小项目可行,代码里有多个set入口、有异步任务、有RPC子线程池时,任何一个finally漏掉,又是一个新雷。
第二招是换InheritableThreadLocal。它解决的是“父线程创建子线程时复制值”的问题。可线程池里的线程不是每次new出来的,复用时根本不会触发复制逻辑;而且它复制的是创建瞬间的快照,父线程后面更新值,子线程完全感知不到。我在项目里试过以后,直接放弃。
第三招是引入阿里开源的TransmittableThreadLocal,给线程池包一层来传递上下文。这个方案能解决多数线程池传递问题,但要给业务里的每个线程池做包装,侵入性强,而且本质上还是在ThreadLocal的架构上打补丁。
把这三招放在一起看,你会发现大家一直在围绕“线程”这个载体做文章,却没人想过:为什么上下文不能只属于“这一次任务”,而不是属于“执行任务的线程”?想明白这一点,ScopedValue的出现就顺理成章了。
2. 可变性、线程级存储、手动清理:ThreadLocal的三个结构性瓶颈
2.1 内存泄漏链条:value是强引用,线程池线程长生不老
先说最经典的坑。ThreadLocal的数据存储结构叫ThreadLocalMap,是Thread类内部的一个字段,可以理解为每个线程都自带一个小的哈希表。调用set(value)时,实际是把当前ThreadLocal对象作为key,把value放进当前线程那张表里。问题在于key是弱引用,value是强引用。线程池里的线程为了复用,生命周期极长,只要value存在表里,GC就永远回收不到它。
网上很多人说“用ThreadLocal要在用完后remove”,其实就是在给这个设计擦屁股。在一个长期运行的Web容器里,线程池线程数量往往是几十到几百个,每次请求set一个对象,请求结束不remove,线程上就是一堆没人要的强引用。压力一大,内存增长曲线就很吓人。Netty、Tomcat都针对这个问题做过专门的内存泄漏检测机制,这不是我危言耸听,是整个生态都在反复踩的坑。
2.2 可变性:谁都能set,出问题不知道是谁干的
ThreadLocal的值是可以随时修改的。拦截器里set了当前用户,业务代码里又set了一遍,某个第三方工具包在链路里也set了一遍。等线上数据不对,你根本说不清楚最后一次是哪个环节改的。ThreadLocal本身不提供任何防篡改机制,它默认所有使用者都是自律的。
这一点在单机服务里还好说,在多团队协作的微服务里就很痛。上下文变量经常变成公共垃圾场:有人塞RPC的spanId,有人塞灰度标记,有人塞当前语言环境。塞的人越多,串扰越严重。ScopedValue把值设计成不可变,绑定一次读到作用域结束,想改就必须重新绑定一个新的作用域,从根上堵住了“随手改”这个行为。
2.3 伪继承:InheritableThreadLocal和线程池根本不兼容
InheritableThreadLocal的设计初衷是支持父子线程继承上下文。实现方式是在创建子线程的一瞬间,把父线程的InheritableThreadLocal值复制给子线程一份。听起来很美,但面对线程池时直接失灵:线程池线程不是在每次任务时新建的,池子初始化时那批线程早就创建完了,后续任务只是被队列分配到已有线程上,根本没有触发复制逻辑。
更隐蔽的问题是:即便手动干预,让线程池任务里的子线程继承了当前值,它继承的也是一次性快照。父线程后续更新了上下文,子线程读到的还是旧值。这种“假继承”在异步化、响应式编程越来越流行的今天,基本等于报废。所以官方在推进结构化并发的过程中,把上下文传播也重新设计了一遍,这就是ScopedValue与StructuredTaskScope配套出现的背景。
3. ScopedValue的设计:作用域、不可变、自动恢复,一次把话说清楚
3.1 三个核心概念:绑定占位符、动态作用域、自动清理
ScopedValue的用法和ThreadLocal差异很大,先看最小示例再解释原理:
import jdk.incubator.concurrent.ScopedValue; private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance(); public void process(Request request) { ScopedValue.where(TRACE_ID, request.getTraceId()) .run(() -> { // 在 run 的调用链内部,TRACE_ID.get() 都能拿到 traceId service.handle(request); }); // run 返回后,绑定自动解除,不需要任何 remove }这里有几个概念要掰开揉碎说。第一,TRACE_ID这个ScopedValue对象本身只是一个“绑定占位符”,它不存任何数据,真正的绑定关系由where(key, value)临时创建。第二,绑定的作用范围不是“线程”,而是“当前正在执行的这段代码及其所有嵌套调用”,官方叫动态作用域。第三,run执行完,绑定自动消失,没有任何残留。你可以把where/run理解为:给一段代码临时设置一个只读常量。
用生活例子类比:ThreadLocal是每个线程自带一个黑板,写上去的字要自己擦;ScopedValue是进办公区时前台发你一张临时工牌,离开办公区工牌自动回收。你根本不需要操心“工牌忘还”这件事。
3.2 有返回值、嵌套覆盖:这些细节决定好不好用
很多业务代码需要在绑定作用域里返回结果,用Runnable不方便。ScopedValue提供了callWhere方法:
String userId = ScopedValue.callWhere(TRACE_ID, traceId, () -> getUserByTrace(traceId));另外,如果外层已经绑定了某个值,你在内层通过where再绑定一个新值,内层会覆盖外层;内层run结束,外层绑定自动恢复。这个特性很适合做“默认值+局部覆盖”的配置场景,比如租户默认配置是A,某个调用需要临时用配置B,直接嵌套一层where即可,外层逻辑完全不受污染。
这里提醒一个容易忽略的点:ScopedValue绑定期间是不可变的,所以不要想着在run内部调用类似set()的API去改值,它压根没有这个接口。如果真需要动态变化,就用嵌套作用域的方式表达,代码反而更清晰。
3.3 性能优势:为什么官方敢说比ThreadLocal更高效
很多人聊ScopedValue会提到性能。我做过高频get()的简单验证,在绑定作用域内反复读取值,ScopedValue完全不输ThreadLocal,某些场景还略快。原因从实现说起:ThreadLocal.get()每一步都要先拿到当前线程的ThreadLocalMap,然后在数组里做哈希定位、探测查找;ScopedValue则允许运行时把绑定值直接存在某个字段里,Java代码里的get()可能只是一次字段读取,省掉一整条查找链。
官方JEP里也明确提到,ScopedValue的实现可以更高效,因为绑定不能被修改,运行时有更多优化空间。但我要泼盆冷水:如果纯粹为了性能去迁移,没必要,两者在绝大多数业务场景下的差别根本感觉不出来。真正值得为ScopedValue买单的,是它更清晰的语义、更安全的作用域,以及把“忘记清理”这类隐患从设计上抹掉。
3.4 配合StructuredTaskScope:子任务自动继承的正确姿势
前面吐槽过InheritableThreadLocal的伪继承,ScopedValue的继承方式是另一套逻辑。它要求你使用StructuredTaskScope来创建子任务,子任务会自动继承父任务作用域中的ScopedValue绑定。看代码:
import jdk.incubator.concurrent.ScopedValue; import jdk.incubator.concurrent.StructuredTaskScope; public void handle(Request request) throws Exception { ScopedValue.where(TRACE_ID, request.getTraceId()) .run(() -> { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<String> userFuture = scope.fork(() -> loadUser(request.userId())); Future<List<Order>> orderFuture = scope.fork(() -> loadOrders(request.userId())); scope.join(); scope.throwIfFailed(); String user = userFuture.resultNow(); List<Order> orders = orderFuture.resultNow(); render(user, orders); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } catch (ExecutionException e) { throw new RuntimeException(e); } }); }两个子任务loadUser和loadOrders内部,直接调用TRACE_ID.get()就能拿到父任务绑定好的traceId。这种继承是动态的、和调用树强绑定的:任务结束、scope关闭,上下文自动消失。不会出现线程池复用导致的值残留,也不存在“复制一次之后再也不同步”的问题。这就是官方设计的“上下文在任务之间传播”的新范式。
4. 从ThreadLocal迁到ScopedValue:改代码、配环境、避坑实战
4.1 同一个traceId,两种写法的直观对比
先看ThreadLocal的经典写法:
public class TraceContext { private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>(); private TraceContext() {} public static void setTraceId(String traceId) { TRACE_ID.set(traceId); } public static String getTraceId() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } } // 使用处 try { TraceContext.setTraceId(UUID.randomUUID().toString()); chain.doFilter(request, response); } finally { TraceContext.clear(); }再看ScopedValue版本:
import jdk.incubator.concurrent.ScopedValue; public class ScopedTraceContext { private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance(); private ScopedTraceContext() {} public static String getTraceId() { return TRACE_ID.get(); } public static void runWithTraceId(String traceId, Runnable action) { ScopedValue.where(TRACE_ID, traceId).run(action); } } // 使用处 ScopedTraceContext.runWithTraceId(UUID.randomUUID().toString(), () -> { chain.doFilter(request, response); });两者最直观的差别,就是ScopedValue版本不再有clear()方法调用。ThreadLocal的set和remove必须成对出现,而ScopedValue的绑定和解绑被封装进where + run,天然配对。少一个remove,就意味着少一个“忘记remove”的bug入口。如果你的项目里有很多填充ThreadLocal的入口,数一数有多少对set/clear,就知道能少写多少样板代码。
4.2 环境配置:JDK版本、Maven和Gradle、IDE
ScopedValue目前还不是JDK标准库的稳定API。它从JDK 22开始以孵化模块jdk.incubator.concurrent提供,JDK 23、JDK 24继续在孵化和演进。所以你要在编译和运行两个阶段都把这个孵化模块加上。
Maven配置示例:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <release>24</release> <compilerArgs> <arg>--add-modules</arg> <arg>jdk.incubator.concurrent</arg> </compilerArgs> </configuration> </plugin>Gradle可以这样配:
tasks.withType(JavaCompile).configureEach { options.release = 24 options.compilerArgs += ['--add-modules', 'jdk.incubator.concurrent'] }运行时一定记得带上模块参数,否则直接抛ClassNotFoundException。IDEA里也要给项目的编译器、运行器加上同样的配置,否则写代码一路飘红。如果你同时用StructuredTaskScope,部分JDK版本还要求配合--enable-preview参数,不同版本要求有差异,以你本地JDK版本的官方文档为准。
注意:ScopedValue目前是孵化API,接口细节会随JDK版本演进。生产环境使用前,务必确认你所在JDK版本对应的API行为。
4.3 场景判断:哪些适合换,哪些别硬换
总结一下我实际使用后的判断标准:
| 场景 | ThreadLocal | ScopedValue | 我的建议 |
|---|---|---|---|
| 请求级traceId、用户身份、租户信息 | 能用但容易串 | 天然契合 | 新代码直接用ScopedValue |
| 线程池/异步任务上下文 | 需要各种补丁 | 结构化并发下自动继承 | 配合StructuredTaskScope用 |
| 线程内可变状态(计数器、临时缓存) | set方便 | 不可变,不适合 | 继续用ThreadLocal |
| JDK 22以下的存量项目 | 无奈之选 | 用不了 | 保持ThreadLocal,做好规范 |
| 依赖Spring Security等框架上下文 | 框架深度绑定 | 无法从根替换 | 外层兼容,内部新逻辑用ScopedValue |
注意最后一行。Spring Security的SecurityContextHolder默认就是基于ThreadLocal的,你不可能为了换而换把所有框架都改一遍。实际工程里更常见的做法是:框架层继续用ThreadLocal,但你自己编写的请求上下文、traceId、配置快照这些新代码,直接上ScopedValue。两者可以共存,不冲突。
4.4 避坑清单:这几件事比ThreadLocal时代更容易搞错
结合我踩过的坑和官方文档,列一份清单:
- 作用域外get()会抛NoSuchElementException,不是返回null。很多人第一次用,在没绑定的线程里直接调用
TRACE_ID.get(),被异常吓了一跳。 - 普通new Thread()不会继承ScopedValue绑定。只有StructuredTaskScope.fork创建的结构化子任务才有自动继承,别想当然。
- 不要在业务代码里自行保存和恢复ScopedValue绑定。它本身就是有效期极短的绑定值,设计上就不鼓励你拿着它到处传。
- 需要返回结果就用callWhere,不要在外部定义一个
AtomicReference去接收结果。代码会很丑,还容易在并发场景踩坑。 - 嵌套where注意层级关系。内层绑定结束会自动恢复外层,这个恢复是隐性的;嵌套层数多了以后,保持结构清晰很重要。
给一个callWhere和AtomicReference的直观对比:
// 不要这样写 AtomicReference<String> ref = new AtomicReference<>(); ScopedValue.where(TRACE_ID, traceId).run(() -> ref.set(loadUser())); String user = ref.get(); // 这样写舒服多了 String user = ScopedValue.callWhere(TRACE_ID, traceId, () -> loadUser());最后分享一个我自己的实操体会。相比性能,我更喜欢ScopedValue带来的安心感:以前每次review代码,看到ThreadLocal.set都会下意识问一句“哪里clear了”;现在写ScopedValue,完全没有这个心理负担。如果你的项目还在JDK 22以下,那不用折腾,ThreadLocal搭配统一的finally清理和线程池包装,虽然不优雅但也可控;如果已经上了较新的JDK版本,又是新写的上下文传递代码,直接切ScopedValue。等ScopedValue从孵化转正、生态适配成熟后,ThreadLocal的舞台大概率会进一步收缩。早一步熟悉它的设计思路,迁移时会从容很多。