Java 17 更新(9):Unsafe 不 safe,我们来一套 safe 的 API 访问堆外内存

使用 Unsafe 直接访问堆外内存存在各种安全性问题,对于使用者的要求也比较高,不太适合在业务当中广泛使用。于是,Java 在新孵化的 API 当中提供了更安全的方案。

JEP 412: Foreign Function & Memory API (Incubator)

接下来,我们来聊聊访问外部资源的新 API,这些内容来自于 **JEP 412: Foreign Function & Memory API (Incubator)**。这个提案主要应对的场景就是调用 Java VM 以外的函数,即 Native 函数;访问 Java VM 以外的内存,即堆外内存(off-heap memory)。

这不就是要抢 JNI 的饭碗吗?

对,这个提案里面提到的堆外内存和代码访问都可以用 JNI 来做到,不过 JNI 不够好用,还够不安全。

Java 程序员不仅需要编写大量单调乏味的胶水代码(JNI 接口),还要去编写和调试自己本不熟悉(多数 Java 程序员甚至根本不会)的 C、C++ 代码,更要命的是调试工具也没有那么好用。当然,这些都可以克服,只是 Java 和 C、C++ 的类型系统却有着本质的区别而无法直接互通,我们总是需要把传到 C、C++ 层的 Java 对象的数据用类似于反射的 API 取出来,构造新的 C、C++ 对象来使用,非常的麻烦。

说到这个问题,我甚至在公司内见过有人用 C++ 基于 JNI 把 Java 层的常用类型都封装了一遍,你能想象在 C++ 代码当中使用 ArrayList 的情形吗?我当时一度觉得自己精神有些恍惚。

这些年来 Java 官方在这方面也没有什么实质性的进展。JNI 难用就难用吧,总算还有得用,一些开源的框架例如 JNA、JNR、JavaCPP 都是基于 JNI 做了一些简化的工作,让 Java 与 Native 语言的调用没那么令人难受。

你可能以为这个提案的目的也是搞一个类似的框架,其实不然。Java 官方嘛,不搞就不搞,要搞就搞一套全新的方案,让开发者用着方便,程序性能更好(至少不比 JNI 更差),普适性更强,也更安全 —— 至少,他们是这么想的。

稍微提一下,堆外内存访问的 API 从 Java 14 就开始孵化,到 Java 17 连续肝了四个版本了已经,仍然还是 incubator;访问外部函数的 API 则从 Java 16 开始孵化,到现在算是第二轮孵化了吧。如果大家要想在自己的程序里面体验这个能力,需要给编译器和虚拟机加参数:

1
--add-modules jdk.incubator.foreign --enable-native-access ALL-UNNAME

由于内容较多,本篇我们只介绍堆外内存的访问。外部函数访问的内容我们放到下一篇介绍。

访问堆外内存

基于现在的方案,我们有三种方式能访问到堆外内存,分别是

  • ByteBuffer(就是 allocateDirect),这个方式用起来相对安全,使用体验也与访问虚拟机堆内存一致,但执行效率相对一般:

    1
    2
    3
    public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
    }
  • 使用 Unsafe 的相关方法,这个方式在 JIT 优化之下效率较高,但非常不安全,因为它实际上可以访问到任意位置的内存,例如:

    1
    2
    3
    4
    5
    6
    7
    Unsafe unsafe = ...;
    var handle = unsafe.allocateMemory(8); // 申请 8 字节内存

    unsafe.putDouble(handle, 1024); // 往该内存当中写入 1024 这个 double
    System.out.println(unsafe.getDouble(handle)); // 从该内存当中读取一个 double 出来

    unsafe.freeMemory(handle); // 释放这块内存
  • 使用 JNI,通过 C/C++ 直接操作堆外内存。

对于 Java 程序员来讲,效率较高的后两种方式都不是特别友好。

接下来我们看一下新的内存访问方案,它主要解决了分配、访问和作用域等几个问题。

堆外内存分配

我们可以通过 MemorySegment 来做到这一点:

1
MemorySegment segment = MemorySegment.allocateNative(100, ResourceScope.newImplicitScope());

尽管看上去跟前面的 Unsafe 类似,但这里面有很多细节上的差异,因为它对于堆外内存的访问是受限制的,就像访问数组一样更加安全。另外请注意 ResourceScope 这个参数,它会控制分配的堆外内存的作用范围,这个我们会在后面介绍。

堆外内存访问

在堆外内存开辟以后,我们通常需要按照某种变量的方式去访问它,例如想要以 int 的方式读写,那么就创建一个 VarHandle 即可:

1
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());

这里支持的类型就是基本类型,包括 byte、short、char、int、float、long、double。

1
2
3
for (int i = 0; i < 25; i++) {
intHandle.set(segment, /* offset */ i * 4, /* value to write */ i);
}

我们知道 Java 的 int 占 4 个字节,因此直接对前面开辟的内存 segment 进行读写操作即可。那如果我读写的范围越界会发生什么呢?

1
intHandle.set(segment, 100 /* out of bounds!! */, 1000);

运行程序结果发现抛了个异常,这个异常就是 MemorySegment 抛出来的:

1
Exception in thread "main" java.lang.IndexOutOfBoundsException: Out of bound access on segment MemorySegment{ id=0x17366e0a limit: 100 }; new offset = 100; new length = 4

这样相比使用 Unsafe 访问内存的好处就在于受控制。

使用 Unsafe 访问堆外内存就好像直接使用 C 指针操作内存一样。C 语言主张相信程序员,所以对于 C 程序员使用指针访问内存不加任何限制。可是在内存管理这个问题上,Java 程序员并不一定像 C 程序员那么可靠。

img

我们不妨再给大家看看 Unsafe 的例子,看看是不是如同操作 C 指针一样:

1
2
3
4
5
6
7
8
9
10
var handle = unsafe.allocateMemory(16);

// 操作分配的内存之后的部分,实际上这部分内存完全不可预见
unsafe.putInt(handle + 16, 1000);
// 读取非法内存
System.out.println(unsafe.getInt(handle + 16));

unsafe.freeMemory(handle);
// 内存已经回收了,仍然可以读
System.out.println(unsafe.getInt(handle));

这样我们就知道 Unsafe 是真的不 safe 啊。不仅如此,一旦忘了释放内存,就会造成内存泄漏。我们甚至无法通过 handle 来判断内存是否有效,对于已经回收的内存,handle 对象不就是野指针了嘛。

除了提升安全性以外,新 API 还提供了一套内存布局相关的 API:

这套 API 可以降低堆外内存访问的代码复杂度,例如:

1
2
3
4
5
6
SequenceLayout intArrayLayout = MemoryLayout.sequenceLayout(25, MemoryLayout.valueLayout(32, ByteOrder.nativeOrder()));
MemorySegment segment = MemorySegment.allocateNative(intArrayLayout, newImplicitScope());
VarHandle indexedElementHandle = intArrayLayout.varHandle(int.class, PathElement.sequenceElement());
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
indexedElementHandle.set(segment, (long) i, i);
}

这样我们在开辟内存空间的时候只需要通过 SequenceLayout 描述清楚我们需要什么样的内存(32bit,Native 字节序),多少个(25 个),然后用它去开辟空间,并完成读写。

  • PaddingLayout 会在我们需要的数据后添加额外的内存空间,主要用于内存对齐。
  • ValueLayout 用来映射基本的数值类型,例如 int、float 等等。
  • GroupLayout 可以用来组合其他的 MemoryLayout。它有两种类型,分别是 STRUCT 和 UNION。熟悉 C 语言的小伙伴们应该立刻就能明白,它在调用 C 函数的时非常有用,可以用来映射 C 的结构体和联合体。

简单来说,在调用 C 函数时,我们可以很方便地使用这些 MemoryLayout 映射到 C 类型。

img

堆外内存的作用域

作用域这个东西实在是关键。

Java 的一大优点就是内存垃圾回收机制。内存都被虚拟机接管了,我们只需要考虑如何使用内存即可,虚拟机就像个大管家一样默默的为我们付出。这极大的降低了程序员管理内存的成本,也极大的降低了程序员在内存操作上犯错误的可能,对比我之前写 C++ 的时候经常因为某个内存错误查到半夜找不到头绪的情况,用 Java 写程序时开发效率的提升真不是一点儿半点儿。

要想让 Java 程序员用得舒服,那必须把堆外内存的管理也尽可能做到简单易用。为此,JDK 引入了资源作用域的概念,对应的类型就是 ResourceScope。这是一个密封接口,它有且仅有一个非密封的实现类 ResourceScopeImpl,JDK 还为这个实现类提供了三种具体的实现:

  • GLOBAL:这实际上是一个匿名内部类对象,它是全局作用域,使用它开辟的堆外内存不会自动释放。
  • ImplicitScopeImpl:我们在前面演示新 API 的使用时已经提到过,调用 ResourceScope.newImplicitScope() 返回的正是 ImplicitScopeImpl。这种类型的 Scope 不能被主动关闭,不过使用它开辟的内存会在持有内存的 MemorySegment 对象不再被持有时释放。这个逻辑在 CleanerImpl 当中通过 ReferenceQueue 配合 PhantomReference 来实现。
  • SharedScope:最主要的能力就是提供了多线程共享访问的支持;是 ImplicitScopeImpl 的父类,二者的差别在于 SharedScope 可以被主动关闭,不过必须确保只能被关闭一次。
  • ConfinedScope:单线程作用域,只能在所属的线程内访问,比较适合局部环境下的内存管理。

我们再来看一个例子:

1
2
3
4
try(var scope = ResourceScope.newConfinedScope()) {
MemorySegment memorySegment = MemorySegment.allocateNative(100, scope);
...
}

这个例子当中我们使用 ConfinedScope 来开辟内存,由于这个 scope 在 try-resource 语句结束之后就会被关闭,因此其中开辟的内存也会在语句结束的时候理解回收。

小结

Java 17 为访问堆外内存提供了一套较为完成的 API,试图简化 Java 代码操作堆外内存的难度。从实际的使用体验来看,安全性确实可以得到一定程度上的保障,不过易用性嘛,倒是保持了 Java 的传统,这个我们在下一篇文章当中还会提及。


关于作者

霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);前腾讯高级工程师,现就职于猿辅导