跳到主要内容

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

概括

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

历史

外部内存访问 API 最初由JEP 370提出,并在 2019 年底作为孵化 API面向 Java 14 ,随后由JEP 383重新孵化,该 API 于 2020 年中期面向 Java 15。该 JEP 建议合并基于根据反馈,并在 Java 16 中重新孵化该 API。此 API 刷新中包含以下更改:

  • MemorySegment和接口之间的角色划分更加清晰MemoryAddress
  • 一个新的接口MemoryAccess,提供通用的静态内存访问器,以尽量减少VarHandle简单情况下对 API 的需求;
  • 支持_共享_段;和
  • 能够使用Cleaner.

目标

  • _通用性:_单个 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 句柄具有一对_访问坐标_:

  • 类型的坐标MemorySegment——要取消引用其内存的段,以及
  • 类型的坐标long— 距段基地址的偏移量,在该处发生取消引用。

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

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

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

可以通过使用类提供的一种或多种组合器方法组合普通内存访问 var 句柄来表达更高级的访问习惯用法MemoryHandles。例如,通过这些,客户端可以使用投影/嵌入方法句柄对来映射内存访问 var 句柄类型。客户端还可以重新排序给定内存访问变量句柄的坐标、删除一个或多个坐标以及插入新坐标。

为了使 API 更易于访问,该类MemoryAccess提供了许多静态访问器,可用于取消引用内存段,而无需构造内存访问 var 句柄。例如,有一个访问器可以int在给定偏移量的段中设置一个值,从而可以将上面的示例简化如下:

try (MemorySegment segment = MemorySegment.allocateNative(100)) {
for (int i = 0; i < 25; i++) {
MemoryAccess.setIntAtOffset(segment, i * 4, 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)) {
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
indexedElementHandle.set(segment, (long) i, i);
}
}

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

未检查的段

解引用操作只能在内存段上进行。由于内存段具有空间和时间界限,因此运行时始终可以确保与给定段关联的内存被安全地取消引用。但在某些情况下,客户端可能只有一个内存地址;例如,与本机代码交互时经常出现这种情况。此外,可以从值构造内存地址long(通过MemoryAddress::ofLong工厂)。在这种情况下,由于运行时无法知道与内存地址关联的空间和时间边界,因此 API 禁止取消引用内存地址。

要取消引用内存地址,客户端有两种选择。如果已知地址属于内存段,则客户端可以执行_变基_操作 ( MemoryAddress::segmentOffset)。变基操作重新解释地址相对于段基地址的偏移量,以产生一个新的偏移量,该偏移量可以应用于现有的段,然后可以安全地取消引用。

或者,如果不存在这样的段,则客户端可以使用特殊MemoryAddress::asSegmentRestricted工厂不安全地创建一个段。该工厂有效地将空间和时间边界附加到未经检查的地址,以便允许取消引用操作。顾名思义,此操作本质上是不安全的,因此必须谨慎使用。因此,当 JDK 系统属性foreign.restricted设置为 以外的值时,外部内存访问 API 仅允许调用此工厂deny。该属性的可能值为:

  • deny— 在每个受限调用上发出运行时异常(这是默认值);
  • permit— 允许受限呼叫;
  • warn— 与 类似permit,但还在每个受限调用上打印一行警告;和
  • debug— 与 类似permit,但也转储与任何给定的受限调用相对应的堆栈。

我们计划将来将对受限操作的访问与模块系统集成。某些模块可能会以某种方式声明它们_需要_受限的本机访问。当执行依赖于此类模块的应用程序时,用户可能需要向这些模块提供执行受限本机操作的权限,否则运行时将拒绝运行该应用程序。

坐月子

除了空间和时间界限之外,段还具有_线程限制的_特征。也就是说,段由创建它的线程_拥有_,任何其他线程都不能访问该段的内容,或对其执行某些操作(例如close)。线程限制虽然具有限制性,但对于保证最佳内存访问性能至关重要,即使在多线程环境中也是如此。

外部内存访问 API 提供了多种放松线程限制障碍的方法。首先,线程可以通过执行显式_切换_操作来协作共享段,其中线程释放其对给定段的所有权并将其转移给另一个线程。考虑以下代码:

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

这种访问模式也称为_串行限制_,在一次只有一个线程需要访问段的生产者/消费者用例中非常有用。请注意,为了使切换操作安全,API_会杀死_原始段(就像close被调用一样,但不会释放底层内存)并返回具有正确所有者的新段。该实现还确保在第二个线程访问该段时,第一个线程的所有写入都已刷新到内存。

当串行限制不够时,客户端可以选择删除线程所有权,即将受限制的段转变为可以由多个线程同时访问和关闭的_共享_段。和以前一样,共享段会杀死原始段并返回一个没有所有者线程的新段:

MemorySegment segmentA = MemorySegment.allocateNative(10); // confined by thread A
...
var sharedSegment = segmentA.withOwnerThread(null); // now a shared segment

当多个线程需要并行操作段的内容(例如,使用诸如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, sequenceElement());

int sum = StreamSupport.stream(MemorySegment.spliterator(segment.withOwnerThread(null),
seq_bulk),
true)
.mapToInt(slice -> {
int res = 0;
for (int i = 0; i < 100 ; i++) {
res += MemoryAccess.getIntAtIndex(slice, i);
}
return res;
}).sum();

MemorySegment::spliterator方法采用一个段和一个_序列_布局,并返回一个 spliterator 实例,该实例将段分割成与所提供的序列布局中的元素相对应的块。在这里,我们想要对包含一百万个元素的数组中的元素求和。在每次计算只处理一个元素的情况下进行并行求和是低效的,因此我们使用布局 API 来派生_批量_序列布局。批量布局是一种序列布局,其大小与原始布局相同,但其中的元素被排列成 100 个元素的组,这使其更适合并行处理。

一旦我们有了分割器,我们就可以用它来构造一个并行流并并行地对段的内容求和。由于分割器操作的段是共享的,因此可以从多个线程同时访问该段。 spliterator API 确保访问以常规方式进行:它从原始段创建切片,并将每个切片提供给线程以执行所需的计算,从而确保没有两个线程可以在同一内存区域上同时操作。

如果传递段的线程不知道哪个其他线程将继续在该段上工作,则共享段对于执行串行限制也很有用,例如:

// thread A
MemorySegment segment = MemorySegment.allocateNative(10); // confined by thread A
// do some work
segment = segment.withOwnerThread(null);

// thread B
segment.withOwnerThread(Thread.currentThread()); // now confined by thread B
// do some more work

多个线程可以竞相获取给定的共享段,但 API 确保只有其中一个线程能够成功获取共享段的所有权。

隐式释放

内存段具有确定性释放功能,但也可以使用 a 进行注册,Cleaner以确保当垃圾收集器确定该段不再可达时,释放与该段关联的内存资源:

MemorySegment segment = MemorySegment.allocateNative(100);
Cleaner cleaner = Cleaner.create();
segment.registerCleaner(cleaner);
// do some work
segment = null; // Cleaner might reclaim the segment memory now

向清理器注册段并不会阻止客户端MemorySegment::close显式调用。 API 保证段的清理操作最多被调用一次 — 显式(由客户端代码)或隐式(由清理器)调用。由于无法访问的段(根据定义)不能被任何线程访问,因此清理器始终可以释放与无法访问的段关联的任何内存资源,无论它是受限段还是共享段。

备择方案

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

风险和假设

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

依赖关系

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