qinghuan
← 回到知识探索

后端现场 / 可下载小项目

ClassValue 已经缓存了结果,computeValue 为什么还能执行多次?

用两个明确等待信号,把重复计算、唯一安装结果、移除重算和失败重试拆成可以分别验证的四件事。

展开本文目录
  1. 下载完整练习包
  2. 先给计算产物一个能辨认的身份
  3. 等两次计算都进入,再统一放行
  4. 计算了两个,最后却返回同一个
  5. 命中缓存和移除后重算,分别看计数
  6. 第一次抛异常,不等于缓存了一个失败值
  7. 把规范承诺、实现线索和实验结果分开
  8. 运行单文件,逐项对照真实输出
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂类、对象引用的 == 比较、异常和 finally。
  • 知道线程池可以提交任务,Future.get 用来取得任务结果;文中会说明两个等待信号。
  • 先读:反射、注解与动态代理如何连起来,理解按类型复用已验证元数据的场景。
遇到不熟悉的词,先看这 2 个解释
原子安装
多个候选可能已经算出,但选择当前关联值的步骤对竞争者形成一致结果。
受控交错
用明确的等待条件安排线程经过某个观察点,而不是按睡眠时长猜测。
打开完整术语小抄 ↗

你为每种 Java 类型扫描一次方法,想把扫描结果放进 ClassValue。于是有人顺手在 computeValue 中加入“只登记一次”的日志、扣次数或打开资源:既然结果会缓存,这段代码应该也只执行一次吧?

这次实验让两个线程同时第一次查询 String.class。程序会得到两个计算产物,却让两个调用者拿到同一个已安装对象。接着再单独观察缓存命中、remove 和异常,把“计算过”与“最终留下”区分开。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • ClassValueRaceDemo.java:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 ClassValueRaceDemo.java
java -cp . ClassValueRaceDemo

解压到独立练习文件夹,在含 ClassValueRaceDemo.java 的目录打开终端运行。Microsoft OpenJDK17/21各14项检查;受控两线程首次计算、候选与安装引用、finally释放和线程池结束、缓存命中、顺序remove、不同类型、首次异常后重算。未测所有JVM交错、并发remove、性能或类卸载。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先给计算产物一个能辨认的身份

RacingCache 继承 ClassValue<Token>。Token 只保存序号与类型,computeValue 每进入一次就增加 calls,并 new 一个 Token。ConcurrentLinkedQueue 保存所有实际创建过的 Token;它是本实验的观察记录,ClassValue 自身并不需要这条队列。

我们用 == 比较引用,因为问题是两次 get 是否返回同一个对象。两个内容相等的对象也可能是两次创建,仅比较 equals 会漏掉这层差别。序号则帮助区分计算次数;它不决定哪个线程的结果获胜。

已有反射文章提过按类型缓存元数据,这里把这一小点展开。实验使用 String.class 和 Integer.class 作为类型键,并没有扫描反射方法,也不测扫描速度。

等两次计算都进入,再统一放行

entered 的初值是二,每个计算进入后减一;主线程等它归零,才知道两个计算都已经到达观察点。release 的初值是一,两个计算在这里等待,主线程将它减到零后,两者才继续返回自己的 Token。

这两个信号表达具体条件。我们没有 sleep 一段时间后猜测另一个线程大概已经开始。await 和 Future.get 都带超时;如果调度或实现没有出现预期交错,程序会失败并说明观察未建立,不能把超时解释成 ClassValue 在所有 JVM 上都只计算一次。

finally 无论成功、断言失败还是等待异常,都会打开 release、请求线程池停止并等待终止。computeValue 被中断时恢复中断标志再抛出异常,避免把被打断的等待当作成功返回。

计算了两个,最后却返回同一个

race 一行记录 computations=2、distinct_candidates=true 和 same_returned_reference=true。代码分别检查计数器、队列里的不同引用、两个 Future 的返回引用,以及赢家确实来自已创建候选,防止只打印一条预设结论。

这对应 ClassValue.get 的契约:多个竞争线程可能分别完成计算,安装结果的步骤是原子的,竞争者会取得被选中的值。对于当前受控交错,计算次数为二;这个数字是实验的观察,不是所有调用场景必须满足的次数。

落选的 Token 仍留在观察队列里,所以读者能看到它确实被构造过。实际业务若在计算中打开连接、写外部记录或扣额度,不能期待“没有被安装”就自动撤销这些动作。这里 Token 只是不可变数据,不承担外部资源。

命中缓存和移除后重算,分别看计数

线程池结束后,再次 get(String.class) 返回原来的引用,calls 仍为二。随后 remove(String.class),下一次 get 产生新 Token,计数变为三;再读一次仍是这个新引用,计数不再增加。

接着 get(Integer.class),得到属于 Integer.class 的另一个值,计数变为四。这说明同一个 ClassValue 可以为不同类型关联不同结果,不是所有类型共享一个全局 Token。

这一组 remove 在并发任务全部结束后执行,因此只验证顺序移除与重建。API 还规定 remove 与正在计算的 get 竞争时如何丢弃旧结果并重试;文末链接可继续阅读,这个程序没有冒充已经跑过那条复杂路径。

第一次抛异常,不等于缓存了一个失败值

第二个 ClassValue 使用独立 attempts 计数:第一次 computeValue 抛 IllegalArgumentException,第二次返回 Token。程序先确认第一次异常传到调用者,然后确认第二次确实重新进入计算并返回序号二。

第三次 get 复用第二次成功的对象,attempts 保持二。这组实验只证明首次失败没有记成可复用的类值,以及之后成功的结果可以缓存;它没有自动重试循环,也没有规定业务应重试多少次。

因此计算函数适合根据类型构造可重复生成的元数据。若某项业务必须只执行一次,需要为那项业务单独设计与验证协调规则,不能从“返回结果被缓存”推导出“所有副作用恰好发生一次”。

把规范承诺、实现线索和实验结果分开

ClassValue API 给出对调用者的行为约定。固定版本 OpenJDK 17 GA 源码中,getFromHashMap 将取得条目、computeValue 和完成条目安装分开;安装后的结果可能不是当前线程刚计算出的候选,遇到移除竞争也有重试分支。

源码链接用于帮助理解这个实现,实际编译运行采用本机 Microsoft OpenJDK 17.0.20.1 和 21.0.12,不能把所链接的 GA 源码版本说成本机更新版的逐字源码。两套运行结果都支持本文记录的这次交错。

14 项检查覆盖候选身份、安装结果、线程池关闭、后续命中、顺序移除、不同类型和首次异常。没有测性能、垃圾回收或类加载器卸载,也没有检验缓存值内部可变字段的线程安全。

运行单文件,逐项对照真实输出

保存为 ClassValueRaceDemo.java,在源码目录逐行运行下面命令。需要包含 javac 的完整 JDK 17 或更新版本;无需 Maven、网络或第三方依赖。

2026 年 10 月 18 日,已在 Windows 上分别使用 Microsoft OpenJDK 17.0.20.1 与 21.0.12,以 --release 17 在新建临时目录编译运行。下面直接保存两次一致的真实输出;断言失败不会继续打印 PASS。

展开收起代码 · 小实验java · 102 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class ClassValueRaceDemo {
    private static int checks;
    record Token(int number, Class<?> type) {}

    private static void check(boolean ok, String message) {
        if (!ok) throw new AssertionError(message);
        checks++;
    }

    static final class RacingCache extends ClassValue<Token> {
        final AtomicInteger calls = new AtomicInteger();
        final ConcurrentLinkedQueue<Token> made = new ConcurrentLinkedQueue<>();
        final CountDownLatch entered = new CountDownLatch(2);
        final CountDownLatch release = new CountDownLatch(1);

        @Override protected Token computeValue(Class<?> type) {
            int number = calls.incrementAndGet();
            Token token = new Token(number, type);
            made.add(token);
            if (number <= 2) {
                entered.countDown();
                try {
                    if (!release.await(10, TimeUnit.SECONDS))
                        throw new IllegalStateException("compute gate timed out");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new IllegalStateException("compute interrupted", e);
                }
            }
            return token;
        }
    }

    public static void main(String[] args) throws Exception {
        RacingCache cache = new RacingCache();
        ExecutorService pool = Executors.newFixedThreadPool(2);
        Token installed;
        try {
            Future<Token> first = pool.submit(() -> cache.get(String.class));
            Future<Token> second = pool.submit(() -> cache.get(String.class));
            // This controlled OpenJDK experiment needs both computations to enter.
            // A timeout is a failed observation, not a proof about every JVM.
            check(cache.entered.await(5, TimeUnit.SECONDS), "two computations did not enter");
            cache.release.countDown();
            Token a = first.get(5, TimeUnit.SECONDS);
            Token b = second.get(5, TimeUnit.SECONDS);
            List<Token> computed = new ArrayList<>(cache.made);
            check(cache.calls.get() == 2, "controlled race computed twice");
            check(computed.size() == 2 && computed.get(0) != computed.get(1), "two different candidates");
            check(a == b, "racing callers receive the same installed reference");
            check(computed.stream().anyMatch(t -> t == a), "winner is one computed candidate");
            check(a.type() == String.class, "winner belongs to requested class");
            installed = a;
        } finally {
            // Always open the gate, including assertion, timeout and interruption paths.
            cache.release.countDown();
            pool.shutdownNow();
            if (!pool.awaitTermination(5, TimeUnit.SECONDS))
                throw new IllegalStateException("workers did not terminate");
        }
        check(pool.isTerminated(), "executor closed");
        System.out.println("race: computations=2; distinct_candidates=true; same_returned_reference=true; executor_closed=true");

        check(cache.get(String.class) == installed && cache.calls.get() == 2, "later get reuses winner");
        cache.remove(String.class);
        Token refreshed = cache.get(String.class);
        check(refreshed != installed && cache.calls.get() == 3, "remove then get recomputes");
        check(cache.get(String.class) == refreshed && cache.calls.get() == 3, "refreshed value cached");
        Token otherClass = cache.get(Integer.class);
        check(otherClass.type() == Integer.class && otherClass != refreshed && cache.calls.get() == 4,
                "another class has another associated value");
        System.out.println("reuse: before_remove=2; after_remove_get=3; after_other_class_get=4");

        AtomicInteger attempts = new AtomicInteger();
        ClassValue<Token> retry = new ClassValue<>() {
            @Override protected Token computeValue(Class<?> type) {
                int number = attempts.incrementAndGet();
                if (number == 1) throw new IllegalArgumentException("first attempt");
                return new Token(number, type);
            }
        };
        boolean failed = false;
        try { retry.get(String.class); }
        catch (IllegalArgumentException e) { failed = "first attempt".equals(e.getMessage()); }
        check(failed && attempts.get() == 1, "first exception reaches caller");
        Token recovered = retry.get(String.class);
        check(recovered.number() == 2 && attempts.get() == 2, "failure was not cached");
        check(retry.get(String.class) == recovered && attempts.get() == 2, "success then cached");
        System.out.println("failure: first_call=IllegalArgumentException; successful_attempt=2; success_reused=true");
        System.out.println("PASS checks=" + checks + "; controlled_OpenJDK_interleaving_only");
    }
}

怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 ClassValueRaceDemo.java;编译成功后,再执行 java -cp . ClassValueRaceDemo。两条命令分别回车。2026-10-18 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。Microsoft OpenJDK17/21各14项检查;受控两线程首次计算、候选与安装引用、finally释放和线程池结束、缓存命中、顺序remove、不同类型、首次异常后重算。未测所有JVM交错、并发remove、性能或类卸载。

运行后对照结果
race: computations=2; distinct_candidates=true; same_returned_reference=true; executor_closed=true
reuse: before_remove=2; after_remove_get=3; after_other_class_get=4
failure: first_call=IllegalArgumentException; successful_attempt=2; success_reused=true
PASS checks=14; controlled_OpenJDK_interleaving_only

记住这三点

  • ClassValue 可以让多个线程分别计算,再原子地选定一个关联值。
  • 应分别记录计算次数、候选对象与最终返回引用;它们回答不同问题。
  • remove 后可重新计算;抛异常的计算不会被记录为成功类值。

别把例子用过头

  • 把 get 返回同一对象,误认为 computeValue 只进入了一次。
  • 用 sleep 猜测另一个线程已到达,而没有等待具体条件。
  • 把初始化函数中的外部副作用当作一定只执行一次。
  • 把当前 OpenJDK 受控实验的两次计算推广成全部 JVM 的固定次数。

自己试一小步

把同一个类型交给三个查询线程,怎样证明计算产物与最终返回值的关系?

  1. 复制到新文件,调整类名,并同步修改线程池大小、entered 的初始计数,以及 computeValue 中 if (number <= 2) 的门控范围,让三个首次查询都执行 countDown 并在放行处等待。
  2. 运行前分别预测计算次数、候选引用数量、返回引用数量,并给每项写独立断言。
  3. 保留超时、finally 放行和线程池终止;不要通过无限等待保证演示成功。
  4. 在三个查询完成后增加一次 remove 和重新 get,单独核对重新计算及后续缓存命中;后续计数断言应随初始并发数一起调整,不能继续照抄原示例的计数。

做到这些,就算完成

  • 在所测试 JDK 的受控交错中记录三个候选,并证明三个 get 返回同一候选引用;不依赖某个线程一定获胜。
  • remove 前后分别记录计数与引用变化,不能仅看最终计数。
  • 保存实际 JDK 版本、完整输出和失败信息;超时须视为实验未通过。
  • 正常执行后进程能退出,异常路径也能打开等待信号并请求停止工作线程。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • ClassValue:并发计算、安装与移除 (新标签页)↗Oracle Java 官方文档 · 英文

    核对并发竞争、计算异常及 remove 后重新初始化。

    2026-10-18 已实际打开并核读:computeValue、get、remove 的方法详情,含 remove 的状态变化与过期计算示例。只记录相关阅读范围。

  • OpenJDK 17 GA:ClassValue 的计算与安装路径 (新标签页)↗OpenJDK 官方源码 · 英文

    把 API 契约与固定版本的 getFromHashMap、Version、makeEntry 对照。

    2026-10-18 已实际打开并核读:get 快速路径,getFromHashMap 的 startEntry / computeValue / finishEntry 与重试分支,Version 注释和 makeEntry 的共享可见性说明;未通读全部文件。只记录相关阅读范围。

  • CountDownLatch:明确等待条件和超时 (新标签页)↗Oracle Java 官方文档 · 英文

    支持实验的到达计数、放行信号和有界等待。

    2026-10-18 已实际打开并核读:类说明的单次门闩、两个协调信号示例、memory consistency effects、await(long, TimeUnit) 和 countDown。只记录相关阅读范围。