qinghuan
← 回到知识探索

后端现场 / 可下载小项目

乐观读已经验证成功,为什么接着读还是会拼出错配数据?

让真实写线程在两次读取之间经过,观察 StampedLock 的验证位置、局部值与读锁回退。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先看确定的错配:旧 x=10,新 y=60
  4. stamp 用来验证一段读取,不是提前挡住写入
  5. 把 validate 放在读取之前,检查的是另一段时间
  6. 读后验证成功,也不等于结果永久最新
  7. 运行完整程序:真实写线程,两套 JDK 各重复三次
  8. 四行结果分别回答四个问题
  9. 只有全部相关写入遵守约定,验证才有意义
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
乐观读
先读一小段数据,再检查这段读取有没有被写锁穿插。
验证区间
stamp 与末尾验证共同界定本次检查所覆盖的时间段。
打开完整术语小抄 ↗

假设两个字段必须满足 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

把验证放在读取之后,才能决定这对值是否可用

正在准备实验控件…

第 1/7 步:准备两个共享字段
共享值是 10、20;完整写入都保持 y=2x。局部变量还没有读取。

此刻共享字段
x=10,y=20
已保存的局部字段
x=未读,y=未读
最近一次验证记录
还未验证
已采用的结果
尚未采用任何一对值
查看整条轨迹
  1. 准备两个共享字段
  2. 取得乐观读凭据
  3. 读取 x=10 到局部变量
  4. 写方完成更新:30、60
  5. 读取 y=60 到局部变量
  6. 末尾验证失败,拒绝这对候选
  7. 取得读锁,两个字段都重新读取

固定轨迹图解,不启动 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 秒等待只为在协调异常时失败退出,不用来制造或衡量读写竞态。

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

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

小实验 · java
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 成功后才开始读取共享字段。
  • 验证失败时只补读一个字段,继续混用旧候选。
  • 在验证之前根据候选值产生不可撤回的业务效果。
  • 把可变对象引用当成已经复制的内容。

自己试一小步

如果一致性规则扩成三个字段,你能用可控时序证明整个读取结果来自一次可接受的状态吗?

  1. 自定三个字段之间的一条一致性规则,并写出至少两组合法值。
  2. 在各个字段的读取间安排受同一写锁保护的更新,先画出预计交错表。
  3. 比较提前验证、末尾验证、验证失败后读锁重读三条路径。
  4. 记录候选值、验证结果和最终发布值;再说明如果存在绕过写锁的写入口,实验前提会发生什么变化。

做到这些,就算完成

  • 不用 sleep 的快慢代替确定的同步条件。
  • 失败的候选值没有继续充当发布结果。
  • 把单次受控实验与所有并发执行的证明分开。

查原始资料

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

  • Java SE 17 StampedLock:乐观读与 validate (新标签页)↗Oracle Java 官方文档 · 英文

    先读局部变量再验证、验证失败时的边界、零 stamp 和显式读写锁。

    2026-10-23 已实际搜索并打开正文,核读Optimistic Reading、Memory Synchronization、示例、tryOptimisticRead 与 validate 正文;未声称通读整页或所有实现。

  • Java SE 17 CountDownLatch:内存一致性 (新标签页)↗Oracle Java 官方文档 · 英文

    实验通过 countDown 与成功返回的 await 确立写入结束之后再读取的顺序。

    2026-10-23 已实际搜索并打开正文,核读类说明与 Memory consistency effects;未声称通读整页或所有实现。

  • Java SE 17 Lock:显式释放惯例 (新标签页)↗Oracle Java 官方文档 · 英文

    显式持锁代码应建立 try/finally 释放路径;StampedLock 的具体接口以它自身文档为准。

    2026-10-23 已实际搜索并打开正文,核读显式锁与 try/finally 使用说明;未声称通读整页或所有实现。