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}

就是使用线程池的时候,经常要把线程绑定的变量手动复制并绑定到任务子线程,任务执行完后还得从子线程上清除掉。这就是 SpringTaskDecorator 经常做的,比如把 slf4jMDC(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 好像解决的不错,可是很多现实问题,比如在方法 ab 经常许多的方法调用,代码跨越了很多类,就不是一个

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) 方法,或各级 RequestFilterdoFilter() 都可以开始一个 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}

CustomRequestFilterRequestContext 同处一个包中,这样在 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

Rustlet 被称作值的绑定,块内部的绑定临时遮盖外部绑定的值,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 ValuesImmutable 只是说一旦向线程绑定了值后,在相同的范围内不能像 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 的事务就是靠 AspectThreadLocal 实现的。

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 能不能支持平台线程,以及传统的线程池。

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