JEP 393: 外部内存访问 API(第三个孵化版)
总结
引入一个 API,以允许 Java 程序安全高效地访问 Java 堆之外的外部内存。
历史
外部内存访问 API 最初由 JEP 370 提出,并于 2019 年底作为 孵化 API 推向 Java 14,随后在 2020 年年中通过 JEP 383 再次孵化并面向 Java 15。本 JEP 提议根据反馈意见进行改进,并在 Java 16 中重新孵化该 API。此次 API 更新包含以下更改:
MemorySegment
和MemoryAddress
接口之间的角色分离更加清晰;- 一个新的接口
MemoryAccess
,它提供通用的静态内存访问器,从而在简单情况下尽量减少对VarHandle
API 的需求; - 支持共享段;以及
- 能够将段注册到
Cleaner
。
目标
-
通用性: 单个 API 应该能够操作各种类型的外部内存(例如,本地内存、持久性内存、托管堆内存等)。
-
安全性: 不管操作的是哪种内存,API 都不应破坏 JVM 的安全性。
-
控制: 客户端应可以选择如何释放内存段:显式地(通过方法调用)或隐式地(当内存段不再使用时)。
-
可用性: 对于需要访问外部内存的程序,该 API 应该是传统 Java API(如
sun.misc.Unsafe
)的一个引人注目的替代方案。
非目标
- 不打算在 Foreign-Memory Access API 的基础上重新实现传统的 Java API,例如
sun.misc.Unsafe
。
动机
- 避免与垃圾回收相关的成本和不可预测性(尤其是在维护大型缓存时);
- 在多个进程之间共享内存;以及
- 通过将文件映射到内存来序列化和反序列化内存内容(例如,通过
mmap
)。
遗憾的是,Java API 并未提供访问外部内存的满意解决方案:
-
Java 1.4 引入的
ByteBuffer
API 允许创建 直接 字节缓冲区,这些缓冲区是在堆外分配的,因此允许用户直接从 Java 操作堆外内存。然而,直接缓冲区有其局限性。例如,由于ByteBuffer
API 使用基于int
的索引方案,因此无法创建大于 2 GB 的直接缓冲区。此外,使用直接缓冲区可能会很麻烦,因为与它们相关的内存释放交给了垃圾回收器;也就是说,只有当垃圾回收器认为直接缓冲区不可达时,相关内存才能被释放。多年来,为了克服这些及其他限制(例如 4496703、6558368、4837564 和 5029431),已经提交了许多增强请求。这些限制中的许多源于ByteBuffer
API 不仅设计用于堆外内存访问,还用于在字符集编码/解码和部分 I/O 操作等领域中生产者/消费者之间的批量数据交换。 -
开发人员可以通过另一种常见途径从 Java 代码访问外部内存,即
Unsafe
API。Unsafe
提供了许多内存访问操作(例如,Unsafe::getInt
和putInt
),由于其相对通用的寻址模型,这些操作既适用于堆内也适用于堆外访问。使用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 引入了一个安全、受支持且高效的外来内存访问 API。通过提供针对访问外来内存问题的专门解决方案,开发者将不再受限于现有 API 的限制和危险。他们还将享受到性能的提升,因为新的 API 从头开始设计时就考虑到了 JIT 优化。
描述
Foreign-Memory Access API 以名为 jdk.incubator.foreign
的 孵化模块 形式提供,位于同名的包中。它引入了三个主要的抽象概念:MemorySegment
、MemoryAddress
和 MemoryLayout
:
MemorySegment
表示具有给定空间和时间边界的连续内存区域,MemoryAddress
表示一个地址,该地址可以位于堆内或堆外,以及MemoryLayout
是对内存段内容的编程描述。
内存段可以由多种来源创建,例如本地内存缓冲区、内存映射文件、Java 数组和字节缓冲区(直接或基于堆)。例如,可以如下创建一个本地内存段:
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
...
}
这将创建一个与大小为 100 字节的原生内存缓冲区相关联的内存段。
内存段是空间受限的,这意味着它们有下限和上限。任何试图使用该段访问这些界限之外的内存的操作都将导致异常。
如上文使用的 try
-with-resource 结构所示,内存段在时间上也是有界的,这意味着它们必须被创建、使用,并在不再使用时关闭。关闭一个段可能会导致其他副作用,例如与该段关联的内存被释放。任何尝试访问已关闭的内存段都会导致异常。空间和时间的界限共同保证了外部内存访问 API 的安全性,从而确保其使用不会导致 JVM 崩溃。
内存解引用
通过获取 变量句柄(Java 9 中引入的数据访问抽象)来实现对与段关联的内存进行解引用。具体来说,一个段是通过 内存访问变量句柄 来解引用的。这种变量句柄具有一对 访问坐标:
- 类型为
MemorySegment
的坐标 —— 要解引用的内存段,以及 - 类型为
long
的坐标 —— 从该段的基地址开始的偏移量,在此位置发生解引用。
内存访问变量句柄是通过 MemoryHandles
类中的工厂方法获得的。例如,要设置本地内存段的元素,我们可以如下使用内存访问变量句柄:
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);
}
}
通过使用 MemoryHandles
类提供的一个或多个组合方法,可以将普通的内存访问变量句柄组合起来,表达更高级的访问习惯用法。通过这些方法,客户端可以例如使用投影/嵌入方法句柄对来映射内存访问变量句柄类型。客户端还可以重新排序给定内存访问变量句柄的坐标,删除一个或多个坐标,并插入新坐标。
为了使 API 更加易于访问,MemoryAccess
类提供了一些静态访问器,可以用来解引用内存段,而无需构造内存访问变量句柄。例如,有一个访问器可以在给定偏移量的段中设置一个 int
值,从而允许将上面的示例简化如下:
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
for (int i = 0; i < 25; i++) {
MemoryAccess.setIntAtOffset(segment, i * 4, i);
}
}
内存布局
为了增强 API 的表现力,并减少对如上例所示的显式数值计算的需求,可以使用 MemoryLayout
以编程方式描述 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 禁止对内存地址进行解引用操作。
要对内存地址进行解引用,客户端有两种选择。如果已知该地址位于内存段之内,客户端可以执行 rebase 操作(MemoryAddress::segmentOffset
)。重新定位操作会将地址的偏移量相对于段的基地址重新解释,以生成一个新偏移量,该偏移量可应用于现有段 —— 然后可以安全地对该段进行解引用。
或者,如果不存在这样的段,则客户端可以使用特殊的 MemoryAddress::asSegmentRestricted
工厂方法来不安全地创建一个段。该工厂方法实际上会为一个未经检查的地址附加空间和时间界限,以便允许解引用操作。顾名思义,此操作本质上是不安全的,因此必须谨慎使用。出于这个原因,Foreign Memory Access API 仅允许在 JDK 系统属性 foreign.restricted
设置为除 deny
以外的值时调用此工厂方法。该属性的可能值包括:
deny
— 在每次受限调用时抛出运行时异常(这是默认值);permit
— 允许受限调用;warn
— 类似于permit
,但在每次受限调用时会打印一行警告;debug
— 类似于permit
,但还会转储与任何给定受限调用对应的堆栈。
我们计划在未来将受限操作的访问与模块系统集成。某些模块可能会以某种方式声明它们需要受限的本地访问权限。当依赖这些模块的应用程序运行时,用户可能需要为这些模块提供执行受限本地操作的权限,否则运行时将拒绝运行该应用程序。
限制
除了空间和时间界限外,片段还具有线程限制特性。也就是说,一个片段由创建它的线程拥有,其他线程无法访问该片段的内容,也无法对其执行某些操作(例如 close
)。尽管线程限制有一定的约束性,但它在即使多线程环境中也至关重要,可以确保最佳的内存访问性能。
外部内存访问 API 提供了多种方式来放宽线程限制屏障。首先,线程可以通过执行显式的 handoff 操作来协作共享段,其中一个线程释放其对某个段的所有权并将其转移给另一个线程。请看以下代码:
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,我们就可以使用它来构建并行流,并对段的内容进行并行求和。由于 spliterator 操作的段是共享的,因此可以从多个线程并发访问该段。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 确保其中只有一个线程能够成功获取该共享段的所有权。
隐式释放
内存段具有确定性的释放特性,但它们也可以注册到 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 将有助于实现 Project Panama 目标之一的原生互操作支持的开发。该 API 还可以用于以更通用和高效的方式访问非易失性内存,这在之前已经可以通过 JEP 352(非易失性映射字节缓冲区) 实现。