跳到主要内容

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

概括

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

历史

外部内存访问 API 由JEP 370提出,目标是 2019 年底的 Java 14 作为孵化 API。此 JEP 建议根据反馈进行改进,并在 Java 15 中重新孵化 API。此 API 更新中包含以下更改:

  • 丰富的VarHandle组合器 API,用于自定义内存访问 var 句柄;
  • 通过接口有针对性地支持内存段的并行处理Spliterator
  • _增强对映射_内存段的支持(例如MappedMemorySegment::force);
  • 支持串行限制的安全 API 点(例如,在两个线程之间转移线程所有权);和
  • 不安全的 API 指向操作和取消引用来自本机调用等地址,或将此类地址包装到合成内存段中。

目标

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

非目标

  • 目标不是sun.misc.Unsafe在外部内存访问 API 之上重新实现旧版 Java API(例如 )。

动机

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

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

不幸的是,Java API 没有提供令人满意的访问外部内存的解决方案:

  • Java 1.4 中引入的API允许创建_直接字节缓冲区,这些_ByteBuffer缓冲区在堆外分配,因此允许用户直接从 Java 操作堆外内存。然而,直接缓冲区是有限的。例如,由于API 使用基于 的索引方案,因此不可能创建大于 2 GB 的直接缓冲区。此外,使用直接缓冲区可能很麻烦,因为与它们相关的内存的释放是留给垃圾收集器的;也就是说,只有在垃圾收集器认为直接缓冲区不可访问后,才能释放关联的内存。多年来,为了克服这些和其他限制,已经提出了许多增强请求(例如 4496703、6558368、48375645029431 。其中许多限制源于这样一个事实:API不仅设计用于堆外内存访问,还设计用于字符集编码/解码和部分 I/O 操作等领域的批量数据的生产者/消费者交换。ByteBuffer``int``ByteBuffer

  • 开发人员从 Java 代码访问外部内存的另一个常见途径是UnsafeAPI。由于相对通用的寻址模型Unsafe,公开了许多内存访问操作(例如,Unsafe::getIntputInt),这些操作适用于堆内和堆外访问。使用Unsafe它来访问内存非常高效:所有内存访问操作都被定义为 HotSpot JVM 内在函数,因此内存访问操作通常会由 HotSpot JIT 编译器进行优化。然而,Unsafe根据定义,API 是_不安全的_——它允许访问_任何_内存位置(例如,Unsafe::getInt获取long地址)。这意味着 Java 程序可以通过访问某些已释放的内存位置来使 JVM 崩溃。最重要的是,该UnsafeAPI 不是受支持的 Java API,并且一直强烈建议不要使用它。

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

总而言之,在访问外部内存时,开发人员面临着一个两难的境地:是应该选择安全但有限(而且可能效率较低)的路径,例如 API ByteBuffer,还是应该放弃安全保证,拥抱危险且不受支持的路径?UnsafeAPI?

此 JEP 引入了一个安全、受支持且高效的 API,用于外部内存访问。通过为访问外部内存的问题提供有针对性的解决方案,开发人员将摆脱现有 API 的限制和危险。他们还将享受到更高的性能,因为新的 API 将从头开始设计,并考虑到 JIT 优化。

描述

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

  • AMemorySegment对具有给定空间和时间边界的连续内存区域进行建模。
  • AMemoryAddress模拟一个地址。通常有两种地址:_已检查的_地址是给定内存段内的偏移量,而未_检查的_地址是空间和时间边界未知的地址,就像不安全地从以下位置获取的内存地址的情况一样:本机代码。
  • AMemoryLayout是内存段内容的编程描述。

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

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

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

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

取消引用与段关联的内存是通过获取var 句柄来实现的,它是 Java 9 中引入的数据访问的抽象。特别是,使用_内存访问 var 句柄_来取消引用段。这种 var 句柄有一个类型的_访问坐标_MemoryAddress,用作取消引用发生的地址。

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

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,以支持更复杂的寻址方案,例如平面内存段的多维寻址。这种内存访问变量句柄通常是通过调用MemoryHandles类中定义的组合器方法来获得的。例如,设置本机内存段元素的更直接方法是通过_索引_内存访问 var 句柄,其构造如下:

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可以使用 a 以编程方式描述 a 的内容MemorySegment。例如,上述示例中使用的本机内存段的布局可以用以下方式描述:

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

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

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)
  • 由长值构造的地址(通过工厂MemoryAddress::ofLong

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

然而,顾名思义,此操作本质上是不安全的,必须谨慎使用。因此,当 JDK 属性foreign.restricted设置为 以外的值时,外部内存访问 API 仅允许调用此工厂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 个元素为一组排列,这应该使其更适合并行处理。

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

备择方案

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

风险和假设

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

依赖关系

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