Structured Concurrency与Scoped Values:Java并发模型的范式转变

Java 21正式引入了结构化并发(Structured Concurrency,预览)和Scoped Values(Incubator)。如果你觉得它们只是"又一个并发工具",可能错过了Java在这十年里最深刻的一次并发模型变革。这篇不讲API怎么用------讲它们解决了什么问题,以及为什么说"真正的杀手锏是结构化任务树"。

一、传统并发的"结构性缺失"

先看一段常见代码:

java 复制代码
// 传统的"火抛"模式
Future<Order> orderFuture = es.submit(() -> fetchOrder(orderId));
Future<User> userFuture = es.submit(() -> fetchUser(userId));

// 两个任务各跑各的,互不关心
Order order = orderFuture.get();  // 可能等在这里
User user = userFuture.get();     // 也可能等在这里

这段代码有三个问题,任何一个问题都可能导致另两个无法被取消

  1. 没有生命周期关联------如果fetchOrder抛异常,fetchUser还在后台跑,线程资源泄漏
  2. 没有"整体"的取消------你不能说"取消这整个查询请求",只能一个个cancel
  3. 任务树不透明------在jstack/thread dump里,你看不出fetchUser是属于哪个调用链的
java 复制代码
// 问题1:子任务失控
try {
    Order order = orderFuture.get(5, TimeUnit.SECONDS);
    // 执行到这里时,fetchUser可能还在后台运行
    // 如果fetchOrder超时,fetchUser在"幽灵"执行
} catch (TimeoutException e) {
    userFuture.cancel(true);  // 你得手动取消兄弟任务
    throw e;
}

这就是"结构性缺失"------没有语言层面的机制保证子任务的生命周期和父任务一致。

二、StructuredTaskScope:任务有了"作用域"

java 复制代码
// JDK 21的结构化并发
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<Order> orderFuture = scope.fork(() -> fetchOrder(orderId));
    Future<User> userFuture = scope.fork(() -> fetchUser(userId));
    
    scope.join();               // 等待所有子任务完成
    scope.throwIfFailed();      // 任何一个失败就抛出
    
    return new Response(
        orderFuture.resultNow(),  // 非阻塞获取(已确保完成)
        userFuture.resultNow()
    );
}
// 离开try块时,所有子任务保证已结束

发生了什么

  1. scope.fork()创建的子任务生命周期绑定到scope
  2. scope.join()等待所有子任务完成(或任何一个失败/取消)
  3. 离开try块时,scope的close()会自动处理未完成的子任务------取消它们
  4. ShutdownOnFailure策略:任何一个子任务失败,立即关闭scope,取消其他所有子任务

这就是"结构化"的含义:并发任务的生命周期嵌套在代码块的语法结构中

2.1 取消策略对比

策略 行为 适用场景
ShutdownOnFailure 任何一个子任务失败,取消所有其他 所有调用缺一不可(如微服务编排)
ShutdownOnSuccess 任何一个成功,取消所有其他 只要有一个结果就够(如多路由查询)
自定义策略 继承StructuredTaskScope 复杂的业务编排

2.2 自定义策略示例

java 复制代码
// 自定义:前两个完成就算成功
public class QuorumScope<T> extends StructuredTaskScope<T> {
    private final int quorum;
    private final List<T> results = new CopyOnWriteArrayList<>();
    private volatile boolean completed = false;
    
    public QuorumScope(int quorum) {
        this.quorum = quorum;
    }
    
    @Override
    protected void handleComplete(Future<T> future) {
        // 任务完成时的回调(无论成功/失败)
        if (!completed && future.state() == Future.State.SUCCESS) {
            results.add(future.resultNow());
            if (results.size() >= quorum) {
                completed = true;
                shutdown();  // 达到quorum后关闭scope
            }
        }
    }
    
    public List<T> results() {
        return List.copyOf(results);
    }
}

三、ScopedValues:ThreadLocal的"正确"替代

3.1 ThreadLocal的问题

java 复制代码
// 传统方式
private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();

// 在某个方法中
CTX.set(new RequestContext(userId, traceId));

// 但在虚拟线程场景下
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        // ❌ ThreadLocal的值是null!
        // 虚拟线程不继承父线程的ThreadLocal
        var ctx = CTX.get();
    });
}

虚拟线程池和平台线程池不一样------虚拟线程不绑定平台线程,ThreadLocal的值不会自动传递。

就算不考虑虚拟线程,ThreadLocal也有两个固有问题:

  1. 可变性------任何地方的代码都能修改ThreadLocal,bug极难排查
  2. 内存泄漏------Web容器线程池场景下,ThreadLocal里的对象在线程归还池后残留

3.2 ScopedValues的方案

java 复制代码
// JDK 21
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();

public void handle(Request request) {
    var ctx = new RequestContext(request.getUserId(), generateTraceId());
    
    ScopedValue.where(CTX, ctx)
        .run(() -> {
            // 这里以及所有被调用的方法,都可以读CTX
            process(request);
        });
    // 离开run后,CTX自动还原为之前的值(或null)
}

private void process(Request request) {
    // 读取,不可变
    var ctx = CTX.get();
    ctx.userId();  // ✅ 读
    // ctx.userId = "xxx";  // ❌ 编译错误(RequestContext设计为不可变)
}

3.3 结构化继承

ScopedValue和StructuredTaskScope配合时,才是最强的

java 复制代码
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

public Response handle(Request req) {
    return ScopedValue.where(TRACE_ID, req.getTraceId())
        .call(() -> {
            try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                Future<Order> orderFuture = scope.fork(() -> fetchOrder());
                Future<User> userFuture = scope.fork(() -> fetchUser());
                scope.join();
                scope.throwIfFailed();
                return new Response(orderFuture.resultNow(),
                                    userFuture.resultNow());
            }
        });
}

private Order fetchOrder() {
    // ✅ 子任务自动继承ScopedValue
    // 不需要手动传递,也不怕被修改
    var traceId = TRACE_ID.get();
    return orderService.get(traceId);
}

关键语义

  • ScopedValue是不可变的------一旦绑定,作用域内不能修改
  • 子任务自动继承父任务的ScopedValue绑定
  • 离开作用域后自动恢复之前的值
  • 没有内存泄漏------不需要调remove()

四、Three Musketeers:虚拟线程 + 结构化并发 + ScopedValues

这三者放在一起,才是JDK 21并发模型的完整图景:

复制代码
虚拟线程       → 解决"并发数不够"(千级→百万级)
结构化并发     → 解决"生命周期管理"(子任务随父任务一起结束)
ScopedValues   → 解决"上下文传递"(不可变的请求级上下文,自动继承)

一个完整的例子:

java 复制代码
public class OrderService {
    private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
    
    public CompletableFuture<OrderResult> processOrder(OrderRequest request) {
        return CompletableFuture.supplyAsync(() -> {
            return ScopedValue.where(TRACE_ID, generateTraceId())
                .call(() -> {
                    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                        // 这三个任务在虚拟线程上并发执行
                        var payment = scope.fork(() -> processPayment(request));
                        var inventory = scope.fork(() -> checkInventory(request));
                        var riskCtrl = scope.fork(() -> evaluateRisk(request));
                        
                        scope.join();          // 等所有
                        scope.throwIfFailed(); // 任一失败则全部取消
                        
                        return buildResult(payment.resultNow(),
                                        inventory.resultNow(),
                                        riskCtrl.resultNow());
                    }
                });
        });
    }
}

五、迁移建议

当前做法 迁移方向 优先级
ThreadLocal传递请求上下文 ScopedValues 高(特别是配合虚拟线程时)
CompletableFuture多个thenCombine火抛 StructuredTaskScope 中(逻辑清晰度提升显著)
手动管理线程池+Future.cancel StructuredTaskScope 高(取消语义更难)
继承ThreadPool做父子任务追踪 ScopedValues + StructuredTaskScope 低(已有生态可能更稳)

六、总结

  1. StructuredTaskScope解决的是"子任务泄漏"------代码块结束,所有子任务结束
  2. ScopedValues解决的是"上下文泄漏和修改"------不可变、自动继承、无需清理
  3. 这两者的共同特征是结构化------在代码语法层面保证了并发任务的生命周期与作用域一致
  4. 它们和虚拟线程是互补的------虚拟线程解决数量,结构化解决管理,ScopedValues解决传递
  5. JDK 21中StructuredTaskScope还是预览(--enable-preview),ScopedValues在Incubator