跳到主要内容

JEP 370:外部内存访问 API(孵化中)

QWen Max 中英对照 JEP 370: Foreign-Memory Access API (Incubator)

概述

引入一个 API,以允许 Java 程序安全高效地访问 Java 堆之外的外部内存。

目标

外部内存 API 应满足以下标准:

  • 通用性: 相同的 API 应该能够操作各种类型的外部内存(例如,本地内存、持久性内存、托管堆内存等)。
  • 安全性: 不管操作的是哪种内存,API 都不应破坏 JVM 的安全性。
  • 确定性: 内存释放操作应在源代码中明确指定。

成功指标

外部内存 API 应该是一个有效的替代方案,以取代目前 Java 程序访问外部内存的主要途径,即 java.nio.ByteBuffersun.misc.Unsafe。新的 API 在性能上应与这些现有的 API 具有竞争力。

动机

许多现有的 Java 库和程序访问外部内存,例如 IgnitemapDBmemcached,以及 Netty 的 ByteBuf API。通过这种方式,它们可以

  • 避免与垃圾回收相关的成本和不可预测性(尤其是在维护大型缓存时),
  • 在多个进程之间共享内存,以及
  • 通过将文件映射到内存来序列化和反序列化内存内容(例如,通过 mmap)。

但是,Java API 并未提供访问外部内存的满意解决方案。

Java 1.4 中引入的 ByteBuffer API 允许创建直接字节缓冲区,这些缓冲区是在堆外分配的,并允许用户直接从 Java 操作堆外内存。然而,直接缓冲区有其局限性。例如,由于 ByteBuffer API 使用基于 int 的索引方案,因此无法创建大于 2 GB 的缓冲区。此外,与直接缓冲区相关联的内存释放交由垃圾回收器处理,这使得操作直接缓冲区变得繁琐;也就是说,只有当垃圾回收器认为某个直接缓冲区不可达时,与其关联的内存才能被释放。多年来,为了克服这些及其他限制(例如,4496703655836848375645029431),已经提交了许多增强请求。这些限制中的许多源于这样一个事实:ByteBuffer API 不仅设计用于堆外内存访问,还设计用于对批量数据进行生产者/消费者交换,而这对字符集编码/解码和部分 I/O 操作等功能至关重要。

开发者通过 sun.misc.Unsafe API 也可以使用另一个常见的途径从 Java 代码访问外部内存。Unsafe 暴露了许多内存访问操作(例如,Unsafe::getIntputInt),由于采用了智能且相对通用的寻址模型,这些操作既可以用于堆内访问,也可以用于堆外访问。使用 Unsafe 访问内存极其高效:所有内存访问操作都被定义为 JVM 内部函数,因此 JIT 会常规优化这些内存访问操作。但不幸的是,按照定义,Unsafe API 是 不安全的 —— 它允许访问任何内存位置(例如,Unsafe::getInt 接收一个 long 类型的地址)。这使得 Java 程序有可能导致 JVM 崩溃,例如,当访问某个已经释放的内存位置时。除此之外,Unsafe API 并不是受支持的 Java API,其使用也一直被强烈反对

虽然使用 JNI 访问内存也是一种可能,但此解决方案相关的固有成本使其在实践中很少适用。整个开发流程十分复杂,因为 JNI 要求开发人员编写和维护 C 代码片段。JNI 本质上也很缓慢,因为每次访问都需要进行 Java 到原生的转换。

总之,在访问外部内存时,开发者面临一个两难的选择:是使用安全但有限(且可能效率较低)的方式(例如 ByteBuffer),还是放弃安全性保障,转而采用不受支持且危险的 Unsafe API?

本 JEP 介绍了一种受支持的、安全且高效的外部内存访问 API。通过提供针对访问外部内存问题的专门解决方案,开发者将不再受到现有 API 的限制和危险。他们还将享受到性能的提升,因为新 API 从头开始设计时就考虑了 JIT 优化。

描述

外部内存访问 API 引入了三个主要的抽象概念:MemorySegmentMemoryAddressMemoryLayout

MemorySegment 用于对具有给定空间和时间边界的连续内存区域进行建模。MemoryAddress 可以看作是段内的偏移量。最后,MemoryLayout 是内存段内容的编程描述。

内存段可以由多种来源创建,例如本地内存缓冲区、Java 数组和字节缓冲区(直接或基于堆)。例如,可以如下创建一个本地内存段:

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
...
}
java

这将创建一个与大小为 100 字节的原生内存缓冲区相关联的内存段。

内存段在空间上是有边界的;也就是说,它们有下限和上限。任何试图使用该段访问这些边界之外的内存的操作都会导致异常。通过 try-with-resource 构造的使用可以看出,内存段在时间上也是有边界的;也就是说,它们被创建、使用,然后在不再使用时关闭。关闭一个段始终是一个显式操作,并可能导致额外的副作用,例如与该段关联的内存被释放。任何试图访问已关闭的内存段的操作都会导致异常。总之,空间和时间上的安全性检查对于保证内存访问 API 的安全性至关重要,从而避免例如严重的 JVM 崩溃等问题。

要对与某个段(segment)关联的内存进行解引用,可以通过获取一个 内存访问变量句柄(memory-access var handle) 来实现。这些特殊的变量句柄至少有一个强制性的访问坐标,其类型为 MemoryAddress,这是解引用操作发生的地址。它们是通过 MemoryHandles 类中的工厂方法获得的。例如,要设置一个原生段(native segment)的元素,我们可以如下使用内存访问变量句柄:

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

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
MemoryAddress base = segment.baseAddress();
for (int i = 0; i < 25; i++) {
intHandle.set(base.addOffset(i * 4), i);
}
}
java

内存访问变量句柄还可以获取一个或多个类型为 long 的额外访问坐标,以支持更复杂的寻址方案,例如多维索引访问。此类内存访问变量句柄通常通过调用一个或多个组合方法获得,这些方法同样定义在 MemoryHandles 类中。例如,设置原生段元素的一种更直接方式是通过索引内存访问句柄,其构造方式如下:

VarHandle intHandle = MemoryHandles.varHandle(int.class, 
ByteOrder.nativeOrder());
VarHandle intElemHandle = MemoryHandles.withStride(intHandle, 4);

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
MemoryAddress base = segment.baseAddress();
for (int i = 0; i < 25; i++) {
intElemHandle.set(base, (long) i, i);
}
}
java

这有效地允许对原本平坦的内存缓冲区进行丰富、多维的寻址。

为了增强 API 的表现力,并减少对如上例所示的显式数值计算的需求,可以使用 MemoryLayout API 以编程方式描述内存段的内容。例如,上述示例中使用的原生内存段的布局可以按照以下方式描述:

SequenceLayout intArrayLayout
= MemoryLayout.ofSequence(25,
MemoryLayout.ofValueBits(32,
ByteOrder.nativeOrder()));
java

这将创建一个序列内存布局,其中给定的元素布局(一个 32 位值)重复 25 次。一旦我们有了一个内存布局,我们就可以摆脱代码中所有手动的数值计算,还可以简化所需内存访问变量句柄的创建,如下例所示:

SequenceLayout intArrayLayout
= MemoryLayout.ofSequence(25,
MemoryLayout.ofValueBits(32,
ByteOrder.nativeOrder()));

VarHandle intElemHandle
= intArrayLayout.varHandle(int.class,
PathElement.sequenceElement());

try (MemorySegment segment = MemorySegment.allocateNative(intArrayLayout)) {
MemoryAddress base = segment.baseAddress();
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
intElemHandle.set(base, (long) i, i);
}
}
java

在此示例中,布局实例通过创建 布局路径 来驱动内存访问变量句柄的创建,该路径用于从复杂的布局表达式中选择嵌套布局。布局实例还驱动本机内存段的分配,这是基于从布局中派生的大小和对齐信息。前面示例中的循环常量已替换为序列布局的元素计数。

外部内存访问 API 最初会作为孵化模块提供,名为 jdk.incubator.foreign,位于同名的包中。

替代方案

继续使用现有的 API,例如 ByteBufferUnsafe,或者更糟糕的是,使用 JNI。

风险与假设

创建一个既安全又高效的外部内存访问 API 是一项艰巨的任务。由于前几节中描述的空间和时间检查需要在每次访问时执行,因此 JIT(即时编译器)能够通过将这些检查优化掉(例如,将其提升到热循环之外)是至关重要的。JIT 的实现可能需要一些工作,以确保使用内存访问 API 的效率和可优化性与现有的 API(如 ByteBufferUnsafe)相当。

依赖

本 JEP 中描述的 API 可能有助于实现 Project Panama 目标之一的原生互操作支持的开发。该 API 还可以用于以更通用和高效的方式访问非易失性内存,这在之前已经可以通过 JEP 352(非易失性映射字节缓冲区) 实现。