关键词:Java Java17
使用 Unsafe 直接访问堆外内存存在各种安全性问题,对于使用者的要求也比较高,不太适合在业务当中广泛使用。于是,Java 在新孵化的 API 当中提供了更安全的方案。
接下来,我们来聊聊访问外部资源的新 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 开始孵化,到现在算是第二轮孵化了吧。如果大家要想在自己的程序里面体验这个能力,需要给编译器和虚拟机加参数:
--add-modules jdk.incubator.foreign --enable-native-access ALL-UNNAME
由于内容较多,本篇我们只介绍堆外内存的访问。外部函数访问的内容我们放到下一篇介绍。
基于现在的方案,我们有三种方式能访问到堆外内存,分别是
ByteBuffer(就是 allocateDirect),这个方式用起来相对安全,使用体验也与访问虚拟机堆内存一致,但执行效率相对一般:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
使用 Unsafe 的相关方法,这个方式在 JIT 优化之下效率较高,但非常不安全,因为它实际上可以访问到任意位置的内存,例如:
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 来做到这一点:
MemorySegment segment = MemorySegment.allocateNative(100, ResourceScope.newImplicitScope());
尽管看上去跟前面的 Unsafe 类似,但这里面有很多细节上的差异,因为它对于堆外内存的访问是受限制的,就像访问数组一样更加安全。另外请注意 ResourceScope 这个参数,它会控制分配的堆外内存的作用范围,这个我们会在后面介绍。
在堆外内存开辟以后,我们通常需要按照某种变量的方式去访问它,例如想要以 int 的方式读写,那么就创建一个 VarHandle 即可:
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
这里支持的类型就是基本类型,包括 byte、short、char、int、float、long、double。
for (int i = 0; i < 25; i++) {
intHandle.set(segment, /* offset */ i * 4, /* value to write */ i);
}
我们知道 Java 的 int 占 4 个字节,因此直接对前面开辟的内存 segment 进行读写操作即可。那如果我读写的范围越界会发生什么呢?
intHandle.set(segment, 100 /* out of bounds!! */, 1000);
运行程序结果发现抛了个异常,这个异常就是 MemorySegment 抛出来的:
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 指针一样:
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 可以降低堆外内存访问的代码复杂度,例如:
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 个),然后用它去开辟空间,并完成读写。
简单来说,在调用 C 函数时,我们可以很方便地使用这些 MemoryLayout 映射到 C 类型。
img
作用域这个东西实在是关键。
Java 的一大优点就是内存垃圾回收机制。内存都被虚拟机接管了,我们只需要考虑如何使用内存即可,虚拟机就像个大管家一样默默的为我们付出。这极大的降低了程序员管理内存的成本,也极大的降低了程序员在内存操作上犯错误的可能,对比我之前写 C++ 的时候经常因为某个内存错误查到半夜找不到头绪的情况,用 Java 写程序时开发效率的提升真不是一点儿半点儿。
要想让 Java 程序员用得舒服,那必须把堆外内存的管理也尽可能做到简单易用。为此,JDK 引入了资源作用域的概念,对应的类型就是 ResourceScope。这是一个密封接口,它有且仅有一个非密封的实现类 ResourceScopeImpl,JDK 还为这个实现类提供了三种具体的实现:
ResourceScope.newImplicitScope()
返回的正是 ImplicitScopeImpl。这种类型的 Scope 不能被主动关闭,不过使用它开辟的内存会在持有内存的 MemorySegment 对象不再被持有时释放。这个逻辑在 CleanerImpl 当中通过 ReferenceQueue 配合 PhantomReference 来实现。我们再来看一个例子:
try(var scope = ResourceScope.newConfinedScope()) {
MemorySegment memorySegment = MemorySegment.allocateNative(100, scope);
...
}
这个例子当中我们使用 ConfinedScope 来开辟内存,由于这个 scope 在 try-resource 语句结束之后就会被关闭,因此其中开辟的内存也会在语句结束的时候理解回收。
Java 17 为访问堆外内存提供了一套较为完成的 API,试图简化 Java 代码操作堆外内存的难度。从实际的使用体验来看,安全性确实可以得到一定程度上的保障,不过易用性嘛,倒是保持了 Java 的传统,这个我们在下一篇文章当中还会提及。