《Java 并发编程·JUC 源码与容器》9.2 内存屏障与 acquire/release

解释 getAcquire/setRelease 的单向屏障语义,用 JCStress 复现一个普通标志位的不安全发布并修好它,再用 Dekker 实验说明 acquire/release 为什么仍弱于 volatile,最后给出 fullFence 等五个屏障方法的适用边界。

承接的问题

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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收