一段"无锁队列"的实现用了 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. 自测
Unsafe的四种能力分别被什么替代?"字段偏移不再由你算"带来什么好处?- 四种内存序的语义差别是什么?为什么"acquire/release 必须配对"?
- 什么时候"必须"用 VarHandle?哪一类是"过度使用"?
- 为什么"用字符串字段名查找"会在重构时静默失效?怎么缓解?
VarHandle相对AtomicLong的真正优势是什么?为什么"默认还是用 Atomic"?- 判断"是否值得离开安全 API"的那个问题是什么?