开始前,会这些就够了
- 能阅读 Java 线程、try
/finally与基础锁调用 - 知道一次业务状态可能由多个字段共同组成
- 先读 两人都改成功,为什么一人的更新不见了?:先认识并发中的版本检查,再区分数据库更新检查与本篇进程内读取验证。
假设两个字段必须满足 y=2*x。写线程每次都在写锁内同时更新它们,读线程却仍可能先看到旧 x,再看到新 y。
StampedLock 的乐观读不会拦住写线程。你要先把候选值读进局部变量,再验证这段读取是否被写锁穿插;一个发生得太早的 true 不能担保未来的读取。
把例子带到自己的电脑上
下载完整练习包
需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- Main.java:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main解压后,在含 Main.java 的目录打开终端。命令分别回车执行。真实 StampedLock 与写线程、CountDownLatch 控制顺序;每 JDK 重复3次,每次16检查。未测吞吐、公平性、复杂对象图或所有并发交错。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
把验证放在读取之后,才能决定这对值是否可用
正在准备实验控件…
固定轨迹图解,不启动 Java 线程、不模拟 Java 内存模型,也不凭浏览器结果证明锁的性能。真实程序用同一把 StampedLock 保护全部写入,并用 CountDownLatch、join 控制实验顺序;这些同步本身也带来内存可见性保证。
先看确定的错配:旧 x=10,新 y=60
共享 Pair 初始是 x=10、y=20。主线程取得乐观读 stamp 并先读 x;随后启动真实写线程,在 writeLock 内把 Pair 改成 30、60。
写线程通过 CountDownLatch 通知已经写完,主线程等到通知后再读 y。于是两个局部候选值确定为 10、60,它们并不属于同一次完整更新。这里没有靠 sleep 估算时序。
接下来 validate(stamp) 返回 false。程序没有把这个候选对当作业务结果,而是改用 readLock,把两个字段都重新读取,最终接收 30、60。
stamp 用来验证一段读取,不是提前挡住写入
tryOptimisticRead 在没有线程持有写锁时可以给出非零 stamp。这个 stamp 并不占有普通读锁,因此我们的写线程仍可完成更新;读者必须考虑这段时间里内容会变化。
validate 检查的是自该 stamp 发出以来是否获取过写锁;它不是逐字段比较数据,也不是承诺以后再也不会有写入。验证失败时,应放弃这次候选值。
安全的简单形状是:取得 stamp,读取短小、可容忍暂时不一致的字段到局部变量,validate,然后才决定使用候选值或回退。验证前不要据这些值扣款、发送消息或调用不能容忍不一致状态的方法。
乐观读的内存可见性保证也有条件:前一次写锁释放之前的操作,与 tryOptimisticRead 之后的操作之间,只有在后续 validate 成功时才获得文档规定的 happens-before 保证;validate 不会为它之后的任意读取继续加保护。
本实验另外使用了 CountDownLatch 和线程 join 建立受控时序,因此它证明的是读取顺序与验证结果的关系,不能单独隔离证明 StampedLock 的内存屏障效果。
把 validate 放在读取之前,检查的是另一段时间
第三组故意写错顺序:先 validate 得到 true,再读取 x=40;让写线程更新为 50、100 后,再读取 y=100。早先保存的 true 仍然是 true,但后来的候选对已是 40、100。
对同一个 stamp 再做一次末尾验证,会得到 false。这个对照说明 true 必须和它实际覆盖的读取区间绑定,不能被保存成一个“后续访问都安全”的许可证。
教学程序打印这对被拒绝的候选值,是为了观察错误形成过程。生产路径应只发布已经验证通过或在读锁下重新取得的结果,不应把调试打印的便利搬成外部业务副作用。
读后验证成功,也不等于结果永久最新
第二组先读入 30、60,再 validate 成功,形成可使用的局部候选。随后写线程把共享字段改成 40、80,局部 int 变量仍为 30、60。
这是一次被接受的读取结果,不能被宣传为“返回时一定是最新值”或“调用者处理期间永远不变”。一份读取快照和把写操作一直挡在外面是两种不同需求。
如果字段保存的是可变对象引用,仅把引用放进局部变量,并不会复制那个对象的内部状态。本例特意只用两个 int;不要把观察结果扩大成任意对象图的深复制或长期一致性保证。
运行完整程序:真实写线程,两套 JDK 各重复三次
保存唯一的 Main.java 后即可编译运行。每次写入都持有同一个 Pair 的写锁,并在 finally 中释放;验证失败后的读取也在 finally 中释放读锁。
2026-10-23,Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12,分别以 --release 17 编译;每个版本运行三次,每次 16 项检查,完整 stdout 一致。5 秒等待只为在协调异常时失败退出,不用来制造或衡量读写竞态。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.StampedLock;
public final class Main {
private static int checks;
private static final class Pair {
final StampedLock lock = new StampedLock();
int x = 10;
int y = 20;
}
private static void check(boolean ok, String message) {
checks++;
if (!ok) throw new AssertionError(message);
}
private static void await(CountDownLatch latch) throws InterruptedException {
if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Coordination timed out");
}
private static void writeOnWorker(Pair pair, int nextX) throws Exception {
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Thread worker = new Thread(() -> {
try {
await(start);
long stamp = pair.lock.writeLock();
try {
pair.x = nextX;
pair.y = 2 * nextX;
} finally {
pair.lock.unlockWrite(stamp);
}
} catch (Throwable failure) {
error.set(failure);
} finally {
done.countDown();
}
}, "controlled-writer");
worker.start();
start.countDown();
try {
await(done);
} finally {
worker.join(5000);
if (worker.isAlive()) {
worker.interrupt();
throw new AssertionError("Worker did not finish");
}
}
if (error.get() != null) throw new AssertionError(error.get());
}
private static int[] readLocked(Pair pair) {
long stamp = pair.lock.readLock();
try {
return new int[]{pair.x, pair.y};
} finally {
pair.lock.unlockRead(stamp);
}
}
public static void main(String[] args) throws Exception {
Pair pair = new Pair();
long stamp = pair.lock.tryOptimisticRead();
check(stamp != 0L, "optimistic read starts while no writer holds the lock");
int oldX = pair.x;
writeOnWorker(pair, 30);
int newY = pair.y;
boolean valid = pair.lock.validate(stamp);
check(oldX == 10 && newY == 60, "controlled interleaving produces a mixed candidate");
check(!valid, "write lock invalidates the earlier stamp");
check(newY != 2 * oldX, "candidate is not a consistent pair");
int[] accepted = valid ? new int[]{oldX, newY} : readLocked(pair);
check(accepted[0] == 30 && accepted[1] == 60, "fallback rereads every field under read lock");
System.out.println("interleaved: candidate=" + oldX + "," + newY
+ "; valid=" + valid + "; accepted=" + accepted[0] + "," + accepted[1]);
stamp = pair.lock.tryOptimisticRead();
int localX = pair.x;
int localY = pair.y;
boolean acceptedBeforeLaterWrite = pair.lock.validate(stamp);
check(acceptedBeforeLaterWrite, "read-then-validate succeeds without an intervening writer");
check(localY == 2 * localX, "accepted local values form a pair");
writeOnWorker(pair, 40);
check(localX == 30 && localY == 60, "later write cannot change captured primitive locals");
check(!pair.lock.validate(stamp), "old stamp is invalid after a subsequent writer");
System.out.println("accepted-snapshot: valid-at-read=" + acceptedBeforeLaterWrite
+ "; locals=" + localX + "," + localY + "; current=" + pair.x + "," + pair.y);
stamp = pair.lock.tryOptimisticRead();
boolean checkedTooEarly = pair.lock.validate(stamp);
int earlyX = pair.x;
writeOnWorker(pair, 50);
int lateY = pair.y;
check(checkedTooEarly, "validation before reading can return true");
check(earlyX == 40 && lateY == 100 && lateY != 2 * earlyX, "later reads are not covered by early check");
check(!pair.lock.validate(stamp), "a final validation rejects the later mixed reads");
System.out.println("check-before-read: early-valid=" + checkedTooEarly
+ "; later-candidate=" + earlyX + "," + lateY
+ "; final-valid=" + pair.lock.validate(stamp));
long writeStamp = pair.lock.writeLock();
long unavailable;
try {
unavailable = pair.lock.tryOptimisticRead();
check(unavailable == 0L, "writer-held lock yields zero optimistic stamp");
check(!pair.lock.validate(unavailable), "zero stamp is invalid");
} finally {
pair.lock.unlockWrite(writeStamp);
}
check(!pair.lock.isWriteLocked(), "writer lock was released");
int[] finalPair = readLocked(pair);
check(finalPair[1] == 2 * finalPair[0], "final locked read is consistent");
System.out.println("writer-held: optimistic-stamp=" + unavailable + "; valid=false");
System.out.println("PASS " + checks);
}
}
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。真实 StampedLock 与写线程、CountDownLatch 控制顺序;每 JDK 重复3次,每次16检查。未测吞吐、公平性、复杂对象图或所有并发交错。
运行后对照结果
interleaved: candidate=10,60; valid=false; accepted=30,60
accepted-snapshot: valid-at-read=true; locals=30,60; current=40,80
check-before-read: early-valid=true; later-candidate=40,100; final-valid=false
writer-held: optimistic-stamp=0; valid=false
PASS 16
四行结果分别回答四个问题
interleaved 一行说明:确实读到错配候选,末尾验证将其拒绝,读锁回退重新得到一致结果。CountDownLatch 保证我们在写入结束后读新 y,因此无需把一个偶然观察当成规范结论。
accepted-snapshot 一行说明:一次验证已接受的局部值不会被后来对共享字段的写入自动替换。check-before-read 则说明:提前的 true 没有覆盖后续那次错配读取。
最后主线程在持有写锁时调用 tryOptimisticRead,得到 0,validate(0) 为 false。这个分支同样不能把 0 当作普通可用 stamp 继续采用读出的值。
只有全部相关写入遵守约定,验证才有意义
本例所有字段写入都受同一个 StampedLock 的写锁保护。若别处绕过这把锁直接写字段,validate 并不能替应用发现那条绕行路径;首先应核对数据的所有写入口。
StampedLock 并不直接实现 Lock 接口,也不是可重入锁。本文借用 Lock 文档说明显式加锁应有明确释放路径;真正使用的 stamp 与释放方法仍以 StampedLock 自己的约定为准。
已验证的是两个 int、受控真实线程、读后验证与读锁回退。没有证明高并发吞吐量、公平性、无饥饿、不同处理器上的所有交错或复杂容器遍历安全,也没有建议仅凭本例把既有锁全部替换。
记住这三点
- 乐观读允许写线程穿插;候选值要先读进局部变量,再验证。
- 验证失败要放弃候选值,回退时重新读取全部相关字段。
- 提前的
true不能覆盖未来读取;有效快照也不意味着永久最新。
别把例子用过头
- 在 validate 成功后才开始读取共享字段。
- 验证失败时只补读一个字段,继续混用旧候选。
- 在验证之前根据候选值产生不可撤回的业务效果。
- 把可变对象引用当成已经复制的内容。
自己试一小步
如果一致性规则扩成三个字段,你能用可控时序证明整个读取结果来自一次可接受的状态吗?
- 自定三个字段之间的一条一致性规则,并写出至少两组合法值。
- 在各个字段的读取间安排受同一写锁保护的更新,先画出预计交错表。
- 比较提前验证、末尾验证、验证失败后读锁重读三条路径。
- 记录候选值、验证结果和最终发布值;再说明如果存在绕过写锁的写入口,实验前提会发生什么变化。
做到这些,就算完成
- 不用 sleep 的快慢代替确定的同步条件。
- 失败的候选值没有继续充当发布结果。
- 把单次受控实验与所有并发执行的证明分开。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java SE 17 StampedLock:乐观读与 validate (新标签页)↗
先读局部变量再验证、验证失败时的边界、零 stamp 和显式读写锁。
- Java SE 17 CountDownLatch:内存一致性 (新标签页)↗
实验通过
countDown与成功返回的 await 确立写入结束之后再读取的顺序。 - Java SE 17 Lock:显式释放惯例 (新标签页)↗
显式持锁代码应建立 try
/finally释放路径;StampedLock的具体接口以它自身文档为准。