Skip to content

JEP 370: Foreign-Memory Access API (Incubator) | 外部内存访问 API(孵化器)

摘要

引入一个 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 的索引方案,因此无法创建大于两吉字节的缓冲区。此外,使用直接缓冲区可能很繁琐,因为与直接缓冲区关联的内存的释放工作留给了垃圾回收器;也就是说,只有在垃圾回收器认为直接缓冲区不可达之后,才能释放关联的内存。多年来,已经提交了许多增强请求以克服这些和其他限制(例如,4496703655836848375645029431)。这些限制中的许多都源于 ByteBuffer API 的设计初衷,它不仅用于堆外内存访问,还用于生产者 / 消费者之间的大批量数据交换,这对于字符集编码 / 解码和部分 I/O 操作等至关重要。

开发人员从 Java 代码中访问外部内存的另一种常见途径是使用 sun.misc.Unsafe API。由于 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 数组和字节缓冲区(无论是直接的还是基于堆的)。例如,可以如下创建一个本地内存段:

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

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

内存段是 空间受限的;也就是说,它们有下限和上限。任何尝试使用段访问这些界限之外的内存都会导致异常。正如使用 try-with-resource 结构所证明的那样,段也是 时间受限的;也就是说,它们在不再使用时会被创建、使用,然后关闭。关闭一个段总是一个显式操作,可能会导致额外的副作用,例如释放与段关联的内存。任何尝试访问已关闭的内存段都会导致异常。空间和时间安全检查共同对于保证内存访问 API 的安全性至关重要,因此,例如,可以防止 JVM 崩溃。

可以通过获取 内存访问 var 句柄 来取消引用与段关联的内存。这些特殊的 var 句柄至少有一个必需的访问坐标,类型为 MemoryAddress,即发生取消引用的地址。它们是通过 MemoryHandles 类中的工厂方法获得的。例如,要设置本地段的元素,我们可以使用内存访问 var 句柄,如下所示:

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

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

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

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

为了增强 API 的表达性,并减少像上述示例中那样的显式数值计算的需求,可以使用 MemoryLayout API 以编程方式描述内存段的内容。例如,上述示例中使用的本地内存段的布局可以按以下方式描述:

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

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

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

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

外部内存访问 API 最初将作为名为 jdk.incubator.foreign 的孵化模块提供,该模块与包名相同。

备选方案

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

风险和假设

创建一个既安全又高效的外部内存访问 API 是一项艰巨的任务。由于前面章节中描述的空间和时间检查需要在每次访问时执行,因此 JIT(即时编译器)必须能够通过一些方式(例如,将它们提升到热循环外部)来优化这些检查。JIT 实现可能需要一些工作来确保内存访问 API 的使用与现有 API(如 ByteBufferUnsafe)的使用一样高效且可优化。

依赖关系

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