跳到主要内容

JEP 383:外部内存访问 API(第二个孵化版)

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

总结

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

历史

JEP 370 在 2019 年末提出 Foreign-Memory Access API,并将其作为 孵化 API 应用于 Java 14。本 JEP 提议根据反馈意见进行改进,并在 Java 15 中重新孵化该 API。以下更改已作为此 API 更新的一部分包含在内:

  • 丰富的 VarHandle 组合 API,用于自定义内存访问变量句柄;
  • 针对通过 Spliterator 接口并行处理内存段的专门支持;
  • 增强了对 映射 内存段的支持(例如,MappedMemorySegment::force);
  • 提供安全的 API 点以支持串行限制(例如,在两个线程之间转移线程所有权);以及
  • 提供不安全的 API 点以操作和解引用来自本机调用的地址,或将此类地址包装为合成内存段。

目标

  • 通用性: 单个 API 应该能够操作各种类型的外部内存(例如,本地内存、持久性内存、托管堆内存等)。
  • 安全性: 不管操作的是哪种内存,API 都不应破坏 JVM 的安全性。
  • 确定性: 对外部内存的释放操作应在源代码中明确。
  • 可用性: 对于需要访问外部内存的程序,该 API 应该是传统 Java API(如 sun.misc.Unsafe)的一个引人注目的替代方案。

非目标

  • 在 Foreign-Memory Access API 之上重新实现传统的 Java API(例如 sun.misc.Unsafe)并不是目标。

动机

许多 Java 程序访问外部内存,例如 IgnitemapDBmemcachedLucene 以及 Netty 的 ByteBuf API。通过这样做,它们可以:

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

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

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

  • 开发人员可以通过另一种常见途径从 Java 代码访问外部内存,即 Unsafe APIUnsafe 提供了许多内存访问操作(例如 Unsafe::getIntputInt),由于其相对通用的寻址模型,这些操作既适用于堆内也适用于堆外访问。使用 Unsafe 访问内存非常高效:所有内存访问操作都被定义为 HotSpot JVM 内部函数,因此这些操作通常会被 HotSpot JIT 编译器优化。然而,根据定义,Unsafe API 是不安全的——它允许访问任何内存位置(例如,Unsafe::getInt 接受一个 long 地址)。这意味着 Java 程序通过访问某些已释放的内存位置可能会导致 JVM 崩溃。除此之外,Unsafe API 并不是受支持的 Java API,其使用一直被强烈反对

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

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

这个 JEP(Java 增强提案)引入了一个安全、受支持且高效的外来内存访问 API。通过提供针对访问外来内存问题的专门解决方案,开发者将不再受现有 API 的限制和危险的束缚。他们还将享受到性能的提升,因为新的 API 从头开始设计时就考虑了 JIT(即时编译器)优化。

描述

外部内存访问 API 以名为 jdk.incubator.foreign孵化模块形式提供,位于同名的包中;它引入了三个主要的抽象概念:MemorySegmentMemoryAddressMemoryLayout

  • MemorySegment 模型化了一个具有给定空间和时间边界的连续内存区域。
  • MemoryAddress 模型化了一个地址。通常有两种地址:一种是已检查的地址,它是给定内存段内的偏移量;另一种是未检查的地址,其空间和时间边界未知,例如从本地代码中(不安全地)获取的内存地址。
  • MemoryLayout 是对内存段内容的程序化描述。

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

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

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

内存段是空间上有界的,这意味着它们有上下界。任何试图使用该段访问这些界限之外的内存的操作都会导致异常。通过 try-with-resource 构造的使用可以证明,内存段也是时间上有界的,这意味着它们必须被创建、使用,并在不再使用时关闭。关闭段始终是一个显式操作,可能会导致一些额外的副作用,例如与该段关联的内存被释放。任何试图访问已关闭的内存段的操作都会导致异常。空间和时间上的界限共同保证了外部内存访问 API 的安全性,从而保证其使用不会导致 JVM 崩溃。

通过获取 var handle 来实现对与某段内存相关联的解引用操作,这是在 Java 9 中引入的一种数据访问抽象。具体来说,一个段是通过 memory-access var handle(内存访问变量句柄)来解引用的。这种变量句柄具有一个类型为 MemoryAddressaccess coordinate(访问坐标),它作为解引用发生的地址。

内存访问变量句柄是通过 MemoryHandles 类中的工厂方法获得的。例如,要设置本地内存段的元素,我们可以如下使用内存访问变量句柄:

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

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

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

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

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

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

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

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

VarHandle indexedElementHandle
= 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++) {
indexedElementHandle.set(base, (long) i, i);
}
}

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

已检查与未检查的地址

解引用操作只能在已检查的内存地址上进行。在 API 中,已检查的地址很常见,例如上面代码中从内存段获取的地址(segment.baseAddress())。然而,如果某个内存地址是未检查的,并且没有任何关联的段,那么它无法被安全地解引用,因为运行时无法知道与该地址相关的空间和时间边界。未检查地址的例子包括:

  • NULL 地址(MemoryAddress::NULL)
  • 从 long 值构造的地址(通过 MemoryAddress::ofLong 工厂方法)

要解引用一个未经检查的地址,客户端有两种选择。如果已知该地址属于客户端已拥有的内存段,则客户端可以执行所谓的 rebase 操作(MemoryAddress::rebase),在此操作中,未经检查的地址的偏移量将相对于该段的基地址重新解释,从而生成一个新的地址实例,这个实例可以安全地被解引用。或者,如果不存在这样的段,客户端可以使用特殊的 MemorySegment::ofNativeRestricted 工厂方法不安全地创建一个段。这个工厂方法有效地为一个未经检查的地址附加了空间和时间边界,从而允许进行解引用操作。

顾名思义,该操作本质上是不安全的,必须谨慎使用。因此,Foreign Memory Access API 仅在 JDK 属性 foreign.restricted 设置为非 deny 值时才允许调用此工厂方法。该属性的可能值包括:

  • deny - 在每次受限调用时抛出运行时异常。这是默认值;
  • permit - 允许受限调用;
  • warn - 类似于 permit,但在每次受限调用时会打印一行警告。
  • debug - 类似于 permit,但还会转储与任何给定受限调用对应的堆栈。

我们计划在未来让对受限操作的访问与模块系统更加集成;也就是说,某些模块可能需要受限的原生访问;当依赖这些模块的应用程序被执行时,用户可能需要为这些模块提供权限以执行受限的原生操作,否则运行时将拒绝构建应用程序的模块图。

限制

除了空间和时间界限外,片段还具有线程限制的特性。也就是说,一个片段是由创建它的线程拥有的,其他线程不能访问该片段上的内容,也不能对其执行某些操作(例如 close)。虽然线程限制显得有些严格,但它是确保在多线程环境中仍能实现最佳内存访问性能的关键。如果去除了线程限制,多个线程可能会同时访问和关闭同一个片段——这将破坏外部内存访问 API 提供的安全保证,除非引入某种非常昂贵的锁定机制来防止访问与关闭之间的竞争。

外部内存访问 API 提供了两种放松线程限制屏障的方式。首先,线程可以通过执行显式的传递操作来协作共享段,其中一个线程释放其对某个段的所有权,并将其转移给另一个线程。请看以下代码:

MemorySegment segmentA = MemorySegment.allocateNative(10); // confined by thread A
...
var segmentB = segmentA.withOwnerThread(threadB); // confined by thread B

这种访问模式也被称为串行限制,在生产者/消费者使用场景中可能会很有用,其中每次只有一个线程需要访问一个段。需要注意的是,为了使交接操作安全,API 会终止原始段(就像调用了 close,但不释放底层内存),并返回一个具有正确所有者的段。实现还确保第一个线程的所有写入在第二个线程访问该段时都被刷新到内存中。

其次,内存段的内容仍然可以 并行 处理(例如,使用 Fork/Join 等框架),通过从内存段中获取一个 Spliterator 实例即可。例如,要并行计算内存段中所有 32 位值的总和,我们可以使用以下代码:

SequenceLayout seq = MemoryLayout.ofSequence(1_000_000, MemoryLayouts.JAVA_INT);
SequenceLayout seq_bulk = seq.reshape(-1, 100);
VarHandle intHandle = seq.varHandle(int.class, PathElement.sequenceElement());

int sum = StreamSupport.stream(MemorySegment.spliterator(segment, seq_bulk), true)
.mapToInt(slice -> {
int res = 0;
MemoryAddress base = slice.baseAddress();
for (int i = 0; i < 100 ; i++) {
res += (int)intHandle.get(base, (long)i);
}
return res;
}).sum();

MemorySegment::spliterator 接收一个段、一个序列布局,并返回一个 spliterator 实例,该实例将段拆分为与提供的序列布局中的元素相对应的块。在这里,我们希望对包含一百万个元素的数组求和;然而,进行每次计算仅处理恰好一个元素的并行求和效率会很低,因此我们改用布局 API 来派生一个批量序列布局。这个批量布局是一个序列布局,其大小与原始布局相同,但元素被组织为每组 100 个元素 —— 这应该使其更适合并行处理。

一旦我们有了 spliterator,我们就可以用它来构建并行流,并对段的内容进行并行求和。虽然这里的段可以被多个线程同时访问,但访问是以一种常规的方式进行的:从原始段创建一个切片,并将其交给一个线程来进行某些计算。外部内存访问运行时能够知道某个线程当前是否正在通过 spliterator 访问段的一个切片,因此可以通过不允许在对同一段进行并行处理关闭该段来强制执行安全性。

替代方案

继续使用现有的 API,例如 java.nio.ByteBuffersun.misc.Unsafe,或者更糟糕的是,使用 JNI。

风险与假设

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

依赖

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