登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java Stable Values 怎么替代双重检查锁

来源:17golang原创

时间:2026-10-05 02:52:07 155浏览 收藏

Java 25 的 Stable Values 可以替代一类典型的双重检查锁:对象需要延迟创建、并发线程只能得到同一个成功初始化的实例,而且初始化完成后不允许再替换。原来由 volatile、synchronized 和两次判空共同保证的语义,可以收敛到 StableValue.supplier(...) 或 StableValue.orElseSet(...)。

但它不是所有锁的通用替代品。StableValue 在 Java 25 中仍是预览 API,值设置后不能重置;需要热更新、主动失效或反复修改的状态仍应使用其他并发工具。

OpenJDK 提案:https://openjdk.org/jeps/502

Java 25 API:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/StableValue.html

核心要点
  • StableValue 保存一个最多成功设置一次的值。
  • StableValue.supplier 提供线程安全的缓存 Supplier,适合替代静态延迟初始化里的双重检查锁。
  • orElseSet 在并发竞争下保证底层 Supplier 成功执行至多一次。
  • Supplier 抛出异常时不会记录内容,后续调用仍可重试。

为什么双重检查锁容易写成维护负担

假设一个服务启动时不一定用到远程客户端,所以团队把客户端延迟到第一次请求再创建。常见写法是双重检查锁:

public final class ClientRegistry {
    private static volatile ApiClient client;

    public static ApiClient client() {
        ApiClient current = client;
        if (current == null) {
            synchronized (ClientRegistry.class) {
                current = client;
                if (current == null) {
                    // 第二次判空确保只有进入临界区的首个线程创建实例。
                    current = new ApiClient(loadConfig());
                    client = current;
                }
            }
        }
        // 局部变量避免成功初始化后重复读取 volatile 字段。
        return current;
    }

    private static Config loadConfig() {
        // 示例只表达昂贵配置加载,真实项目应处理读取异常。
        return new Config("https://service.internal");
    }
}

这段代码并非错误,但正确性分散在多个细节里:字段必须是 volatile,锁内必须再次判空,构造完成后才能发布引用,维护者还要理解局部变量为何存在。一次看似无害的重构,例如移除 volatile 或第二次检查,都可能破坏并发语义。

Stable Values 的设计动机,是把“晚一点初始化”与“初始化后像 final 一样稳定”放进标准 API。调用者只表达如何创建值,JDK 负责一次性存储、并发协调与可见性。

从双重检查锁改成 StableValue.supplier

如果调用方只需要一个 Supplier,最直接的改写是 StableValue.supplier:

import java.util.function.Supplier;

public final class ClientRegistry {
    private static final Supplier CLIENT =
            StableValue.supplier(ClientRegistry::createClient);

    public static ApiClient client() {
        // 第一次 get 延迟创建,后续 get 返回同一个成功记录的实例。
        return CLIENT.get();
    }

    private static ApiClient createClient() {
        // 初始化逻辑只在首次成功计算时执行一次。
        return new ApiClient(new Config("https://service.internal"));
    }
}

字段仍然声明为 static final,但它保存的是稳定 Supplier,而不是提前创建的客户端。第一次调用 get() 才会运行底层 Supplier。多个线程同时到达时,竞争线程会等待计算完成,然后观察到同一个已记录值,不需要业务代码自己维护 volatile 和同步块。

Java 双重检查锁与 StableValue supplier 静态依赖关系说明图
图1:双重检查锁把并发初始化规则散落在字段和方法中,StableValue.supplier 将其收进稳定值契约。这是静态说明图,不是运行截图。

实例字段可以直接使用 StableValue.orElseSet

若类内部需要观察“尚未设置”和“已经设置”两种状态,可以保留 StableValue holder,并在访问器中调用 orElseSet。官方建议通常把它作为私有字段,不直接暴露给调用方。

public final class DocumentService {
    private final Config config;
    private final StableValue parser = StableValue.of();

    public DocumentService(Config config) {
        this.config = config;
    }

    public Parser parser() {
        // 未设置时原子计算并保存;已设置时直接返回已有 Parser。
        return parser.orElseSet(() -> new Parser(config));
    }

    public boolean parserReady() {
        // isSet 只查看 holder 状态,不触发初始化。
        return parser.isSet();
    }
}

StableValue.of() 创建未设置的 holder。orElseSet 成功返回时,内容一定已经设置;成功写入与之后的成功读取之间存在 happens-before 关系,因此读取线程能看到初始化完成的对象状态。

并发语义与异常边界

Stable Values 替代双重检查锁时,最重要的不只是代码变短,而是初始化规则发生了明确约束:

场景Stable Value 行为迁移时要确认
多个线程同时首次访问最多一个成功的 Supplier 计算被记录,其他线程等待并读取该值初始化允许竞争线程暂时阻塞
Supplier 正常返回值被永久记录,之后不能替换对象生命周期确实是一次性初始化
Supplier 抛出异常异常返回给当前调用者,不记录内容后续调用可能再次执行初始化副作用
需要清空或重新加载不支持重置已设置内容改用原子引用、缓存或显式生命周期组件

“成功至多一次”并不等于“尝试至多一次”。如果初始化先写入外部系统再抛异常,下次调用会重新尝试,副作用可能重复。迁移前应让 Supplier 尽量幂等,或者把不可重复操作放到有事务或去重保证的组件中。

Java StableValue 并发调用一次性存储和异常重试静态关系说明图
图2:Stable Value 只记录首次成功计算的值,失败不落盘,后续调用仍可重试。这是静态结构图,不是运行结果。

旧代码的影响与采用边界

适合迁移的代码通常同时满足四个条件:对象创建成本较高、确实需要延迟初始化、成功后引用不再变化、所有并发调用者应该共享同一个结果。数据库元数据、解析器、配置派生对象和客户端工厂都可能符合这种模式。

以下情况不建议直接替换:

  • 需要刷新:令牌、动态配置、可失效缓存和连接状态需要更新,不能放进只能设置一次的 holder。
  • 只要静态单例:初始化按需持有者模式已经简单、成熟且不依赖预览 API,没有必要为了新 API 强行迁移。
  • 必须跨旧 JDK 运行:StableValue 从 Java 25 开始提供,旧运行时无法加载相关字节码。
  • 公共 API 要长期稳定:预览 API 的签名和形态可能在后续版本变化,最好封装在私有实现层。

Stable Values 还允许 JVM 把已设置的稳定值视作可优化的常量候选,但不应仅凭这个能力承诺业务性能提升。迁移的首要收益是更清晰的并发语义和更少的手写同步代码。

启用预览 API 并做最小验证

Java 25 使用 Stable Values 时,编译和运行都要启用预览特性:

# 使用 JDK 25 编译,并显式启用预览 API。
javac --release 25 --enable-preview StableValueDemo.java

# 运行阶段同样必须启用预览特性。
java --enable-preview StableValueDemo

下面的最小程序让多个虚拟线程同时访问同一个稳定 Supplier,并统计构造次数与对象身份。它用于验证迁移后的关键契约,不代表性能基准。

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import java.util.stream.IntStream;

public class StableValueDemo {
    private static final AtomicInteger BUILDS = new AtomicInteger();

    private static final Supplier CLIENT = StableValue.supplier(() -> {
        // 记录初始化函数被成功执行的次数。
        BUILDS.incrementAndGet();
        return new Client();
    });

    public static void main(String[] args) throws Exception {
        Set identities = ConcurrentHashMap.newKeySet();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var tasks = IntStream.range(0, 100)
                    .mapToObj(i -> (Runnable) () -> {
                        // 并发读取稳定 Supplier,并收集对象身份。
                        identities.add(System.identityHashCode(CLIENT.get()));
                    })
                    .toList();

            for (Runnable task : tasks) {
                executor.submit(task);
            }
        }

        // 预期两个数字都为 1:一个实例身份,一次成功构造。
        System.out.println("实例种类数: " + identities.size());
        System.out.println("成功构造数: " + BUILDS.get());
    }

    static final class Client {
        // 示例对象不含业务逻辑,只用于观察延迟初始化身份。
    }
}

预期输出是“实例种类数: 1”和“成功构造数: 1”。真实项目还应增加一个失败 Supplier 测试,确认异常后是否允许重试,以及重复副作用是否已经得到控制。

迁移建议

  1. 先确认旧双重检查锁只承担一次性延迟初始化,不承担刷新、销毁或版本切换。
  2. 静态访问优先改为私有 static final Supplier,实例访问按需使用私有 StableValue。
  3. 把构造逻辑放进可单独测试的 Supplier,并明确失败是否允许重试。
  4. 在构建、测试、启动脚本里同时加入 Java 25 的 --enable-preview。
  5. 不要把 StableValue holder 直接暴露为公共 API,为后续预览变更保留替换空间。

如果这些条件都满足,Stable Values 能把双重检查锁从“每个团队都要重新证明正确”的代码模式,变成由平台提供的一次性延迟初始化契约。对尚未准备采用预览 API 的生产项目,保留现有正确实现或使用初始化按需持有者模式仍然是合理选择。

相关问题

StableValue.supplier 和 orElseSet 有什么区别?

supplier 返回普通 Supplier,调用方只需 get();orElseSet 直接操作 holder,适合类内部还要使用 isSet 等状态方法的场景。

Supplier 抛异常后会缓存异常吗?

不会。异常会抛给当前调用者,内容不被记录,之后的调用可以再次尝试计算。

StableValue 可以替代 volatile 吗?

只能替代“一次成功设置后永不变化”这一类用法。持续变化的共享状态仍需要 volatile、原子类、锁或其他同步方案。

Java 25 不启用预览能使用吗?

不能。编译和运行包含 Stable Values 的代码时都必须启用预览特性。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>