2332 字
约 7 分钟
15
S05-A22 · `VarHandle` 与 `Unsafe`:什么时候值得离开"安全 API"
2024-12-31
2026-11-10

一段"无锁队列"的实现用了 sun.misc.Unsafe 的 compareAndSwapInt,跑得很好——直到 JDK 17 上编译报"该包不可导出",而团队用 --add-exports 强行开放。三个月后,另一个模块在 JDK 21 上遇到 Unsafe 的字段偏移量 API 行为变化。

Unsafe 是"JDK 内部使用的工具",而 VarHandle(JDK 9+)是它的"公开替代"。两者都能做"原子操作与内存序",这个差别的"现实后果"很具体:Unsafe 需要 --add-exports 才能在 JDK 17+ 编过,而"加参数"这件事在"打包成库给别人用"时会传给下游——于是"库的使用者"也要加参数。
而它们的差别在"可移植性"与"是否会被 JDK 内部变化影响"。


1. 四种能力与它们的现代替代

Unsafe 能力 替代
CAS(compareAndSwapX) VarHandle.compareAndSet
普通读写(getXVolatile) VarHandle 的 getVolatile
字段偏移(objectFieldOffset) VarHandle 由 lookup 得到
堆外内存(allocateMemory) ByteBuffer/Arena(JDK 21+)

第三项是"用好 VarHandle 的关键":偏移量不再由你算,而由 MethodHandles.lookup().findVarHandle(...) 给出第三项的"额外收益"是"类型检查在查找时发生":findVarHandle(Counter.class, "count", int.class) 若类型不符会立刻抛错,而 Unsafe 的偏移量算错只是"改到别的字段"。
——于是"字段布局变化"不会让你的代码错位。

第四项的"关键变化"是"生命周期被显式化":Arena 是 AutoCloseable,于是"堆外内存"能进 try-with-resources——而 Unsafe.allocateMemory 需要你记得 freeMemory。
第四项的现代写法是 MemorySegment + Arena(Foreign Function & Memory API):它把"堆外内存"纳入"资源管理"(Arena 关闭时释放)。

// VarHandle 的标准写法(三步)
private static final VarHandle COUNT;
static {
    try {
        COUNT = MethodHandles.lookup().findVarHandle(Counter.class, "count", int.class);
    } catch (ReflectiveOperationException e) {
        throw new ExceptionInInitializerError(e);
    }
}
void inc() { COUNT.getAndAdd(this, 1); }          // 原子自增,无需偏移量

2. 三种"内存序"的语义

模式 语义
plain(普通) 无保证(可重排)
opaque 保证"不撕裂"(单次读写的原子性)
acquire/release 单向屏障(配对使用)
volatile 全屏障(最强)

第四项最"安全"也最慢:它在读后、写前都插入屏障,于是"禁止重排"。第四项的"代价"在"高频写"上最明显:每次写都要"刷到缓存一致",于是在"多核争用同一字段"时吞吐下降——这也是"分段计数(LongAdder)"存在的原因。

第三项的"便宜"来自"单向屏障":release 只保证"之前的写不外泄",acquire 只保证"之后的读不提前"——比"双向全屏障"少一半约束。
第三项是"性能与正确性的折中":release 写 + acquire 读能保证"发布对象"安全,而比 volatile 便宜。

// acquire/release 的正确配对(发布一个不可变对象)
static final VarHandle DATA;
void publish(Config c) {
    DATA.setRelease(this, c);          // 之前的所有写,对"读到 c 的线程"可见
}
Config read() {
    return (Config) DATA.getAcquire(this);   // 之后的读,能看到发布前的写
}
// 关键:release 与 acquire 必须"配对"才有语义;单独一个不构成保证

3. 什么时候"必须"用这些

场景 是否必要
无锁数据结构(自研队列) 是
高频计数器(比 Atomic 更省) 是(getAndAdd)
普通业务计数 否(AtomicLong 够)
堆外大缓冲(零拷贝) 是(FMA/Arena)

第二项是"最实际的收益":**VarHandle 的 getAndAdd 与 AtomicLong 相比,省掉了"对象头 + 一次间接"**第二项的"量化门槛"是"每秒千万次以上":低于这个量级,"省下的几纳秒"在总耗时里看不出来——而它提高了代码难度。
——在高频路径(每秒千万次)上可测。

第三项是最常见的"过度使用":业务里的计数用 AtomicLong 就够了,而"换成 VarHandle"只增加复杂度。

4. 三类"用错"的后果

用错 后果
plain 当 volatile 用 可见性问题(偶发)
acquire/release 不配对 无保证(等于 plain)
字段名改成"字符串"查找 重构时静默失效

第三项的"表现"是"类初始化失败"(ExceptionInInitializerError),而堆栈指向"静态块里的某一行"——于是排查从"类加载"开始,离"字段改名"很远。
第三项很隐蔽:findVarHandle 用字符串字段名,于是"重命名字段"时编译器不报错,而运行期抛 NoSuchFieldException——且它在静态初始化块里,表现为"类初始化失败"。

// 反例:字段改名后,这里抛 ExceptionInInitializerError
COUNT = MethodHandles.lookup().findVarHandle(Counter.class, "cnt", int.class);  // 字段实际叫 count
// 缓解:① 名字用常量;② 单元测试里显式触发类初始化;③ 用 record/局部变量替代共享字段

"分段计数"是"高频计数"的常规解法,而它比"换 API"更有效:

// LongAdder:把计数分散到多个 cell,减少争用
private final LongAdder hits = new LongAdder();
void onHit() { hits.increment(); }            // 高并发下比 AtomicLong 快
long total() { return hits.sum(); }           // 读取时汇总(略贵)
// 判据:写远多于读(计数)→ LongAdder;读写相当 → AtomicLong

5. 与 AtomicX 的关系

维度 AtomicLong VarHandle
粒度 整个字段 任意字段
内存序 固定(volatile 语义) 可选(plain/opaque/acquire/release)
可移植 完全 完全(JDK 9+)
可读性 高 中

第二项是"VarHandle 的真正优势":它允许"只在需要的地方用强屏障",而 AtomicLong 一律 volatile。第二项的"适用"很具体:"发布不可变配置"与"读多写少的状态位"——它们只需要"一次 release + 多次 acquire",而 volatile 在"写"上偏重。

第一项与第四项说明"默认选择":能用 AtomicX 就用它;只有"字段不能换成 Atomic 类型"(如既有 POJO)或"性能压到极限"时才用 VarHandle。

"内存序"的问题几乎无法靠"读代码"发现,只能靠"压力测试 + 工具":

// 三个手段(组合使用)
1 jcstress:专测"内存序与并发正确性"(发现偶发的可见性问题)
2 高并发压测 + 断言(在循环里验证不变式)
3 JFR 的 "Java Monitor Enter"/"Thread Park" 事件(看阻塞与竞争)
// 说明:普通单元测试跑一万次也可能不触发可见性问题,所以要用 jcstress

6. 排错:五种"并发工具相关问题"

现象 成因 处理方向
偶发读到旧值 plain 当 volatile 改 acquire/release
发布对象后字段为 null 缺 release/acquire 配对 加配对
类初始化失败 字段名/类型不符 用常量 + 测试触发
Unsafe 编译报错 包不可导出 换 VarHandle
性能没提升 瓶颈不在原子操作 先 profile

第五项的"替代假设"值得先验证:很多"原子操作热点"其实是"伪共享"(两个字段在同一缓存行),修法是"填充"而不是"换 API"。
第五项很常见:"换成 VarHandle"之前没 profile,于是"省下的几纳秒"被"更复杂的代码"抵消。

最后一个"封装"建议:把 VarHandle 的用法封在一个小类里,对外只暴露"语义明确的方法"(publish/read)。

// 封装(把"内存序"关在一个地方)
public final class AtomicRef<T> {
    private static final VarHandle VH = ...;   // 只有这里知道 VarHandle
    private volatile Object value;
    public void publish(T v) { VH.setRelease(this, v); }
    public T read() { return (T) VH.getAcquire(this); }
}
// 收益:业务代码不出现 "acquire/release",错误只可能出现在这一个类里

7. 边界:什么情况不该用

场景 为什么 做法
普通业务计数 AtomicLong 足够 用它
没有并发热点 无收益 用普通字段
团队不熟内存序 出错难查 用 synchronized/Atomic
需要"可读性优先" VarHandle 代码晦涩 用封装好的工具类

判断口径一句话:"这个字段的并发访问是否在性能热点上,且默认的 volatile 语义是否过强"。最后一条与"演进"有关的边界:Unsafe 的移除是长期趋势(JDK 的 sun.misc.Unsafe 内存方法已在逐步废弃)。所以"现在能跑"的代码,在"下一次大版本"可能要改——这个成本要提前计入。
两者都成立,才值得离开安全 API;否则它只是"把简单问题复杂化"。


8. 自测

  1. Unsafe 的四种能力分别被什么替代?"字段偏移不再由你算"带来什么好处?
  2. 四种内存序的语义差别是什么?为什么"acquire/release 必须配对"?
  3. 什么时候"必须"用 VarHandle?哪一类是"过度使用"?
  4. 为什么"用字符串字段名查找"会在重构时静默失效?怎么缓解?
  5. VarHandle 相对 AtomicLong 的真正优势是什么?为什么"默认还是用 Atomic"?
  6. 判断"是否值得离开安全 API"的那个问题是什么?
S05-A22 · `VarHandle` 与 `Unsafe`:什么时候值得离开"安全 API"
https://www.clxhxhhr.top/posts/6602/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。