JEP 370:外部内存访问 API(孵化中)
概述
引入一个 API,以允许 Java 程序安全高效地访问 Java 堆之外的外部内存。
目标
外部内存 API 应满足以下标准:
- 通用性: 相同的 API 应该能够操作各种类型的外部内存(例如,本地内存、持久性内存、托管堆内存等)。
- 安全性: 不管操作的是哪种内存,API 都不应破坏 JVM 的安全性。
- 确定性: 内存释放操作应在源代码中明确指定。
成功指标
外部内存 API 应该是一个有效的替代方案,以取代目前 Java 程序访问外部内存的主要途径,即 java.nio.ByteBuffer
和 sun.misc.Unsafe
。新的 API 在性能上应与这些现有的 API 具有竞争力。
动机
- 避免与垃圾回收相关的成本和不可预测性(尤其是在维护大型缓存时),
- 在多个进程之间共享内存,以及
- 通过将文件映射到内存来序列化和反序列化内存内容(例如,通过
mmap
)。
但是,Java API 并未提供访问外部内存的满意解决方案。
Java 1.4 中引入的 ByteBuffer
API 允许创建直接字节缓冲区,这些缓冲区是在堆外分配的,并允许用户直接从 Java 操作堆外内存。然而,直接缓冲区有其局限性。例如,由于 ByteBuffer
API 使用基于 int
的索引方案,因此无法创建大于 2 GB 的缓冲区。此外,与直接缓冲区相关联的内存释放交由垃圾回收器处理,这使得操作直接缓冲区变得繁琐;也就是说,只有当垃圾回收器认为某个直接缓冲区不可达时,与其关联的内存才能被释放。多年来,为了克服这些及其他限制(例如,4496703、6558368、4837564 和 5029431),已经提交了许多增强请求。这些限制中的许多源于这样一个事实:ByteBuffer
API 不仅设计用于堆外内存访问,还设计用于对批量数据进行生产者/消费者交换,而这对字符集编码/解码和部分 I/O 操作等功能至关重要。
开发者通过 sun.misc.Unsafe
API 也可以使用另一个常见的途径从 Java 代码访问外部内存。Unsafe
暴露了许多内存访问操作(例如,Unsafe::getInt
和 putInt
),由于采用了智能且相对通用的寻址模型,这些操作既可以用于堆内访问,也可以用于堆外访问。使用 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 引入了三个主要的抽象概念:MemorySegment
、MemoryAddress
和 MemoryLayout
。
MemorySegment
用于对具有给定空间和时间边界的连续内存区域进行建模。MemoryAddress
可以看作是段内的偏移量。最后,MemoryLayout
是内存段内容的编程描述。
内存段可以由多种来源创建,例如本地内存缓冲区、Java 数组和字节缓冲区(直接或基于堆)。例如,可以如下创建一个本地内存段:
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
...
}
这将创建一个与大小为 100 字节的原生内存缓冲区相关联的内存段。
内存段在空间上是有边界的;也就是说,它们有下限和上限。任何试图使用该段访问这些边界之外的内存的操作都会导致异常。通过 try
-with-resource 构造的使用可以看出,内存段在时间上也是有边界的;也就是说,它们被创建、使用,然后在不再使用时关闭。关闭一个段始终是一个显式操作,并可能导致额外的副作用,例如与该段关联的内存被释放。任何试图访问已关闭的内存段的操作都会导致异常。总之,空间和时间上的安全性检查对于保证内存访问 API 的安全性至关重要,从而避免例如严重的 JVM 崩溃等问题。
要对与某个段(segment)关联的内存进行解引用,可以通过获取一个 内存访问变量句柄(memory-access var handle) 来实现。这些特殊的变量句柄至少有一个强制性的访问坐标,其类型为 MemoryAddress
,这是解引用操作发生的地址。它们是通过 MemoryHandles
类中的工厂方法获得的。例如,要设置一个原生段(native segment)的元素,我们可以如下使用内存访问变量句柄:
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 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 以编程方式描述内存段的内容。例如,上述示例中使用的原生内存段的布局可以按照以下方式描述:
SequenceLayout intArrayLayout
= MemoryLayout.ofSequence(25,
MemoryLayout.ofValueBits(32,
ByteOrder.nativeOrder()));
这将创建一个序列内存布局,其中给定的元素布局(一个 32 位值)重复 25 次。一旦我们有了一个内存布局,我们就可以摆脱代码中所有手动的数值计算,还可以简化所需内存访问变量句柄的创建,如下例所示:
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);
}
}
在此示例中,布局实例通过创建 布局路径 来驱动内存访问变量句柄的创建,该路径用于从复杂的布局表达式中选择嵌套布局。布局实例还驱动本机内存段的分配,这是基于从布局中派生的大小和对齐信息。前面示例中的循环常量已替换为序列布局的元素计数。
外部内存访问 API 最初会作为孵化模块提供,名为 jdk.incubator.foreign
,位于同名的包中。
替代方案
继续使用现有的 API,例如 ByteBuffer
和 Unsafe
,或者更糟糕的是,使用 JNI。
风险与假设
创建一个既安全又高效的外部内存访问 API 是一项艰巨的任务。由于前几节中描述的空间和时间检查需要在每次访问时执行,因此 JIT(即时编译器)能够通过将这些检查优化掉(例如,将其提升到热循环之外)是至关重要的。JIT 的实现可能需要一些工作,以确保使用内存访问 API 的效率和可优化性与现有的 API(如 ByteBuffer
和 Unsafe
)相当。
依赖
本 JEP 中描述的 API 可能有助于实现 Project Panama 目标之一的原生互操作支持的开发。该 API 还可以用于以更通用和高效的方式访问非易失性内存,这在之前已经可以通过 JEP 352(非易失性映射字节缓冲区) 实现。