《Java 并发编程·JUC 源码与容器》9.3 用 VarHandle 替换 Unsafe

梳理 JDK 25 中 Unsafe 到 VarHandle 的迁移路径:实例字段、静态字段、数组元素与字节数组视图四种句柄,lookup 访问权限与 final 字段限制,给出一张迁移对照表和库存扣减的迁移前后代码,并说明哪些能力 VarHandle 接不住。

承接的问题

9.1 和 9.2 已经把 VarHandle 的语义讲完,还剩一个现实问题:手上有一堆 sun.misc.Unsafe 旧代码,怎么迁?

先看 JDK 25 自己的态度。AtomicInteger 的源码里留着这样一段:

// JDK 25 源码摘录,已删减:AtomicInteger 的字段声明
public class AtomicInteger extends Number implements java.io.Serializable {
    /*
     * This class intended to be implemented using VarHandles, but there
     * are unresolved cyclic startup dependencies.
     */
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE
        = U.objectFieldOffset(AtomicInteger.class, "value");

    /** @serial */
    private volatile int value;

注释说得很清楚:本来打算用 VarHandle 实现,但启动期的循环依赖没解决。这不是「Unsafe 更好」,而是 JDK 自身在类初始化顺序上的妥协。同包的 Striped64 没有这个负担,Cell 和 base、cellsBusy 都已经切到 VarHandle:

// JDK 25 源码摘录,已删减:Striped64.Cell 的 VarHandle 路径
@jdk.internal.vm.annotation.Contended static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    final boolean cas(long cmp, long val) {
        return VALUE.weakCompareAndSetRelease(this, cmp, val);
    }
    final void reset() {
        VALUE.setVolatile(this, 0L);
    }
    final long getAndSet(long val) {
        return (long)VALUE.getAndSet(this, val);
    }
    private static final VarHandle VALUE = MhUtil.findVarHandle(
            MethodHandles.lookup(), "value", long.class);
}

第 8 章已经讲过 Cell 为什么要分片、为什么用 weakCompareAndSetRelease;本节只关心「同样的语义怎么用 VarHandle 写出来」。

还要注意一个常被忽略的事实:AtomicIntegerFieldUpdater 在 JDK 25 仍然用 Unsafe。javap -p 可以看到它的实现类里是 private static final Unsafe U 和 private final long offset,不是句柄。所以「JUC 全迁完了」是错的,迁移是按类、按启动依赖逐个做的。

四种句柄,各自一套坐标

VarHandle 不是只有字段一种。按访问目标分,常用的是四类:

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

一段代码把四种都跑一遍:

// demo.HandlesDemo(节选)
static final VarHandle VALUE;
static final VarHandle GLOBAL;
static final VarHandle INTS;
static final VarHandle INT_VIEW;

static {
    try {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        VALUE = lookup.findVarHandle(HandlesDemo.class, "value", long.class);
        GLOBAL = lookup.findStaticVarHandle(HandlesDemo.class, "global", long.class);
        INTS = MethodHandles.arrayElementVarHandle(int[].class);
        INT_VIEW = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.nativeOrder());
    } catch (ReflectiveOperationException e) {
        throw new ExceptionInInitializerError(e);
    }
}

long value;
static long global;

public static void main(String[] args) {
    HandlesDemo demo = new HandlesDemo();
    VALUE.set(demo, 7L);
    System.out.println("instance field      = " + VALUE.get(demo));

    GLOBAL.setVolatile(41L);
    System.out.println("static field        = " + GLOBAL.getVolatile());

    int[] numbers = {1, 2, 3};
    INTS.set(numbers, 1, 20);
    int old = (int) INTS.getAndAdd(numbers, 2, 5);
    System.out.println("array element       = " + Arrays.toString(numbers) + " (old at [2]=" + old + ")");

    byte[] buffer = new byte[Integer.BYTES];
    INT_VIEW.set(buffer, 0, 0x01020304);
    System.out.println("byte view bytes     = " + Arrays.toString(buffer));
    System.out.printf("byte view read back = 0x%08x%n", (int) INT_VIEW.get(buffer, 0));
}

本机输出(只摘录上文调用对应的行;程序还额外打印了字节序与计数器结果):

instance field      = 7
static field        = 41
array element       = [1, 20, 8] (old at [2]=3)
byte view bytes     = [4, 3, 2, 1]
byte view read back = 0x01020304

byte view bytes = [4, 3, 2, 1] 是小端机器的结果:0x01020304 的最低字节 04 放在下标 0。字节数组视图的坐标是「字节偏移」,不是元素下标;在长度足够的 buffer 里,INT_VIEW.get(buffer, 4) 读的才是第二个 int。

这里有一个必须提前知道的限制:字节数组视图句柄只支持 plain 的 get/set。本机验证:

byte view GET          = true
byte view GET_VOLATILE = false
byte view CAS          = false
getVolatile -> java.lang.UnsupportedOperationException

原因在 MethodHandles.byteArrayViewVarHandle 的注释里:byte[] 不保证按视图类型对齐,非 plain 模式无法安全实现。需要原子或有序访问时,改用 ByteBuffer.allocateDirect 或 java.lang.foreign.MemorySegment 上的句柄。

迁移对照表

把常见 Unsafe 调用逐条对上:

Unsafe 旧写法VarHandle 替代说明
objectFieldOffset + getInt(obj, off)get(obj)plain,无跨线程顺序
getIntVolatile / putIntVolatilegetVolatile / setVolatile与 volatile 字段等价
getIntAcquire / putIntReleasegetAcquire / setRelease单向屏障
getIntOpaque / putIntOpaquegetOpaque / setOpaque单变量一致,无跨变量顺序
compareAndSwapIntcompareAndSet强 CAS,不允许伪失败
compareAndExchangeIntcompareAndExchange返回 witness value
weakCompareAndSwapIntweakCompareAndSet允许伪失败
getAndAddIntgetAndAdd默认 volatile 语义
getAndSetIntgetAndSet返回旧值
arrayBaseOffset + arrayIndexScalearrayElementVarHandle不再手算偏移
copyMemory / setMemorySystem.arraycopy / MemorySegment.copy批量复制不需要 Unsafe
allocateMemory / freeMemoryArena + MemorySegment堆外内存走 FFM
monitorEnter / monitorExitsynchronized / ReentrantLock监视器语义
park / unparkLockSupport线程挂起
getUnsafe无替代只能通过受信模块获取
defineClass / defineAnonymousClass无等价替代类定义不属于变量访问

旧代码大致长这样。下面这段只是对照,不提供 import,不能直接编译:

// 旧写法(反例,不可直接编译):Unsafe 版计数器
final class UnsafeCounter {
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE = U.objectFieldOffset(UnsafeCounter.class, "value");

    private volatile long value;

    void increment() {
        U.getAndAddLong(this, VALUE, 1L);
    }

    long sum() {
        return U.getLongVolatile(this, VALUE);
    }
}

迁到 VarHandle 之后,偏移量、类型转换和字段名检查都收进句柄:

// demo.VarHandleCounter
public class VarHandleCounter {

    private static final VarHandle VALUE;

    static {
        try {
            VALUE = MethodHandles.lookup()
                    .findVarHandle(VarHandleCounter.class, "value", long.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private long value;

    public void increment() {
        VALUE.getAndAdd(this, 1L);
    }

    public boolean casIncrement(long expected) {
        return VALUE.compareAndSet(this, expected, expected + 1L);
    }

    public long sum() {
        return (long) VALUE.getVolatile(this);
    }

    public static void main(String[] args) throws InterruptedException {
        VarHandleCounter counter = new VarHandleCounter();
        int threads = 8;
        int perThread = 100_000;

        Thread[] workers = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            workers[i] = Thread.ofPlatform().start(() -> {
                for (int j = 0; j < perThread; j++) {
                    counter.increment();
                }
            });
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("after getAndAdd: " + counter.sum());
    }
}
after getAndAdd: 800000

这个数只证明「8 线程 × 100 000 次没有丢更新」,不是吞吐结论。迁移时最有价值的不是性能,而是三件事:字段类型在查找时校验、偏移量不再暴露给业务代码、访问模式在调用点一眼可见。

访问权限与 final 字段

Lookup.findVarHandle 的权限规则和普通反射不同:它检查的是调用 lookup() 的那个类对目标成员的访问权。同一个类里拿自己的私有字段没问题;跨包拿包私有字段会抛 IllegalAccessException;跨模块还要目标模块 opens 对应的包。

MethodHandles.privateLookupIn(target, lookup) 可以提升到目标类的私有访问级别,但它不是绕过模块系统:目标模块必须把包开放给调用方。classpath 上的未命名模块默认开放,命名模块要显式配置。

final 字段是另一个边界。本机探测结果:

instance final SET supported = false
instance final CAS supported = false
static final SET supported   = false
normal SET supported         = true
plain read via handle        = 1
set final -> java.lang.UnsupportedOperationException

findVarHandle 能拿到 final 字段的句柄,也能读,但所有写模式都不支持;调用 set 抛 UnsupportedOperationException。这意味着 VarHandle 不能用来改 final 字段,也不能像某些 Unsafe 技巧那样在构造完成后强改常量。需要可变状态就老老实实声明非 final 字段。

什么时候不要用 VarHandle

  • 堆外内存管理:allocateMemory、reallocateMemory、freeMemory、地址算术,VarHandle 一概不做。用 Arena + MemorySegment,或者 ByteBuffer.allocateDirect。
  • 任意偏移量访问:objectFieldOffset 拿到的裸偏移可以传给 JNI 或做指针运算,VarHandle 把偏移封装在句柄里,不暴露。依赖偏移量的本地代码无法直接迁移。
  • 类定义与反射式改写:defineClass、defineAnonymousClass 没有对应物。隐藏类用 Lookup.defineHiddenClass,但语义不同。
  • 监视器与线程挂起:monitorEnter/monitorExit、park/unpark 不在变量访问范畴,用 synchronized、ReentrantLock、LockSupport。
  • getUnsafe() 本身:VarHandle 没有「获取一个万能后门」的入口。迁移的第一步是把「拿 Unsafe 实例」这段删掉,而不是找替代品。
  • 字节数组视图的原子访问:只支持 plain get/set,需要原子性时换 MemorySegment。
  • float/double 的 CAS:VarHandle 按位比较,NaN 的不同位模式和 -0.0 与 +0.0 会让 CAS 出现「看起来相等却失败」的情况,迁移时要按位语义重新检查。

在 libraryd-conc 里落地

卷一第 2 章的 UnsafeLoanService 是故意写出竞态的反例。真正要迁移的是它背后的库存单元:旧实现用 Unsafe 的偏移量做 CAS,新实现把偏移量换成句柄。

package com.plume.conc.core;

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

public final class StockLedger {

    private final ConcurrentHashMap<String, StockCell> cells = new ConcurrentHashMap<>();
    private final BookRepository books;

    public StockLedger(BookRepository books) {
        this.books = books;
    }

    public void seed(String isbn) {
        cells.computeIfAbsent(isbn, key -> new StockCell(books.findByIsbn(key)
                .map(Book::stock)
                .orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, key))));
    }

    public void reserve(String isbn) {
        StockCell cell = Optional.ofNullable(cells.get(isbn))
                .orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
        if (!cell.tryReserveOne()) {
            throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
        }
    }

    public int stockOf(String isbn) {
        StockCell cell = cells.get(isbn);
        return cell == null ? 0 : cell.current();
    }

    static final class StockCell {

        private static final VarHandle STOCK;

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

        private int stock;

        StockCell(int stock) {
            this.stock = stock;
        }

        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;
        }

        void restock(int copies) {
            STOCK.getAndAdd(this, copies);
        }

        int current() {
            return (int) STOCK.getVolatile(this);
        }
    }
}

迁移前后要对照的是「谁在算偏移」:旧代码把 objectFieldOffset 的返回值存成 long,每个方法都把它传给 Unsafe;新代码只在静态初始化里查一次句柄,业务方法只写 STOCK.compareAndExchangeRelease(this, ...)。偏移量从公开状态变成实现细节,字段名打错会在类初始化时以 NoSuchFieldException 为原因抛出 ExceptionInInitializerError,而不是在运行时读写到错误地址。

StockLedger 的粒度也要说清楚:它只保证「一个 ISBN 对应的一个 int 库存」原子扣减;跨 ISBN 的批量扣减、库存与会员配额联动仍然不是原子的。VarHandle 迁移的是访问方式,不是事务边界。

迁移检查清单

  1. 先删 Unsafe.getUnsafe(),确认没有遗留的 offset 字段;有就说明迁移没做完。
  2. 把 objectFieldOffset 换成 findVarHandle,字段类型必须逐字匹配;int 写成 long 会抛 NoSuchFieldException。
  3. 按语义选模式:只需要单变量一致用 opaque,发布用 release/acquire,需要全序才用 volatile。
  4. 检查 final 字段:写模式不支持,必须改成非 final 或换设计方案。
  5. 检查数组访问:手算的 base + index * scale 全部换成 arrayElementVarHandle;字节数组视图需要原子性时不能留在 byteArrayViewVarHandle。
  6. 检查 float/double 的 CAS:按位语义重写比较逻辑。
  7. 检查权限:跨包、跨模块的句柄要么有 privateLookupIn 加 opens,要么把访问收敛到目标类内部。

小结

  • JDK 25 的 AtomicInteger 仍用 Unsafe 是启动期循环依赖的妥协;Striped64 已经完整迁到 VarHandle,AtomicIntegerFieldUpdater 仍在用 Unsafe。
  • 四种常用句柄:实例字段、静态字段、数组元素、字节数组视图;字节数组视图只支持 plain 读写。
  • 迁移的核心是把「偏移量 + 类型转换」换成「一次查找、静态缓存、调用点带语义的句柄」。
  • final 字段只有读模式;privateLookupIn 受模块 opens 约束;getUnsafe、堆外内存、类定义、监视器不在 VarHandle 的能力范围内。
  • 库存单元迁移后仍然只保证单字段原子扣减;跨字段事务要另找边界。

阅读导航:上一节 9.2 内存屏障与 acquire/release | 下一节 10.1 无锁栈与 Michael-Scott 队列

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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