《Java 并发编程·JUC 源码与容器》9.1 VarHandle 的访问模式

从 JDK 25 源码拆解 VarHandle 的五种访问模式:plain、opaque、acquire/release、volatile 与 CAS 各自保证什么,用 JCStress 的 Dekker 实验展示 plain 与 volatile 的差距,并核对句柄获取与类型检查时机,最后落到库存扣减代码。

承接的问题

第 8 章讲 AtomicInteger 和 Striped64 时,已经看到一个尴尬的对照:JDK 25 的 Striped64.Cell 用 VarHandle 做 CAS 和发布,而 AtomicInteger 仍然抱着 jdk.internal.misc.Unsafe。源码里的理由只有一句注释:启动期的循环依赖还没解决。这不代表 AtomicInteger 的语义更弱,而是说明 VarHandle 已经能覆盖 JUC 中绝大多数的原子字段访问需求;哪些还没迁、为什么没迁,9.3 会逐条对照。

问题在于,VarHandle 把「一次字段访问」拆成了很多个名字:get、getOpaque、getAcquire、getVolatile、compareAndSet、getAndAdd……名字越多,越容易随手写一个「看起来能用」的版本,却说不清它到底保证了什么。本节回答三件事:

  • 五种访问模式各自提供哪一层保证,plain 与 volatile 的差距在哪里;
  • weakCompareAndSet 与 compareAndSet 的区别,以及什么时候敢用弱版本;
  • 句柄在什么时候做类型检查,为什么有些错误在启动时就抛,有些要到调用时才抛。

内存模型的基本推导在卷一第 3 章已讲,本节只讨论 VarHandle 如何把这些规则暴露成 API。

源码里的五种模式

java.lang.invoke.VarHandle 是抽象类,公开方法几乎都是 native 的签名多态方法。真正决定语义的是访问模式,类注释把边界写得很清楚:

// JDK 25 源码摘录,已删减:VarHandle 类注释中的访问模式说明
 * <p>Access modes control atomicity and consistency properties.
 * <em>Plain</em> read ({@code get}) and write ({@code set})
 * accesses are guaranteed to be bitwise atomic only for references
 * and for primitive values of at most 32 bits, and impose no observable
 * ordering constraints with respect to threads other than the
 * executing thread. <em>Opaque</em> operations are bitwise atomic and
 * coherently ordered with respect to accesses to the same variable.
 * 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.

把这段话翻译成一张表:

模式读方法写方法原子性跨变量顺序典型用途
plaingetset引用与 ≤32 位基本类型按位原子;long/double 可能撕裂无单线程或已有外部同步
opaquegetOpaquesetOpaque所有类型按位原子只对同一个变量有序进度标记、统计位,不需要发布
acquire/releasegetAcquiresetRelease所有类型按位原子读之后的访问排在写之前的访问之后发布不可变对象、标志位
volatilegetVolatilesetVolatile所有类型按位原子所有 volatile 访问之间有全序与 volatile 字段等价
CAScompareAndSet、compareAndExchange同左读改写原子默认带 volatile 语义计数器、无锁结构

几个容易忽略的点:

  • plain 的「按位原子」只覆盖引用和 32 位以内的基本类型。对一个非 volatile 的 long 字段用 get/set,在 32 位 JVM 或某些实现上仍可能读到半个新值半个旧值。VarHandle 不会替你补上这个保证,要原子就用 opaque 起步。
  • opaque 解决的是「同一个变量不能读到更旧的值」和「不能凭空出现」,但不建立跨变量的 happens-before。用 opaque 标志位去发布另一个字段,读者可能看到标志已更新、数据还是旧的。
  • acquire/release 只有配对时才成立:读者必须真正读到写者 release 的那个值(或更新的值),之前的写才对它可见。读到旧值,什么也不保证。
  • volatile 比 acquire/release 多出来的是「所有 volatile 访问之间的全序」,代价是更强的屏障。9.2 会专门拆这一层。

javap -p java.lang.invoke.VarHandle 可以核对完整方法表:getAndAdd、getAndBitwiseOr、getAndSet 都有对应的 Acquire/Release 变体,CAS 有 compareAndExchange 与四个 weakCompareAndSet*。这些不是语法糖,每个后缀都对应不同的内存序。

五种模式的读写路径

先看一段能直接跑的代码。它在一个 long stock 字段上依次走五种模式,并打印每次读回的值:

// demo.ModesDemo(节选)
static final VarHandle STOCK;

static {
    try {
        STOCK = MethodHandles.lookup()
                .findVarHandle(ModesDemo.class, "stock", long.class);
    } catch (ReflectiveOperationException e) {
        throw new ExceptionInInitializerError(e);
    }
}

long stock;

public static void main(String[] args) {
    ModesDemo demo = new ModesDemo();

    STOCK.set(demo, 10L);
    System.out.println("plain get          -> " + STOCK.get(demo));

    STOCK.setOpaque(demo, 11L);
    System.out.println("opaque getOpaque   -> " + STOCK.getOpaque(demo));

    STOCK.setRelease(demo, 12L);
    System.out.println("acquire getAcquire -> " + STOCK.getAcquire(demo));

    STOCK.setVolatile(demo, 13L);
    System.out.println("volatile getVolatile -> " + STOCK.getVolatile(demo));

    System.out.println("compareAndSet      -> " + STOCK.compareAndSet(demo, 13L, 14L));
    System.out.println("compareAndExchange -> " + STOCK.compareAndExchange(demo, 14L, 15L));
    System.out.println("weakCompareAndSet  -> " + STOCK.weakCompareAndSet(demo, 15L, 16L));
    System.out.println("weakCASPlain       -> " + STOCK.weakCompareAndSetPlain(demo, 16L, 17L));
    System.out.println("getAndAdd(5)       -> " + STOCK.getAndAdd(demo, 5L));
    System.out.println("getAndAddAcquire(1) -> " + STOCK.getAndAddAcquire(demo, 1L));
    System.out.println("getAndSet(20)      -> " + STOCK.getAndSet(demo, 20L));
    System.out.println("final value        -> " + STOCK.getVolatile(demo));
}

本机输出:

plain get          -> 10
opaque getOpaque   -> 11
acquire getAcquire -> 12
volatile getVolatile -> 13
compareAndSet      -> true
compareAndExchange -> 14
weakCompareAndSet  -> true
weakCASPlain       -> true
getAndAdd(5)       -> 17
getAndAddAcquire(1) -> 22
getAndSet(20)      -> 23
final value        -> 20

这段输出只证明调用链通了,不能证明内存序。要证明 plain 和 volatile 的差别,得让两个线程真正竞争。另外,两个弱 CAS 这次都返回 true,但它们允许伪失败,换一次运行或换一台机器可能看到 false,这不是 bug。

Dekker 实验:plain 允许两边都读不到

Dekker 互斥是最短的可见性实验:两个线程各自先写自己的变量,再读对方的变量。若两个线程都读到 0,说明两次写都没有在对方的读之前生效。

// lab.PlainDekkerTest
@JCStressTest
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "both loads missed the other store")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "actor1 missed, actor2 saw")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "actor1 saw, actor2 missed")
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "both saw")
@State
public class PlainDekkerTest {

    int x;
    int y;

    @Actor
    public void actor1(II_Result r) {
        x = 1;
        r.r1 = y;
    }

    @Actor
    public void actor2(II_Result r) {
        y = 1;
        r.r2 = x;
    }
}

命令与结果:

java -cp target/classes:$(cat cp.txt) org.openjdk.jcstress.Main \
  -t lab.PlainDekkerTest -iters 10 -f 1 -v
  RESULT        SAMPLES     FREQ       EXPECT  DESCRIPTION
    0, 0  5,895,080,324   54.17%  Interesting  both loads missed the other store
    0, 1  2,060,537,339   18.93%   Acceptable  actor1 missed, actor2 saw
    1, 0  2,927,613,651   26.90%   Acceptable  actor1 saw, actor2 missed
    1, 1         48,180   <0.01%   Acceptable  both saw

普通字段下,(0, 0) 占了 54.17%。这不是「运气不好」,而是允许的结果:两个线程的 store 与随后的 load 之间没有顺序约束。

把两个字段改成 volatile int,同一份代码、同一台机器:

volatile int x;
volatile int y;
  RESULT        SAMPLES     FREQ      EXPECT  DESCRIPTION
    0, 0              0    0.00%   Forbidden  both loads missed the other store
    0, 1  2,063,959,323   51.53%  Acceptable  actor1 missed, actor2 saw
    1, 0  1,941,521,472   48.47%  Acceptable  actor1 saw, actor2 missed
    1, 1         61,739   <0.01%  Acceptable  both saw

(0, 0) 直接归零。volatile 的全序让「先写 x 再读 y」与「先写 y 再读 x」不可能同时交错成两边都读旧值。这就是 plain 与 volatile 的可见性差距:前者只保证「最终能看到」,后者保证所有 volatile 访问排成一条全局顺序。

weakCompareAndSet 与 compareAndSet

javap 显示两者签名一样,都返回 boolean,默认都带 volatile 语义:

public final native boolean compareAndSet(Object... args);
public final native boolean weakCompareAndSet(Object... args);
public final native boolean weakCompareAndSetPlain(Object... args);
public final native boolean weakCompareAndSetAcquire(Object... args);
public final native boolean weakCompareAndSetRelease(Object... args);

差别只有一条:weakCompareAndSet 允许伪失败。即使当前值等于期望值,它也可以返回 false;compareAndSet 不允许。听起来弱版本更差,但它换来的是实现上的自由度:在 LL/SC 架构上,弱 CAS 可以直接映射成一次 load-linked/store-conditional,而强 CAS 需要在伪失败时循环重试。

所以弱版本只适合本来就写在循环里的场景:

// 计数器:弱 CAS 失败就重读,不区分真失败还是伪失败
public boolean casIncrement(long expected) {
    return VALUE.weakCompareAndSet(this, expected, expected + 1L);
}

public long incrementAndGet() {
    long current;
    do {
        current = (long) VALUE.getVolatile(this);
    } while (!VALUE.weakCompareAndSet(this, current, current + 1L));
    return current + 1L;
}

如果调用方只调用一次、失败就放弃(例如「抢占一次初始化权」),必须用 compareAndSet,否则伪失败会被误判成「别人已经抢到」。

compareAndExchange 的返回值是 witness value:成功时等于期望值,失败时等于当前实际值。它适合「失败后还要知道现在是多少」的循环,可以少一次读。默认 compareAndExchange 带 volatile 语义;compareAndExchangeAcquire 失败时用 acquire 读回 witness、成功时只是普通 set;compareAndExchangeRelease 成功时是 release 写、失败时是普通 get。这两个变体只在明确知道单向顺序够用时才用。

句柄怎么拿,类型错误什么时候抛

四种句柄工厂分工明确:

目标工厂坐标类型
实例字段Lookup.findVarHandle(Class, name, type)接收者类型
静态字段Lookup.findStaticVarHandle(Class, name, type)无
数组元素MethodHandles.arrayElementVarHandle(int[].class)(int[], int)
字节数组视图MethodHandles.byteArrayViewVarHandle(int[].class, order)(byte[], int)

findVarHandle 在创建句柄时检查三件事:字段存在、声明类型与传入的 type 完全一致、当前 Lookup 有访问权限。任何一项不满足都当场抛异常。调用时的接收者类型和值类型则要到调用点才检查,因为签名多态方法需要看调用方的符号类型描述符。

本机把几类错误都试了一遍:

FLAG.set(demo, "x")                -> java.lang.invoke.WrongMethodTypeException
STOCK.get("not-a-demo")            -> java.lang.ClassCastException
wrong field type                   -> java.lang.NoSuchFieldException
missing field                      -> java.lang.NoSuchFieldException
no private access                  -> java.lang.IllegalAccessException
privateLookupIn                    -> no exception

逐条解释:

  • 字段类型写成 int.class 而实际是 long,findVarHandle 抛 NoSuchFieldException,不是 ClassCastException。句柄查找把「类型不匹配」当成「没有这个字段」。
  • 从另一个包用普通 lookup() 拿包私有字段,抛 IllegalAccessException;换成 MethodHandles.privateLookupIn(ModesDemo.class, lookup) 就能拿到。privateLookupIn 要求目标模块把包开放给调用方;classpath 上的未命名模块默认开放,命名模块需要在 module-info 里写 opens。
  • 传错值类型抛 WrongMethodTypeException,传错接收者抛 ClassCastException。前者是方法句柄的类型描述符不匹配,后者是引用转型失败。
  • 默认句柄是「可变型」的:hasInvokeExactBehavior() 返回 false,调用时会做 asType 适配。调用 withInvokeExactBehavior() 后,类型必须逐字匹配,连 int 到 long 的放宽都不允许。

在 libraryd-conc 里落地

库存扣减最适合用 VarHandle 表达「读、判断、CAS」三步。conc-core 里可以这样写:

package com.plume.conc.core;

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public final class StockLevel {

    private static final VarHandle STOCK;

    static {
        try {
            STOCK = MethodHandles.lookup()
                    .findVarHandle(StockLevel.class, "stock", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private int stock;

    public StockLevel(int initial) {
        if (initial < 0) {
            throw new IllegalArgumentException("stock < 0");
        }
        this.stock = initial;
    }

    public boolean tryReserveOne() {
        int current = (int) STOCK.getAcquire(this);
        while (current > 0) {
            int witness = (int) STOCK.compareAndExchangeRelease(this, current, current - 1);
            if (witness == current) {
                return true;
            }
            current = witness;
        }
        return false;
    }

    public void restock(int copies) {
        if (copies <= 0) {
            throw new IllegalArgumentException("copies <= 0");
        }
        STOCK.getAndAdd(this, copies);
    }

    public int snapshot() {
        return (int) STOCK.getVolatile(this);
    }
}

这里每个模式都有理由:

  • 字段本身不是 volatile,所有访问都走句柄;否则等于付两次代价。
  • 初始读用 getAcquire:只需要保证「读到库存后,后续对库存快照的读取不乱序」,不需要全序。
  • 扣减用 compareAndExchangeRelease:成功时把「之前对库存的修改」发布出去;失败时返回 witness,直接拿来做下一轮期望值。
  • 补货用 getAndAdd,默认 volatile 语义,因为补货与扣减之间需要一致的修改顺序。
  • 对外快照用 getVolatile,让调用方看到至少与某次扣减一致的完整值。

这段代码解决的是「单个 int 字段的原子扣减」。跨字段的「扣库存 + 减配额」仍然不是原子的,VarHandle 不会把两个 CAS 合成一个事务;那种场景要么合并成一个聚合对象,要么回到锁或数据库事务。

什么时候不要用

  • 字段已经是 volatile:再用 VarHandle 包一层,语义不会更强,反而多一次句柄调用。要么保留 volatile,要么彻底改成句柄访问,不要混着写。
  • 需要复合原子性:VarHandle 的 CAS 只作用于一个变量。库存、配额、订单状态三个字段要一起变,CAS 帮不上忙。
  • 需要阻塞:VarHandle 没有等待队列,抢不到就返回 false。需要排队、超时、可中断时用锁或同步器。
  • 非 volatile 的 long/double 用 plain 模式:可能撕裂。至少要 opaque,或者确认平台与 JVM 实现。
  • 每次调用都重新 findVarHandle:查找有权限检查和类型解析成本,句柄必须缓存成 static final。
  • 对引用类型调用 getAndAdd:访问模式不支持,调用会抛 UnsupportedOperationException;先用 isAccessModeSupported 判断。

小结

  • VarHandle 把字段访问拆成五种模式:plain 无跨线程顺序,opaque 只保证单变量一致,acquire/release 建立单向发布,volatile 提供全序,CAS 提供原子读改写。
  • JDK 25 的 AtomicInteger 仍用 Unsafe 是启动期依赖问题,不是能力问题;Striped64.Cell 已经用 weakCompareAndSetRelease、setVolatile、getAndSet。
  • 本机 Dekker 实验:plain 字段下 (0, 0) 出现 54.17%,改成 volatile 后为 0。plain 只保证最终可见,不保证跨变量顺序。
  • weakCompareAndSet 允许伪失败,只适合自带重试循环的场景;一次性抢占必须用 compareAndSet。
  • 字段类型和访问权限在 findVarHandle 时检查,接收者与值类型在调用点检查;句柄应缓存为 static final。

阅读导航:上一节 8.3 Striped64 与高并发计数设计 | 下一节 9.2 内存屏障与 acquire/release

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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