探索Java 全新的线程模型——作用域值

在虚拟线程和结构化并发之后,作用域值(Scoped Values)是 Project Loom 的第三个交付成果。它旨在取代长期使用的 ThreadLocal 变量,并能更好地与虚拟线程协作。

在本章中,我们将:

  1. 回顾 ThreadLocal 变量到底是什么,以及它们为何存在。
  2. 探讨为什么需要替代 ThreadLocal
  3. 详细了解它们的继任者------作用域值(Scoped Values)。

什么是 ThreadLocal 变量?

ThreadLocal 变量是绑定到某个线程的变量。它的值是唯一的,仅限于特定线程访问。例如,Thread-1Thread-2 都可以访问一个名为 countThreadLocal 变量。每个线程都有自己独立的值,并且只能访问自己的值,无法访问其他线程的值。

由此可见,ThreadLocal 为每个执行线程提供了唯一值,就像一种隐式参数。

举个例子:假设有一个 web 应用,用户需要登录。用户在更新数据时,其用户 ID 会被记录在修改的数据中以便审计。当用户发起请求时,web 服务会从请求参数中获取用户 ID。但若更新数据库的代码需要调用一连串的业务方法,这些方法都需要把用户 ID 作为参数传递。如果后来又需要增加额外参数(如用户评论),所有方法的参数列表会变得非常冗长,导致代码可读性差。

在这种情况下,使用 ThreadLocal 变量会更简单。当请求到达服务器时,会分配一个线程处理该请求(通常来自线程池)。该线程可以创建所需的 ThreadLocal 并赋值,线程内部的所有方法都可以访问这些值,而无需显式传递参数。

ThreadLocal 的工作原理

以下是一个简单的示例:

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class SimpleLocalThreads implements Runnable {
    private static final AtomicInteger value = new AtomicInteger(100);
    private static final ThreadLocal<Integer> COUNTER = new ThreadLocal<>() {
        @Override
        protected Integer initialValue() {
            System.out.println("Getting the next value for a new thread ");
            return value.incrementAndGet();
        }
    };

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " : " + COUNTER.get());
    }

    public static void main(String... args) throws InterruptedException {
        var r = new SimpleLocalThreads();
        for (var i = 0; i < 10; i++) {
            Thread.sleep(200);
            Thread.ofVirtual().name("Thread " + i).start(r);
        }
    }
}
  • 该类实现了 Runnable 接口,重写了 run() 方法,打印当前线程的 ThreadLocal 值。
  • initialValue() 被重写,用一个 AtomicInteger 生成线程特定的初始值,避免并发递增冲突。

运行结果示例:

arduino 复制代码
Getting the next value for a new thread
Thread 0 : 101
Getting the next value for a new thread
Thread 1 : 102
Getting the next value for a new thread
Thread 2 : 103
...

输出显示,每创建一个新线程,ThreadLocalinitialValue() 会被调用,为该线程生成独特值。

ThreadLocal 的内部机制

每个线程都有一个 ThreadLocalMap

ini 复制代码
ThreadLocal.ThreadLocalMap threadLocals;

ThreadLocalMap 是一个定制的 HashMap,键为 ThreadLocal 的哈希码,值为线程对应的变量值。值得注意的是,这个 map 不是由 Thread 维护,而是由 ThreadLocal 维护。

ThreadLocal.set() 方法内部实现如下:

arduino 复制代码
private void set(Thread t, T value) {
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}
  • 尝试获取线程的 ThreadLocalMap,若存在则设置或覆盖值,若不存在则创建新 map。

getMap() 方法返回线程的所有 ThreadLocal 变量:

javascript 复制代码
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

ThreadLocal 的优点

  1. 线程安全:每个线程拥有独立值,无需使用同步机制,提高性能和代码清晰度。
  2. 存储上下文信息:可存储用户会话、凭证、事务、数据库连接等,避免参数传递冗长。
  3. 线程特定配置 :如存储 locale 信息、日志级别,或 SimpleDateFormat 实例(线程不安全,通过每线程独立实例解决)。

ThreadLocal 的缺点

随着虚拟线程的出现,ThreadLocal 的缺点更突出:

1. 可变状态(Mutable State)

ThreadLocal 的值可以随时修改:

bash 复制代码
API_KEY.set("the_secret_api_key");

由于 ThreadLocal 常被定义为 public static,本质上相当于全局变量。任何方法都可能修改它,导致难以追踪和调试。

2. 内存泄漏

平台线程开销大,通常使用线程池复用。若线程执行完毕回到线程池时没有清理 ThreadLocal,变量仍然存在,可能导致内存泄漏。

csharp 复制代码
API_KEY.remove();

可通过 remove() 清理当前线程的 ThreadLocal。也可以在 ThreadPoolExecutor.afterExecute() 中清理。

3. 内存消耗

当线程生成子线程时,子线程默认继承父线程的 ThreadLocal 值。若线程数目巨大(如虚拟线程可达百万级),每个线程复制一份值会迅速消耗大量内存,可能导致 JVM 内存耗尽。

介绍 Scoped Values

在了解了 ThreadLocal 的工作原理以及它可能带来的问题后,接下来我们要探讨替代方案。Project Loom 在虚拟线程和结构化并发之后的第三个交付成果就是 Scoped Values(作用域值) 。本节将介绍 Scoped Values 是什么、如何使用以及它们解决了哪些问题,并进一步探讨 Scoped Values API 的一些方法。

一个简单的 ScopedValue 示例

typescript 复制代码
public class ScopedValueExample {
    public static final ScopedValue<String> USER = ScopedValue.newInstance(); // 1

    public static void main(String... args) {
        new ScopedValueExample().execute();
    }

    Runnable runnable = () -> { // 2
        System.out.println(String.format("Authenticating user %s", // 2
                USER.get())); // 2
    };

    private void execute() {
        ScopedValue.where(USER, "Duke") // 3
                   .run(runnable); // 4
    }
}

几个关键点:

  1. 定义 ScopedValue :在标记为 "1" 的行,我们通过 ScopedValue.newInstance() 创建一个作用域值变量。此处并未设置初始值。
  2. 定义 Runnable :在标记为 "2" 的行,我们定义了一个 lambda 表达式,实现了 Runnable 接口。在其中通过 USER.get() 获取 ScopedValue 的值。
  3. 绑定值 :在标记为 "3" 的行,通过 ScopedValue.where() 方法为 ScopedValue 赋值。
  4. 执行 Runnable :在标记为 "4" 的行,调用 run() 执行 Runnable,绑定的值在 Runnable 及其调用的所有方法中可见。

Scoped Values 的关键在于:赋值只在指定作用域(Runnable 执行上下文)中可见。这就是它们被称为 "Scoped Values" 的原因------变量的存在和可见性被限制在特定作用域内。

支持 Callable

ScopedValues 也支持 Callable 接口:

csharp 复制代码
Callable<String> callable = () -> {
    System.out.println(String.format("Authenticating user %s", USER.get()));
    return USER.get().toLowerCase();
};

private void execute() throws Exception{
    var value = ScopedValue.where(USER, "Duke")
                           .call(callable);
    System.out.println(String.format("Returned value: %s", value));
}
  • 绑定单个 ScopedValue 时可以使用简写:
sql 复制代码
ScopedValue.runWhere(USER, "Duke", runnable);
ScopedValue.callWhere(USER, "Duke", callable);

绑定多个 ScopedValue

虽然 API 设计上鼓励限制 ScopedValue 数量,但仍可绑定多个值:

bash 复制代码
ScopedValue.where(USER, "Duke")
           .where(PWD, "s3cr3t")
           .where(API_KEY, "THE_SECRET_KEY")
           .run(runnable);

运行结果示例:

vbnet 复制代码
Password: s3cr3t
Api Key : THE_SECRET_KEY

未绑定值的处理

直接调用 get() 获取未绑定的 ScopedValue 会抛出 NoSuchElementException,因此需要先检查是否绑定:

less 复制代码
Runnable runnable = () -> {
    System.out.println(String.format("Authenticating user %s", 
        USER.isBound() ? USER.get() : "Unknown"));
};

替代方法:

  1. orElse(T other):返回指定默认值而非抛异常
ini 复制代码
Runnable runnable = () -> {
    System.out.println(String.format("Authenticating user %s", USER.orElse("Unknown")));
};
  1. orElseThrow(Supplier<? extends X> exceptionSupplier):自定义异常
ini 复制代码
Runnable runnable = () -> {
    System.out.println(String.format("Authenticating user %s", USER.orElseThrow(IllegalArgumentException::new)));
};

Scoped Values 总结

ScopedValues 解决了 ThreadLocal 的三个主要问题:

  1. 可变状态 :通过 where() 绑定的值是不可变的。可以在嵌套作用域中重新绑定(rebinding),但原始值保持不变。
  2. 内存泄漏:值在方法执行前绑定,执行后自动解绑,无论方法成功或抛异常,内存都会自动清理。
  3. 值复制问题:值不可变,因此无需复制,直接通过引用传递即可,节省内存和性能开销。

作用域绑定(Scope Binding)

标准绑定

csharp 复制代码
public class ScopedValueBoundExample {
    public static final ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String... args) {
        new ScopedValueBoundExample().execute();
    }

    Runnable runnable = () -> {
        System.out.println("Inside runnable: USER bound=" + USER.isBound()
                + " => " + USER.get()); // 3
        otherMethod(); // 4
    };

    private void otherMethod() {
        System.out.println("Inside otherMethod: USER bound=" + USER.isBound()
                + " => " + USER.get()); // 5
    }

    private void execute() {
        System.out.println("Before: USER bound=" + USER.isBound()); // 1
        ScopedValue.where(USER, "Duke").run(runnable); // 2
        System.out.println("After: USER bound=" + USER.isBound()); // 6
    }
}

输出示例:

ini 复制代码
Before: USER bound=false
Inside runnable: USER bound=true => Duke
Inside otherMethod: USER bound=true => Duke
After: USER bound=false
  • execute() 前,ScopedValue 未绑定。
  • 在 Runnable 执行期间及其调用的方法中,ScopedValue 被绑定。
  • 执行完成后,ScopedValue 自动解绑。

ScopedValue 的重新绑定(Rebinding)

如果希望在嵌套方法中使用不同的值,可在嵌套作用域重新绑定:

kotlin 复制代码
Runnable runnable = () -> {
    System.out.println("Inside runnable: USER bound=" + USER.isBound() + " => " + USER.get());
    ScopedValue.where(USER, "Duchess").run(this::otherMethod);
};
  • Runnable 内的 USER 值为 "Duke"
  • otherMethod 内的 USER 值为 "Duchess"
  • 执行完后,USER 值恢复为 "Duke"

输出示例:

ini 复制代码
Before: USER bound=false
Inside runnable: USER bound=true => Duke
Inside otherMethod: USER bound=true => Duchess
After: USER bound=false

这一机制体现了 嵌套作用域(nested scopes) 的概念。

最后的思考

希望你能看到 ScopedValue 相较于 ThreadLocal 的诸多优势,并在下一个项目中考虑使用它们。在本书撰写时,ScopedValues 仍然是一个 预览特性(preview feature) ,这意味着它们尚未成为 Java 标准语言的一部分。不过,我们预计它将在 Java 23 中正式引入,该版本计划于 2024 年 9 月发布。自上一个预览版以来,ScopedValues API 没有太大变化。

实际上,结构化并发(structured concurrency) 也仍处于预览阶段,并有望在 Java 23 中成为标准特性。尤其是因为从第二个预览版开始,结构化并发默认使用 ScopedValues 作为其机制,这使得它更有可能成为标准。

但你无需担心现有仍在使用 ThreadLocal 的代码和框架。ThreadLocal 在 Java 中不会很快消失,因为仍有大量依赖它们的代码存在。

总结

  • 我们介绍了 ThreadLocal 及其使用场景,并分析了它们的优缺点。
  • 随后我们引入了 ScopedValues,展示了它们如何优雅地解决 ThreadLocal 可能带来的问题,尤其是在与虚拟线程结合使用时。

至此,我们完成了对 Project Loom 三大核心特性 的介绍。

相关推荐
浮游本尊几秒前
Java学习第12天 - Spring Security安全框架与JWT认证
java
Wgllss6 分钟前
完整烟花效果,Compose + 协程 + Flow + Channel 轻松实现
android·架构·android jetpack
David爱编程19 分钟前
happens-before 规则详解:JMM 中的有序性保障
java·后端
小张学习之旅20 分钟前
ConcurrentHashMap
java·后端
程序猿阿伟44 分钟前
《支付回调状态异常的溯源与架构级修复》
后端·架构
熊文豪1 小时前
保姆级Maven安装与配置教程(Windows版)
java·windows·maven·maven安装教程·maven配置教程·maven安装与配置教程
SmalBox1 小时前
【渲染流水线】[逐片元阶段]-[深度写入]以UnityURP为例
架构
怀旧,2 小时前
【C++】 9. vector
java·c++·算法
猿java2 小时前
Elasticsearch有哪几种分页方式?该如何选择?
后端·elasticsearch·架构
渣哥2 小时前
震惊!Java注解背后的实现原理,竟然如此简单又高深!
java