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 程序都会访问外部内存,例如Ignite、mapDB、memcached、Lucene和 Netty 的ByteBuf API。通过这样做,他们可以:
- 避免与垃圾收集相关的成本和不可预测性(特别是在维护大型缓存时);
- 跨多个进程共享内存;和
- 通过将文件映射到内存(例如,通过
mmap
)来序列化和反序列化内存内容。
不幸的是,Java API 没有提供令人满意的访问外部内存的解决方案:
-
Java 1.4 中引入的API允许创建_直接字节缓冲区,这些_
ByteBuffer
缓冲区在堆外分配,因此允许用户直接从 Java 操作堆外内存。然而,直接缓冲区是有限的。例如,由于API 使用基于 的索引方案,因此不可能创建大于 2 GB 的直接缓冲区。此外,使用直接缓冲区可能很麻烦,因为与它们相关的内存的释放是留给垃圾收集器的;也就是说,只有在垃圾收集器认为直接缓冲区不可访问后,才能释放关联的内存。多年来,为了克服这些和其他限制,已经提出了许多增强请求(例如, 4496703、6558368、4837564和5029431 )。其中许多限制源于这样一个事实:API不仅设计用于堆外内存访问,还设计用于字符集编码/解码和部分 I/O 操作等领域的批量数据的生产者/消费者交换。ByteBuffer``int``ByteBuffer
-
开发人员从 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 到本机的转换。
总而言之,在访问外部内存时,开发人员面临着一个两难的境地:是应该选择安全但有限(而且可能效率较低)的路径,例如 API ByteBuffer
,还是应该放弃安全保证,拥抱危险且不受支持的路径?Unsafe
API?
此 JEP 引入了一个安全、受支持且高效的 API,用于外部内存访问。通过为访问外部内存的问题提供有针对性的解决方案,开发人员将摆脱现有 API 的限制和危险。他们还将享受到更高的性能,因为新的 API 将从头开始设计,并考虑到 JIT 优化。
描述
外部内存访问 API 作为名为 的孵化器模块提供jdk.incubator.foreign
,位于同名包中;它引入了三个主要抽象:MemorySegment
、MemoryAddress
和MemoryLayout
:
- A
MemorySegment
对具有给定空间和时间边界的连续内存区域进行建模。 - A
MemoryAddress
模拟一个地址。通常有两种地址:_已检查的_地址是给定内存段内的偏移量,而未_检查的_地址是空间和时间边界未知的地址,就像不安全地从以下位置获取的内存地址的情况一样:本机代码。 - A
MemoryLayout
是内存段内容的编程描述。
内存段可以从多种来源创建,例如本机内存缓冲区、内存映射文件、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()));