3

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

 2 years ago
source link: https://www.bennyhuo.com/2021/10/02/Java17-Updates-09-foreignapi-memory/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

发表于 2021-10-02 | 阅读次数:
本文字数: 6.1k | 阅读时长 ≈ 10 分钟

使用 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 的饭碗吗?

764F4997.gif

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

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

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

7657EB7E.jpg

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

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

765D0537.jpg

稍微提一下,堆外内存访问的 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 对象不就是野指针了嘛。

78650EAC.jpg

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

image-20210923070228075.png

这套 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 个),然后用它去开辟空间,并完成读写。

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

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

img

堆外内存的作用域

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

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

786BA6D8.gif

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

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

我们再来看一个例子:

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

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

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


C 语言是所有程序员应当认真掌握的基础语言,不管你是 Java 还是 Python 开发者,欢迎大家关注我的新课 《C 语言系统精讲》:

扫描二维码或者点击链接《C 语言系统精讲》即可进入课程

program_in_c.png


Kotlin 协程对大多数初学者来讲都是一个噩梦,即便是有经验的开发者,对于协程的理解也仍然是懵懵懂懂。如果大家有同样的问题,不妨阅读一下我的新书《深入理解 Kotlin 协程》,彻底搞懂 Kotlin 协程最难的知识点:

扫描二维码或者点击链接《深入理解 Kotlin 协程》购买本书

understanding_kotlin_coroutines.png


如果大家想要快速上手 Kotlin 或者想要全面深入地学习 Kotlin 的相关知识,可以关注我基于 Kotlin 1.3.50 全新制作的入门课程:

扫描二维码或者点击链接《Kotlin 入门到精通》即可进入课程

exported_qrcode_image_256.png


Android 工程师也可以关注下《破解Android高级面试》,这门课涉及内容均非浅尝辄止,除知识点讲解外更注重培养高级工程师意识:

扫描二维码或者点击链接《破解Android高级面试》即可进入课程

15520936284634.jpg


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK