起因:虚拟线程把 ThreadLocal 的成本放大了
我们有个订单详情接口,一次请求要串三个下游:用户中心、库存中心、营销中心。最早是同步串行,后来改成线程池并发,再后来迁到虚拟线程承载。
TraceId 一直是用 ThreadLocal 存着的:
public final class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void set(String v) { TRACE_ID.set(v); }
public static String get() { return TRACE_ID.get(); }
public static void clear() { TRACE_ID.remove(); }
}
这套写法在平台线程池时代挺稳的:池里就 200 个线程,最多 200 份副本,入口 set、出口 clear,中间任何一层需要就直接 get。迁到虚拟线程之后情况变了——虚拟线程的生命周期跟着请求走,QPS 2000、链路 200ms 的情况下,同时活着的虚拟线程常态在几百个,压测时上千。每个虚拟线程都要挂一个 ThreadLocalMap,里面装着我们塞进去的 TraceId、TenantId、灰度标记,开销从固定成本变成了随并发线性增长的成本。
除了内存,还有两个老问题一直没根治:
- 漏了
remove就会脏数据串号,尤其是异步分支和异常分支,代码走查很难覆盖全。 InheritableThreadLocal在线程池里基本靠不住,父线程的上下文经常传不到子线程,只能手动往参数里塞。
JDK 25 把 ScopedValue(JEP 506)从预览转正了,我们借这个机会把整套上下文机制换掉了。下面是从设计到落地的完整过程。
ScopedValue 的三条核心语义
先把规则说清楚,不然后面看代码容易想歪。
第一,绑定必须落在一个明确的代码区间里。 用 ScopedValue.where(key, value) 拿到一个 Carrier,然后调 run() 或 call() 把逻辑包进去。代码块一结束,绑定自动失效。没有 remove,也就没有忘记 remove 这回事。
第二,绑定之后值不可变。 作用域内部没有 set 方法,想换成另一个值,只能再嵌一层 where,内层会遮蔽外层。这一点直接消灭了「谁在中间偷偷改了上下文」这类排查噩梦。
第三,读不到就是读不到。 未绑定时调 get() 抛 NoSuchElementException。像日志打印这种「有没有都得能打」的场景,用 orElse() 更合适。
最小可运行的样子长这样:
import java.util.concurrent.ThreadLocalRandom;
public class HelloScopedValue {
static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public static void main(String[] args) {
String traceId = "trace-" + ThreadLocalRandom.current().nextInt(100000, 999999);
String result = ScopedValue.where(TRACE_ID, traceId)
.call(() -> doBusiness());
System.out.println("out of scope: " + TRACE_ID.isBound()); // false
System.out.println("result: " + result);
}
static String doBusiness() {
// 这里不需要任何参数传递,直接读
return "handled by " + TRACE_ID.get();
}
}
注意 ScopedValue 在 java.lang 包下,不用 import。newInstance() 返回的实例通常声明成 static final,一个上下文项对应一个实例。
完整案例:订单聚合接口
下面这份代码可以直接复制成一个文件跑起来,JDK 25 加 --enable-preview 编译执行。它把我们线上那套模型压缩成了一个自包含的 demo。
第一步:定义上下文
public final class Ctx {
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public static final ScopedValue<String> TENANT = ScopedValue.newInstance();
private Ctx() {}
/** 日志场景用 orElse,未绑定时不炸 */
public static String traceId() {
return TRACE_ID.orElse("no-trace");
}
public static String tenant() {
return TENANT.orElse("public");
}
}
对外只暴露只读方法,不提供任何 set 入口。这是整套改造的第一原则:上下文一旦绑定,业务代码就只能看,不能改。
第二步:下游客户端直接读上下文
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class UserClient {
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(1))
.build();
public String fetch(long userId) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://user-center/api/users/" + userId))
// 关键:traceId 不再作为方法参数一层层往下传
.header("X-Trace-Id", Ctx.traceId())
.header("X-Tenant-Id", Ctx.tenant())
.timeout(Duration.ofSeconds(2))
.GET()
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
return resp.body();
}
}
改造前,fetch 的签名是 fetch(long userId, String traceId, String tenant),然后每个中间层都要原样把这些参数带下去。改造后中间层彻底干净了。
第三步:用 StructuredTaskScope 并发聚合
import java.util.List;
import java.util.concurrent.StructuredTaskScope;
public class OrderAggregator {
record User(String name) {}
record Stock(int available) {}
record Coupon(String code, int off) {}
record OrderDetail(User user, Stock stock, List<Coupon> coupons) {}
static User fetchUser(long userId) { sleep(120); log("user-center 返回"); return new User("张三"); }
static Stock fetchStock(long sku) { sleep(90); log("stock-center 返回"); return new Stock(42); }
static List<Coupon> fetchCoupons(long userId) { sleep(150); log("promo-center 返回"); return List.of(new Coupon("SUMMER20", 20)); }
/** 三个下游并发拉取,最慢的那个决定整体耗时 */
public static OrderDetail assemble(long userId, long sku) {
try (var scope = StructuredTaskScope.open()) {
var userTask = scope.fork(() -> fetchUser(userId));
var stockTask = scope.fork(() -> fetchStock(sku));
var couponTask = scope.fork(() -> fetchCoupons(userId));
scope.join(); // 默认策略:任一子任务失败就取消其余
return new OrderDetail(userTask.get(), stockTask.get(), couponTask.get());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("聚合被中断", e);
}
}
static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
static void log(String msg) {
System.out.printf("[%s][%s] %s%n", Ctx.traceId(), Thread.currentThread(), msg);
}
}
StructuredTaskScope 在 JDK 25 里仍然是预览 API(JEP 505 第五次预览),所以编译运行需要加参数:
javac --release 25 --enable-preview -d out src/*.java
java --enable-preview -cp out OrderApp
第四步:入口处绑定一次
import java.util.concurrent.ThreadLocalRandom;
public class OrderApp {
public static void main(String[] args) {
String traceId = "trace-" + ThreadLocalRandom.current().nextInt(100000, 999999);
OrderAggregator.OrderDetail detail =
ScopedValue.where(Ctx.TRACE_ID, traceId)
.where(Ctx.TENANT, "tmall")
.call(() -> OrderAggregator.assemble(1001L, 8899L));
System.out.println(detail);
// 作用域退出后,绑定自动失效
System.out.println("after scope, bound = " + Ctx.TRACE_ID.isBound());
}
}
跑起来会看到三个下游的日志里带着同一个 traceId,线程名是 VirtualThread[#xx],而且退出作用域之后 isBound() 是 false。
整个调用链上,traceId 只在 main 里出现过一次。这就是它相对参数透传最直观的收益。
为什么这里用 call 而不是 run
Carrier 提供了两个方法:run() 不返回结果,call() 返回结果并且允许抛受检异常。看起来只是返回值的有无,实际写起来差别很大。
用 run() 拿到聚合结果,得在外面挂一个容器,或者用 AtomicReference 兜着:
// 别扭的写法,只是为了演示
var holder = new Object[1];
ScopedValue.where(Ctx.TRACE_ID, traceId)
.run(() -> holder[0] = OrderAggregator.assemble(1001L, 8899L));
OrderDetail detail = (OrderDetail) holder[0];
不但难看,还绕过了编译期的类型检查。入口层只要需要返回值,一律用 call()。
哪些地方会翻车
改完之后我们踩了几个坑,按出现频率排一下。
坑一:@Async 和 CompletableFuture 把上下文弄丢了
按 JEP 506 的语义,作用域值只向 StructuredTaskScope 里 fork 出来的子任务传播。通过 @Async、@Scheduled、CompletableFuture.supplyAsync、或者自己 ExecutorService.submit 提交出去的任务,都不在传播范围内,子线程里 get() 会直接抛 NoSuchElementException。
这不是设计缺陷,恰恰是刻意的——ThreadLocal 时代那些莫名其妙「上下文串到别的请求上」的 bug,根源就是继承机制太隐式。代价是每个异步出口你都要显式接管:
// 提交异步任务前,把当前绑定捕获下来,在任务里重新绑定
ScopedValue.Carrier carrier =
ScopedValue.where(Ctx.TRACE_ID, Ctx.traceId())
.where(Ctx.TENANT, Ctx.tenant());
executor.submit(() -> carrier.run(() -> doAsyncWork()));
麻烦是麻烦了点,但好处是:所有跨越线程边界的上下文传递,在代码里都是看得见的。
坑二:SLF4J 的 MDC 不认 ScopedValue
MDC 底层还是 ThreadLocal,日志 pattern 里的 %X{traceId} 不会因为换了 ScopedValue 就自动生效。我们在 Logback 里加了一个自定义转换器:
import ch.qos.logback.classic.pattern.ClassicConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
public class TraceIdConverter extends ClassicConverter {
@Override
public String convert(ILoggingEvent event) {
return Ctx.traceId();
}
}
配置文件里注册一下,pattern 换成 %traceId:
<conversionRule conversionWord="traceId"
converterClass="com.example.log.TraceIdConverter"/>
<pattern>%d{HH:mm:ss.SSS} [%traceId] [%thread] %-5level %logger{36} - %msg%n</pattern>
这一步别忘了,否则日志里全是 no-trace,问题排查时会怀疑人生。
坑三:虚拟线程的线程名是空的
日志 pattern 里的 %thread 在虚拟线程上打出来的默认是空字符串,看起来像日志错乱。在入口处补一句 Thread.currentThread().setName(...) 就够了,比如拼上 traceId 后缀,方便在日志里做聚合。
用 JFR 验证一下内存这块到底省了多少
我们没做严谨的微基准。ScopedValue.get() 的单次开销本来就在纳秒级,纠结这个数量级没什么意义。真正值得看的是堆上的东西,方法也很简单——开 JFR 记录对象实例数:
java -XX:StartFlightRecording=filename=app.jfr,settings=profile
--enable-preview -cp out OrderApp
用 JMC 打开录制文件,看 java.lang.ThreadLocal$ThreadLocalMap$Entry 的实例数量变化。改造前的版本,这个数字基本等于同时活跃的虚拟线程数乘以上下文项数量;改造后是 0。另外在「对象统计」里能直接看到 ThreadLocalMap 及其内部数组从热点条目里消失。
压测侧我们只盯了两个指标:P99 延迟和 Full GC 次数。P99 没有明显变化——本来就是下游耗时主导的;Full GC 频率在高并发下确实降了,具体降多少跟你们堆里有多少其他 ThreadLocal 有关,不太有普适参考价值,这里就不给数字了。建议你们自己在压测环境跑一遍 JFR,看自己服务的真实数字。
从 ThreadLocal 迁到 ScopedValue 的顺序
不建议一次性全量改,容易乱。我们分了四步,每一步都能独立上线。
第一步,先改接口,不改实现。 把 TraceContext 的 set / clear 两个方法删掉,只留 traceId() 和 tenant() 这类只读方法,底层继续用 ThreadLocal 撑着。这一步的目的是把「谁在写上下文」这个问题暴露出来——编译不过的地方,就是所有写入点。
第二步,写入点收口。 把所有写入点收敛到 Filter 或拦截器里唯一的一处,其余地方一律只能读。
第三步,替换底层实现。 把 TraceContext 内部从 ThreadLocal 换成 ScopedValue,同时把收口点从 set 改成 ScopedValue.where(...).call(...)。这一步做完,业务代码一行都不用动。
第四步,处理异步出口。 把 @Async、@Scheduled、CompletableFuture、消息消费者这些出口逐个过一遍,确认每个出口都有显式的上下文接管。
第四步是整个迁移里最花时间的,也是最容易漏的。建议先上监控:在所有 Ctx.traceId() 返回 no-trace 的地方打点,观察一段时间,把打点高频出现的位置作为排查清单。
最后说一个边界
ScopedValue 不是 ThreadLocal 的全量替代品,它的定位很窄:一次请求生命周期内、只读的、需要跨层共享的上下文。
像数据库连接、事务对象这种需要在线程生命周期内反复替换、可变的东西,ScopedValue 做不了,也不该硬套。真正遇到「需要在线程里反复改」的场景,那通常说明这个数据本身就不该用上下文机制来传,改成显式参数更合适。
想清楚这条边界,用起来就不会别扭。

