Java 25 新特性学习 - Scoped Values

Java 25 是一个 LTS 版本,它的众多新特性中就数 JEP 506: Scoped Values 能改变我们从前使用 ThreadLocal 时的编程范式。因其重要性,所以单列一篇来专门学习它。Scoped Values, 可用来替代 ThreadLocal 的使用,特别是在虚拟线程当中。

Scoped Values 用于在当前线程或子线程中共享不可变的数据,说到不可变的数据就会想到 JEP 502: Stable Values (Preview). ThreadLocal, 甚至 InheritableThreadLocal 用于子线程中共享数据还是有些挑战性,特别碰到线程池的情况, 而且 ThreadLocal 不在乎数据可变还是不可变的,执行当中谁改了数据,不知道。Scoped Values 的 JEP 说其目的不是用来完全替代 ThreadLocal。

下面来看看 Scoped Values 如何在子线程中共享数据,它还能与虚拟线程的结构化并发配合使用。

ThreadLocal/InteritableThreadLocal 仍然是很多框架用来在线程中(间)共享数据的办法,在 Spring 框架中有大量的使用, 如 XxxContextHolder 之类的。目前的 ThreadLocal 存在一些问题

  • ThreadLocal 中的数据是可变的,多数应用场景只要不可变的数据
  • 难以管理共享数据的生命周期,特别是在线程池中,线程被重用时,ThreadLocal 中的数据可能会被意外共享或泄露。由使用方主动清除数据,更是会造成潜在的 Bug
  • 成本高昂,创建子线程,或线程切换时要对共享数据进行复制,VirtualThread 也是继承自 Thread, 所以虚拟线程也能用 ThreadLocal。 一旦虚拟线程的数量达到成千上万,十百万的级别时,ThreadLocal 数据不停复制的代价就很高了

Scroped Values 就是设计来解决以上问题的,减少像 ThreadLocal 的复杂性; 数据为不可变的话,就能更高效的共享,特别是在有巨量的虚拟线程时; 数据在超过共享期后自动清除。

除去成本来说,个人觉得 ThreadLocal 用起来还是很方便的,可以让两个不怎么相关的代码共享数据。比如用 ThreadLocal 时,共享代码的方式为

 1public class Test {
 2    public static void main(String[] args) {
 3        A.foo();
 4    }
 5}
 6
 7class A {
 8    public static void foo() {
 9        Holder.set("value a");
10        B.bar();
11    }
12}
13
14class B {
15    public static void bar() {
16        System.out.println(Holder.get()); // value a
17    }
18}
19
20class Holder {
21    private static final ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "default");
22
23    public static String get() {
24        return threadLocal.get();
25    }
26
27    public static void set(String value) {
28        threadLocal.set(value);
29    }
30}

就是使用线程池的时候,经常要把线程绑定的变量手动复制并绑定到任务子线程,任务执行完后还得从子线程上清除掉。这就是 Spring 的 TaskDecorator 经常做的,比如把 slf4j 的 MDC(Mapped Diagnostic Context) 数据复制到子线程中的做法

 1ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
 2executor.setTaskDecorator(runnable -> {
 3    Map<String, String> mdcContextMap = MDC.getCopyOfContextMap();
 4    if (mdcContextMap == null) {
 5        return runnable;
 6    }
 7    return () -> {
 8        try {
 9            MDC.setContextMap(mdcContextMap);
10            runnable.run();
11        } finally {
12            MDC.clear();
13        }
14    };
15});

倒是想看看 Scoped Values 如何在线程中共享数据。用 Claude 告诉它用 Scoped Value 来重构上面的代码的话,会被改成

 1class A {
 2    public static void foo() {
 3        ScopedValue.where(Holder.VALUE, "value a").run(B::bar);
 4    }
 5}
 6
 7class B {
 8    public static void bar() {
 9        System.out.println(Holder.VALUE.orElse("default")); // value a
10    }
11}
12
13class Holder {
14    static final ScopedValue<String> VALUE = ScopedValue.newInstance();
15}

Test 类不变,用 Scoped Values 关键就在 ScopedValue.where() 和 ScopedValue.run() 上。这两方法的原型为

1    public static <T> Carrier where(ScopedValue<T> key, T value) {
2        return Carrier.of(key, value);
3    }
4    
5    Carrier.run(Runnable op);
6    Carrier.call(CallableOp<? extends R, X> op) throws X;

这种简单问题用 Scoped Value 好像解决的不错,可是很多现实问题,比如在方法 a 与 b 经常许多的方法调用,代码跨越了很多类,就不是一个

1ScopedValue.where(Holder.VALUE, "value a").run(() -> {
2    // method a
3    // method b
4});

能简单解决的。再如果要共享更多的数据,可能会写出 ScropedValue.where() 嵌套出来

1ScopedValue.where(Holder.VALUE1, "value 1").run(()-> {
2    ScopedValue.where(Holder.VALUE2, "value 2").run(() -> {
3        // method a
4        // method b
5        ...
6    });
7});

想要跨线程如何共享数据呢?尝试下面的代码

 1public class Test {
 2    static final ScopedValue<Object> VALUE = ScopedValue.newInstance();
 3
 4    public static void main(String[] args) {
 5        var obj = new Object();
 6        System.out.println(obj);
 7        ScopedValue.where(VALUE, obj).run(()->{
 8            new Thread(()-> {
 9                System.out.println(VALUE.get());
10            }).start();
11        });
12    }
13}

执行出错

1Exception in thread "Thread-0" java.util.NoSuchElementException: ScopedValue not bound
2	at java.base/java.lang.ScopedValue.slowGet(ScopedValue.java:571)
3	at java.base/java.lang.ScopedValue.get(ScopedValue.java:564)
4	at Test.lambda$main$1(Test.java:9)
5	at java.base/java.lang.Thread.run(Thread.java:1474)

如果用 InteritableThreadLocal 是可解决这个问题的。

继续阅读 JEP 506.

对于一个应用框架,如 SpringBoot Web 框架,每个请求都必经一些关口,如 org.springframework.web.servlet.service(request, response) 方法,或各级 RequestFilter 的 doFilter() 都可以开始一个 Scope, 然后自己写的 Controller 方法自然而然的涵盖进了这个 Scope, 也就能访问到其中的共享数据,等 servlet.service() 或 doFilter() 结束后共享数据自动被清除,这样就不想考虑往一个 HTTP 线程放进去的 ThreadLocal 数据会不会污染到另一个 HTTP 请求。

对于我们最容易的使用方式应该是在自定义的 RequestFilter 中, 下面就试着用 Scoped Value 来重构一个 SpringBoot Web 程序的就用场景, 请求进入后,首后获得或生成 requestId 和 userId, 然后在该 HTTP 线程上随时可用, 三个代码来演示

RequestContext

 1package yanbin.blog;
 2
 3public record RequestContext(String requestId, String userId) {
 4    static ScopedValue<RequestContext> holder = ScopedValue.newInstance();
 5
 6    public static String getRequestId() {
 7        return holder.get().requestId;
 8    }
 9
10    public static String getUserId() {
11        return holder.get().userId;
12    }
13}

让 CustomRequestFilter 和 RequestContext 同处一个包中,这样在 CustomRequestFilter 中可以访问到 holder, 其他地方只能调用 RequestContext.getRequestId() 和 RequestContext.getUserId() 来访问共享数据了。

CustomRequestFilter

 1package yanbin.blog;
 2
 3import jakarta.servlet.FilterChain;
 4import jakarta.servlet.http.HttpServletRequest;
 5import jakarta.servlet.http.HttpServletResponse;
 6import org.jspecify.annotations.NonNull;
 7import org.springframework.core.Ordered;
 8import org.springframework.core.annotation.Order;
 9import org.springframework.stereotype.Component;
10import org.springframework.web.filter.OncePerRequestFilter;
11
12import java.util.UUID;
13
14@Component
15@Order(Ordered.HIGHEST_PRECEDENCE)
16public class CustomRequestFilter extends OncePerRequestFilter {
17
18  @Override
19  protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
20                                  @NonNull FilterChain filterChain) {
21
22    ScopedValue.where(RequestContext.holder,
23                    new RequestContext(UUID.randomUUID().toString(), "user-1234"))
24            .run(() -> {
25              try {
26                filterChain.doFilter(request, response);
27              } catch (Exception e) {
28                throw new RuntimeException(e);
29              }
30            });
31  }
32}

TestController

 1package yanbin.blog;
 2
 3import org.springframework.web.bind.annotation.GetMapping;
 4import org.springframework.web.bind.annotation.RestController;
 5
 6@RestController
 7public class TestController {
 8
 9    @GetMapping("/test")
10    public String test() {
11        return "requestId: %s, userId: %s".formatted(
12            RequestContext.getRequestId(), RequestContext.getUserId());
13    }
14}

测试

1curl http://localhost:8080/test
2requestId: bf713edb-1a84-488f-9481-6be54ef9e8f3, userId: user-1234

不用 ThreadLocal 的方式,比如任何时候要在 HTTP 线程上找 Request 的相关数据就不必从 RequestContextHolder.getRequestAttributes() 中翻找。

Scoped Values 像 Rust 那样的绑定

Scopped Values 的值绑定就点像 Rust 的规则一样

1    let name = "value1";
2    println!("{}", name); // value1
3    {
4        let name = "value2"; // shadowing, 遮盖了外部的 name
5        println!("{}", name); // value2
6    }
7    println!("{}", name); // value1

在 Rust 中 let 被称作值的绑定,块内部的绑定临时遮盖外部绑定的值,Java 的 Scoped Values 也有类似的效果

1    ScopedValue.where(VALUE, "value1").run(() -> {
2        System.out.println(VALUE.get());     // value1
3        ScopedValue.where(VALUE, "value2").run(() -> {
4            System.out.println(VALUE.get()); // value2
5        });
6         System.out.println(VALUE.get());    //value1
7    });

Scoped Values 在实现上也确实如其名所表达的那样,在每个 where(X, v).run(()->...) 时就会开启一个 Scope 并在该 Scope 上绑定值, 再来 where(Y, w).run(()->...) 又会往下层创建一个 Scope, 在 Scope 中要使用某个值时则从内往外找,找到即止。

子线程上获取 Scoped Values

InteritableThreadLocal 会让子线程自动继承父线程上绑定的值,但必须父线程上有绑定值后用 new Thread() 创建的线程(包括线程池中新创建的线程) 才能继承父线程上绑定的值。

前面提过这个疑问,那么用 Scoped Values 如何让子线程共享父线程上绑定的数据呢?最好的实现方式是在创建虚拟线程时用结构化并发(Structured Concurrency API(JEP 505)), 使用类 StructuredTaskScope, 父线程上绑定的值会通过 StructuredTaskScope 自动被子虚拟线程继承, 而且这其中还不存在 Scoped Values 的拷贝过程。

代码演示

 1    ScopedValue.where(VALUE, "123").run(() -> {
 2        try (var scope = StructuredTaskScope.open()) {
 3            scope.fork(() -> {
 4                System.out.printf("%s: %s: %s\n", "task1", Thread.currentThread(), VALUE.get());
 5            });
 6            scope.fork(() -> {
 7                System.out.printf("%s: %s: %s\n", "task2", Thread.currentThread(), VALUE.get());
 8            });
 9            scope.join();
10        } catch (InterruptedException e) {
11            throw new RuntimeException(e);
12        }
13    });

StructuredTaskScope 在 Java 25 和 26 中仍处于预览阶段,scope.join() 后绑定的值会被自动清除。StructuredTaskScope 还不能与传统 的线程池(像 FokJoinPool)一起使用,这是一个缺憾,只能用于虚拟子线程。

上面代码的输出结果为

1task1: VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1: 123
2task2: VirtualThread[#30]/runnable@ForkJoinPool-1-worker-2: 123

值是传递到了子虚拟线程去了。

Scoped Values 真的是不可变的吗?

Scoped Values 的 Immutable 只是说一旦向线程绑定了值后,在相同的范围内不能像 ThreadLocal.set(value) 那样可以重新绑定值,只能通过 ScopedValue.where(key, value) 来绑定并生成一个 Scope, 如果绑定的值的内部状态是可变的,执行下方的代码

 1public class Test {
 2    static final ScopedValue<User> VALUE = ScopedValue.newInstance();
 3
 4    public static void main(String[] args) {
 5        User user = new User("name1");
 6        ScopedValue.where(VALUE, user).run(() -> {
 7            User u = VALUE.get();
 8            System.out.println(u.name);     // name1
 9            u.name = "name2";
10            System.out.println(VALUE.get().name);     // name2
11        });
12        System.out.println(user.name);     // name2
13    }
14
15    static class User {
16        public String name;
17
18        public User(String name) {
19            this.name = name;
20        }
21    }
22}

输出值分别为

name1
name2
name2

迁移到 Scoped Values

在我们使用 ThreadLocal 时应首先考虑一下是否可以用 Scoped Values 实现。Scoped Values 能自动检测递归,可用于嵌套事物当中, 比如当前已存在一个事物则当前操作自动加入到该事物当中,Spring 的事务就是靠 Aspect 和 ThreadLocal 实现的。

Scoped Values 在一个范围中绑定多个值可以把多个 where() 串联起来,如

1    ScopedValue<String> VALUE1 = ScopedValue.newInstance();
2    ScopedValue<String> VALUE2 = ScopedValue.newInstance();
3    ScopedValue.where(VALUE1, "111").where(VALUE2, "222").run(() -> {
4        System.out.println(VALUE1.get());     // 111
5        System.out.println(VALUE2.get());     // 222
6    });

以后还要继续关注 structured concurrency 的发展,不知到后面 Scoped Values 能不能支持平台线程,以及传统的线程池。

由于 Scoped Values 尚不支持平台线程和传统的线程池,在现有代码使用了 ThreadLocal 的情况下(如 SLF4J MDC), 有时候必须手动的在 Scoped Values 和 ThreadLocal 之间拷贝数据。

永久链接 https://yanbin.blog/java-25-new-features-scoped-values/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

Posts in this series