承接的问题
第 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.
把这段话翻译成一张表:
| 模式 | 读方法 | 写方法 | 原子性 | 跨变量顺序 | 典型用途 |
|---|---|---|---|---|---|
| plain | get | set | 引用与 ≤32 位基本类型按位原子;long/double 可能撕裂 | 无 | 单线程或已有外部同步 |
| opaque | getOpaque | setOpaque | 所有类型按位原子 | 只对同一个变量有序 | 进度标记、统计位,不需要发布 |
| acquire/release | getAcquire | setRelease | 所有类型按位原子 | 读之后的访问排在写之前的访问之后 | 发布不可变对象、标志位 |
| volatile | getVolatile | setVolatile | 所有类型按位原子 | 所有 volatile 访问之间有全序 | 与 volatile 字段等价 |
| CAS | compareAndSet、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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。