开始前,会这些就够了
- 能读懂类、对象引用的 == 比较、异常和 finally。
- 知道线程池可以提交任务,
Future.get用来取得任务结果;文中会说明两个等待信号。 - 先读:反射、注解与动态代理如何连起来,理解按类型复用已验证元数据的场景。
你为每种 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。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 的固定次数。
自己试一小步
把同一个类型交给三个查询线程,怎样证明计算产物与最终返回值的关系?
- 复制到新文件,调整类名,并同步修改线程池大小、entered 的初始计数,以及
computeValue中 if (number <= 2) 的门控范围,让三个首次查询都执行countDown并在放行处等待。 - 运行前分别预测计算次数、候选引用数量、返回引用数量,并给每项写独立断言。
- 保留超时、finally 放行和线程池终止;不要通过无限等待保证演示成功。
- 在三个查询完成后增加一次 remove 和重新 get,单独核对重新计算及后续缓存命中;后续计数断言应随初始并发数一起调整,不能继续照抄原示例的计数。
做到这些,就算完成
- 在所测试 JDK 的受控交错中记录三个候选,并证明三个 get 返回同一候选引用;不依赖某个线程一定获胜。
- remove 前后分别记录计数与引用变化,不能仅看最终计数。
- 保存实际 JDK 版本、完整输出和失败信息;超时须视为实验未通过。
- 正常执行后进程能退出,异常路径也能打开等待信号并请求停止工作线程。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- ClassValue:并发计算、安装与移除 (新标签页)↗
核对并发竞争、计算异常及 remove 后重新初始化。
- OpenJDK 17 GA:ClassValue 的计算与安装路径 (新标签页)↗
把 API 契约与固定版本的
getFromHashMap、Version、makeEntry对照。 - CountDownLatch:明确等待条件和超时 (新标签页)↗
支持实验的到达计数、放行信号和有界等待。