StampedLock 乐观读要先复制再验证:用指定交错检查一致性
- 发布时间:2026-10-23 23:15:56
- 本文热度:浏览 1 赞 0 评论 0
- 文章标签: Java StampedLock 并发
- 全文共1字,阅读约需1分钟
这两个结果来自同一个坐标对象:安全读返回 (15,30),把 validate 放在前面的读却返回 (15,40)。写入方法每次都维护 y=2*x,没有任何一次合法状态是 (15,40)。问题发生在读者拼接了两次状态,先验证成功并不能保护后续读取。
StampedLock 的乐观读不是一把已经拿到的读锁。tryOptimisticRead 给出一个可供验证的票据,期间写线程仍然可以进入。正确顺序是先把相关字段复制到局部变量,再验证票据;通过以后,才能把局部副本当成一致的一次观察使用。失败就丢掉那组候选值,在真正读锁内重新读取。
让冲突发生在指定位置
很多并发演示靠两条线程随机跑几万次,偶尔发现一次坏值。这里用两个门闩精确安排交错,避免把“这次没看到问题”当作正确性证明。测试钩子只存在于最小程序,用来暂停读线程;正常 snapshot 不会执行外部业务回调。
程序使用 Java 17,没有第三方依赖。保存为 OptimisticPoint.java 并执行 java OptimisticPoint.java。一共观察三种读取:没有写入、复制之后发生写入、验证之后才开始交错读取。
import java.util.concurrent.*;
import java.util.concurrent.locks.StampedLock;
public class OptimisticPoint {
record Snapshot(int x, int y, boolean fallback) {}
static final class Point {
final StampedLock lock = new StampedLock();
private int x = 10, y = 20;
void move(int delta) {
long stamp = lock.writeLock();
try {
int nextX = Math.addExact(x, delta);
int nextY = Math.multiplyExact(nextX, 2);
x = nextX;
y = nextY;
} finally { lock.unlockWrite(stamp); }
}
Snapshot snapshot() { return snapshot(() -> {}); }
Snapshot snapshot(Runnable afterCopy) {
long stamp = lock.tryOptimisticRead();
int localX = x;
int localY = y;
afterCopy.run(); // Test seam; production snapshot uses the empty hook.
boolean fallback = !lock.validate(stamp);
if (fallback) {
stamp = lock.readLock();
try { localX = x; localY = y; }
finally { lock.unlockRead(stamp); }
}
return new Snapshot(localX, localY, fallback);
}
Snapshot wrongValidateFirst(Runnable betweenFields) {
long stamp = lock.tryOptimisticRead();
if (!lock.validate(stamp)) throw new IllegalStateException("test setup");
int localX = x;
betweenFields.run();
return new Snapshot(localX, y, false); // Intentionally wrong.
}
}
static void await(CountDownLatch latch) {
try {
if (!latch.await(3, TimeUnit.SECONDS)) throw new IllegalStateException("test coordination timeout");
} catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); }
}
public static void main(String[] args) throws Exception {
Point p = new Point();
System.out.println("quiet=" + p.snapshot());
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
CountDownLatch copied = new CountDownLatch(1), changed = new CountDownLatch(1);
Future<Snapshot> read = worker.submit(() -> p.snapshot(() -> { copied.countDown(); await(changed); }));
await(copied);
p.move(5);
changed.countDown();
Snapshot safe = read.get(3, TimeUnit.SECONDS);
System.out.println("interleaved=" + safe);
if (!safe.fallback() || safe.y() != safe.x() * 2) throw new AssertionError("safe snapshot");
CountDownLatch firstField = new CountDownLatch(1), moved = new CountDownLatch(1);
Future<Snapshot> wrong = worker.submit(() -> p.wrongValidateFirst(() -> { firstField.countDown(); await(moved); }));
await(firstField);
p.move(5);
moved.countDown();
Snapshot mixed = wrong.get(3, TimeUnit.SECONDS);
System.out.println("validated-too-early=" + mixed);
System.out.println("mixed=" + (mixed.y() != mixed.x() * 2));
long write = p.lock.writeLock();
try { System.out.println("reentrant-write=" + (p.lock.tryWriteLock() != 0)); }
finally { p.lock.unlockWrite(write); }
} finally {
worker.shutdownNow();
if (!worker.awaitTermination(3, TimeUnit.SECONDS)) throw new IllegalStateException("worker did not stop");
}
}
}
quiet=Snapshot[x=10, y=20, fallback=false]
interleaved=Snapshot[x=15, y=30, fallback=true]
validated-too-early=Snapshot[x=15, y=40, fallback=false]
mixed=true
reentrant-write=false
第一条 quiet 在没有写者干扰时完成,fallback 为 false。第二条先让读者复制旧坐标,再让主线程移动对象,票据因此失效;读者走读锁分支,得到新坐标 (15,30)。程序不要求“乐观读必须拿到最新值”,这里只要求返回值属于验证支持的一致观察。
第三条故意犯错:先 validate,再读 x,暂停,让写者把状态变成 (20,40),最后读 y。读者于是拿到旧 x 和新 y。wrongValidateFirst 只用于展示失败,不是可以放进业务工具箱的另一种实现。
票据验证的对象是读取区间
将 validate 移到前面,看起来像是先检查锁可用再访问,实际上改变了它要证明的区间。它只能说明从获得票据到此次验证之间,没有破坏这个乐观读条件的写操作;它不会为验证之后的任意动作颁发通行证。
在正确分支里,验证之后只使用 localX 和 localY。若 validate 成功以后又访问 p.x 或跟随另一个可变引用读取新字段,新的读取就不在此前那次验证覆盖的候选副本里。重构时把一段计算挪进 getter,尤其容易不经意再次回到共享状态。
票据为零也不能按成功处理。写锁已被持有时,tryOptimisticRead 可能返回零,validate 会失败,代码自然进入有读锁的重读路径。本例不靠循环忙试取得一个非零值,也不把读失败转换成某个默认坐标。
StampedLock 的文档对乐观读取有专门的内存语义约束。使用它的前提是写入都遵守同一把锁的协议,并在字段复制后成功验证。不能把这个技巧拆成一个与锁无关的“先读普通字段,再随便检查一个版本号”,也不能夹杂一条不加锁的写入后宣称整体仍由票据保证。
候选值可能不一致,验证前不要做不可撤回动作
示例先复制两个 int,不在验证前计算并发布结果。读取更复杂的数据时,写者可能正在改变引用和索引之间的关系;候选下标短暂越界、候选引用为空,都可能让你在走到 validate 前就抛异常。文档并没有承诺未验证的读取天然可安全做任何计算。
因此乐观区应尽量短,只复制可以安全读取的必要值。涉及数组结构、链表遍历或彼此依赖的对象图,应仔细证明中间态是否可访问;证明不清楚时,直接使用读锁更容易维护。不要为了在代码里出现“乐观”两个字,把本来明确的锁协议变成难以审核的引用追踪。
同样不要在候选读取期间发送消息、写文件或修改其他对象。验证失败意味着这份候选观察不能被采用,但先前副作用不会自动撤回。程序的门闩钩子仅为测试调度服务,不能照搬成真实读取回调接口;实际业务的回调还可能再次获取锁、持有时间无法控制。
Point.move 在写锁内先算完两个新值,再赋给字段。这样算术溢出不会留下只更新 x 的状态。锁保证互斥,不会替多步写入提供异常回滚;即使读端使用正确票据,写端若在异常路径破坏不变量,读者仍可能正常验证并接受坏状态。
读锁路径不是可重入锁的替代品
程序最后在线程持有写锁时再次尝试 writeLock,立即返回失败。这里使用 tryWriteLock,所以实验不会永久挂住;把它换成无超时的阻塞写锁,就可能让线程等自己释放一个永远到不了的锁。
StampedLock 不按传统的线程持有计数提供可重入能力。已有写操作调用另一段“为了稳妥再加一次锁”的公共方法,可能制造这种自锁。内部方法应明确要求由调用方持有哪类票据,或在结构上把已加锁与未加锁的入口分开,而不是随处重复加锁。
票据也不能与其他锁实例互换使用。读写解锁都要匹配取得的模式与有效票据,并在 finally 中释放。转换方法返回的新票据要重新保存,转换失败则按失败协议处理;旧票据不会因为变量名字还叫 stamp 就自动具有新模式。
验证失败也不等于两个局部值一定错误。写者可能取得写锁后又把原值写回,或者只改了与本次结果无关的受保护状态;票据仍可能失效。读者按协议重读即可,不应为了省掉一次重读,再拿候选值去猜“这次修改应该没有影响”。那会把锁的明确合同换成一份随业务字段增长而失效的例外清单。
反过来,验证成功不代表返回时对象还保持那组值。验证之后写者可以立即修改共享对象,但局部快照仍然是一份有效观察。需要让后续判断与修改构成同一个原子动作时,仅返回快照不足以实现;必须在相应锁或版本更新协议里重新核对条件,不能把一次成功乐观读当作长期预订。
什么时候值得保留这段复杂度
如果读取很短、状态容易安全复制、写入干扰不多,乐观读可以让许多读取避开真正的读锁路径。反过来,复制对象图很昂贵、验证经常失败、读取本来就要调用外部组件时,这个实现可能比普通读锁更难解释。这里没有根据三条输出给出吞吐结论,输出只能证明指定交错下的协议行为。
实际评估应分别观察验证失败比例、重读成本与写者等待,并使用目标负载。如果每次失败都复制一份大对象再丢掉,少拿一次读锁未必抵消额外工作;若整体状态已经能作为不可变版本发布,也可以直接读取一个版本引用,避免逐字段乐观观察。
测试中所有门闩和Future都带有限等待,线程池在 finally 中中断并等待终止。超时属于实验协调失败,不能当作锁正确性的证据;它的作用是让代码改坏以后尽快暴露,而不是把挂住的演示留在后台。保留 safe 与 mixed 两个断言,能在以后调整字段读取位置时确认真正受保护的是哪一组副本。