承接的问题
9.1 把 VarHandle 的访问模式排成了一张表,acquire/release 那一行只写了「读之后的访问排在写之前的访问之后」。这句话太短,容易让人以为它和 volatile 差不多,于是把发布标志从 volatile 换成 setRelease/getAcquire,以为只是「更轻量的写法」。
真实情况要分两半:
- 在「一个线程写数据、另一个线程读数据」的发布模式里,release/acquire 足够,而且比 volatile 精确:写者只需要把之前的写排到标志之前,读者只需要把之后的读排到标志之后,两边都不需要全局顺序。
- 在需要「所有线程对多个变量的操作排成一条全局顺序」的场景里,release/acquire 不够。9.1 的 Dekker 实验已经证明 plain 会两边都读 0;本节会证明 acquire/release 仍然会。
卷一第 3 章讲过 happens-before 的推导,这里不再重复,只把 VarHandle 暴露的屏障原语逐个对上。
单向屏障:release 只管之前,acquire 只管之后
VarHandle 类注释对 acquire/release 的定义是精确的:
// JDK 25 源码摘录,已删减:VarHandle 类注释中的 acquire/release 定义
* In addition to obeying Opaque properties, <em>Acquire</em> mode
* reads and their subsequent accesses are ordered after matching
* <em>Release</em> mode writes and their previous accesses. In
* addition to obeying Acquire and Release properties, all
* <em>Volatile</em> operations are totally ordered with respect to
* each other.
拆开看:
| 操作 | 约束的方向 | 不约束的方向 |
|---|---|---|
setRelease(v) | 该方法之前的读写,不能排到这次写之后 | 该方法之后的读写,可以随意排到它前面 |
getAcquire(v) | 该方法之后的读写,不能排到这次读之前 | 该方法之前的读写,可以随意排到它后面 |
setVolatile(v) | 前后都约束,且所有 volatile 访问有全序 | 无 |
getVolatile(v) | 前后都约束,且所有 volatile 访问有全序 | 无 |
「匹配」两个字是关键:release 写和 acquire 读只有通过同一个变量、并且读者读到了写者写入的值(或更新值),才建立 happens-before。读者读到旧值,写者之前的数据对它没有任何可见性保证。
一个最小的发布器(句柄声明见后面的落地代码):
// demo.SnapshotPublisherDemo(节选)
private Snapshot snapshot;
public void publish(String isbn, int stock, long version) {
SNAPSHOT.setRelease(this, new Snapshot(isbn, stock, version));
}
public Snapshot current() {
return (Snapshot) SNAPSHOT.getAcquire(this);
}
Snapshot 是不可变 record,构造完成后不会再改。写者把引用 release 出去,读者 acquire 到引用后,读到的一定是构造完成的对象。这里不需要 volatile 的全序:两个读者之间、两个写者之间都不需要互相排序。
修一个真实的不安全发布
先看错误版本:data 和 ready 都是普通字段,写者先写数据再写标志,读者看到标志后读数据。
// lab.PlainPublicationTest
@JCStressTest
@Outcome(id = "-1", expect = Expect.ACCEPTABLE, desc = "flag not observed")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "flag observed, data missing")
@Outcome(id = "1", expect = Expect.ACCEPTABLE, desc = "flag and data observed")
@State
public class PlainPublicationTest {
int data;
int ready;
@Actor
public void writer() {
data = 1;
ready = 1;
}
@Actor
public void reader(I_Result r) {
if (ready == 1) {
r.r1 = data;
} else {
r.r1 = -1;
}
}
}
RESULT SAMPLES FREQ EXPECT DESCRIPTION
-1 8,967,242,810 49.12% Acceptable flag not observed
0 779,068 <0.01% Interesting flag observed, data missing
1 9,288,108,816 50.88% Acceptable flag and data observed
0 出现了 779 068 次。比例很小,但性质很严重:读者明明看到了 ready == 1,读到的 data 还是 0。普通字段之间没有顺序,JIT 可以把 data = 1 挪到 ready = 1 之后,硬件也可以让另一个核心先看到标志。
修复只需要把标志的写改成 release、读改成 acquire,数据字段保持普通:
// lab.AcquireReleasePublicationTest
private static final VarHandle READY;
static {
try {
READY = MethodHandles.lookup()
.findVarHandle(AcquireReleasePublicationTest.class, "ready", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
int data;
int ready;
@Actor
public void writer() {
data = 1;
READY.setRelease(this, 1);
}
@Actor
public void reader(I_Result r) {
if ((int) READY.getAcquire(this) == 1) {
r.r1 = data;
} else {
r.r1 = -1;
}
}
RESULT SAMPLES FREQ EXPECT DESCRIPTION
-1 4,187,905,076 55.35% Acceptable flag not observed
0 0 0.00% Forbidden flag observed, data missing
1 3,378,402,898 44.65% Acceptable flag and data observed
0 被标成 Forbidden 且样本数为 0,JCStress 没有报失败。注意这里修的不是「把标志变原子」——int 的写本来就是原子的;修的是顺序:release 把 data = 1 钉在标志写之前,acquire 把 data 的读钉在标志读之后。
这也解释了一个常见误判:有人把 ready 改成 volatile,测试也通过了,于是认为「volatile 更安全」。在单向发布里两者都对,但 volatile 多买了一层你不需要的全序。
acquire/release 不是 volatile:Dekker 反例
把 9.1 的 Dekker 实验改用 setRelease/getAcquire:
// lab.AcquireReleaseDekkerTest
@Actor
public void actor1(II_Result r) {
X.setRelease(this, 1);
r.r1 = (int) Y.getAcquire(this);
}
@Actor
public void actor2(II_Result r) {
Y.setRelease(this, 1);
r.r2 = (int) X.getAcquire(this);
}
RESULT SAMPLES FREQ EXPECT DESCRIPTION
0, 0 7,960,672 0.18% Interesting both loads missed the other store
0, 1 2,322,479,695 51.22% Acceptable actor1 missed, actor2 saw
1, 0 2,203,816,317 48.60% Acceptable actor1 saw, actor2 missed
1, 1 120,410 <0.01% Acceptable both saw
(0, 0) 仍然出现,占比 0.18%。而 9.1 里同样的代码把字段换成 volatile 后,(0, 0) 是 0。
原因不复杂:release 只约束「本次写之前的访问」,acquire 只约束「本次读之后的访问」。在 actor1 里,X.setRelease 与紧随其后的 Y.getAcquire 之间没有约束——release 管不到它后面的读,acquire 管不到它前面的写。两个线程各自把「写自己的变量」和「读对方的变量」重排成「先读后写」,就能互相读到 0。
所以选型边界是:
- 发布/消费:
setRelease+getAcquire足够,也更精确。 - 互斥、握手、需要「至少一方能看到对方」的推理:必须用
volatile或显式fullFence。 - 不要因为「release/acquire 更快」就把所有
volatile机械替换掉;先问自己需不需要全序。
屏障方法:什么时候需要显式 fence
VarHandle 还提供五个静态屏障方法,javap 可以看到它们都是 public static void:
public static void fullFence();
public static void acquireFence();
public static void releaseFence();
public static void loadLoadFence();
public static void storeStoreFence();
源码注释给出的约束:
| 方法 | 约束 |
|---|---|
fullFence() | 之前的读写不能与之后的读写重排,等价于 memory_order_seq_cst 栅栏 |
acquireFence() | 之前的读不能与之后的读、写重排 |
releaseFence() | 之前的读、写不能与之后的写重排 |
loadLoadFence() | 之前的读不能与之后的读重排 |
storeStoreFence() | 之前的写不能与之后的写重排 |
什么时候用 fence 而不是句柄?当被排序的那次访问无法或不想通过 VarHandle 表达时。例如标志位是数组里的一个普通元素,或者一段代码要同时保护多个普通字段:
// 用 releaseFence 把两个普通写钉在标志写之前
data = 1;
auditTrail = 2;
VarHandle.releaseFence();
ready = 1;
// 读者侧:先读普通标志,再用 acquireFence 把后续读钉在后面
if (ready == 1) {
VarHandle.acquireFence();
int seen = data;
}
这段代码的语义与 setRelease/getAcquire 等价,只是把「顺序」从某一次访问上剥离出来,单独放在两条语句之间。fullFence() 则用于 Dekker 这类需要双向约束的场景:在两个普通字段的写和读之间各插一个 fullFence(),(0, 0) 就会被禁止。
代价要说清楚:fence 是全局指令,不会因为「只保护两个字段」而变便宜。能用一次 acquire/release 访问表达的顺序,不要用 fullFence 兜底;fullFence 只留给真正需要全序的握手协议。
在 libraryd-conc 里落地
库存快照的发布与读取,正好是 release/acquire 的典型场景。conc-core 可以放一个只读快照发布器:
package com.plume.conc.core;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
public final class StockSnapshotPublisher {
public record Snapshot(String isbn, int stock, long version) {
}
private static final VarHandle SNAPSHOT;
static {
try {
SNAPSHOT = MethodHandles.lookup()
.findVarHandle(StockSnapshotPublisher.class, "snapshot", Snapshot.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private Snapshot snapshot;
public void publish(String isbn, int stock, long version) {
SNAPSHOT.setRelease(this, new Snapshot(isbn, stock, version));
}
public Snapshot current() {
return (Snapshot) SNAPSHOT.getAcquire(this);
}
}
本机运行一个读者自旋等待、写者连续发布五版的例子:
reader observed stock = 10
latest snapshot = Snapshot[isbn=978-7-111-0001, stock=50, version=5]
读者看到的 stock 一定来自某个完整构造的 Snapshot,不会出现「对象已分配、字段还没写完」的中间态。原因有两层:引用赋值本身是原子的,release 又保证构造中的写对 acquire 读者可见。
如果业务要求「版本号必须严格递增、读者不能看到回退」,仅靠 release/acquire 不够:读者可能读到较旧的快照,因为快照引用只有一个变量,读到的就是某个时刻的值;要判断新旧,得在 Snapshot.version 上做比较,或者让发布路径串行化。release/acquire 解决的是可见性,不解决「读到哪个版本」的语义。
这个设计的代价
- 单向屏障只对配对生效:读者没读到 release 的值,之前的数据一律不保证。用
getAcquire读一个可能为 0 的标志,再无条件读数据,等于没加屏障。 - acquire/release 不提供全序:Dekker 实验里
(0, 0)仍有 0.18%。需要「至少一方看到对方」的算法必须用 volatile 或 fullFence。 - fence 不能替代原子性:
releaseFence()只排序,不保证被排序的写是原子的;普通long仍然可能撕裂。 - 发布的对象必须真正不可变:
Snapshot的字段都是 final 或 record 组件,构造后不再改。如果发布后还能改内部字段,acquire 只保证「当时的引用可见」,不保证后续修改。 - 不要用 release/acquire 保护复合状态:两个字段要一起更新时,各自 release 不能合成一个原子事务。
小结
setRelease把之前的读写钉在写之前,getAcquire把之后的读写钉在读之后;两者通过同一个变量配对,才建立 happens-before。- 不安全发布实验:plain 标志下「看到标志、数据仍为 0」出现 779 068 次;改成
setRelease/getAcquire后该结果被禁止,样本为 0。 - acquire/release 不是 volatile:Dekker 实验里
(0, 0)仍有 7 960 672 次(0.18%),而 volatile 版本是 0。全序是 volatile 独有的。 fullFence、acquireFence、releaseFence、loadLoadFence、storeStoreFence用于无法用句柄表达顺序的场合;能用一次 release/acquire 访问表达时,不要用 fullFence 兜底。- 发布模式可以用 release/acquire 替代 volatile;互斥与握手协议不行。
阅读导航:上一节 9.1 VarHandle 的访问模式 | 下一节 9.3 用 VarHandle 替换 Unsafe
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。