Skip to content

JEP 383: Foreign-Memory Access API (Second Incubator) | 外部内存访问 API(第二次孵化版)

摘要

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

历史

Foreign-Memory Access API 由 JEP 370 提出,并计划在 2019 年底的 Java 14 中作为 孵化 API 推出。本 JEP 提议根据反馈意见进行改进,并在 Java 15 中重新孵化该 API。作为此次 API 更新的一部分,以下更改已被纳入:

  • 丰富的 VarHandle 组合 API,用于定制内存访问 var handles;
  • 通过 Spliterator 接口为目标内存段提供并行处理支持;
  • 增强对 映射 内存段的支持(例如,MappedMemorySegment::force);
  • 安全 API 点以支持串行限制(例如,在两个线程之间转移线程所有权);以及
  • 不安全 API 点,用于操作和解引用来自例如本地调用的地址,或将这些地址封装成合成内存段。

目标

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

非目标

  • 本项目的目标不是基于外部内存访问 API 重新实现传统 Java API(如 sun.misc.Unsafe)。

动机

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

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

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

  • Java 1.4 中引入的 ByteBuffer API 允许创建 直接 字节缓冲区,这些缓冲区在堆外分配,因此允许用户直接从 Java 中操作堆外内存。但是,直接缓冲区存在限制。例如,由于 ByteBuffer API 使用基于 int 的索引方案,因此无法创建大于两吉字节的直接缓冲区。此外,使用直接缓冲区可能会很麻烦,因为与它们关联的内存的释放留给垃圾收集器来处理;也就是说,只有在垃圾收集器认为直接缓冲区不可达时,才能释放相关内存。多年来,为了克服这些和其他限制(例如,4496703655836848375645029431),已经提出了许多增强请求。这些限制中的许多都源于 ByteBuffer API 的设计初衷,它不仅用于访问堆外内存,还用于字符集编码 / 解码和部分 I/O 操作等领域中的生产者 / 消费者批量数据交换。

  • 开发者从 Java 代码访问外部内存的另一种常见途径是 Unsafe APIUnsafe 通过相对通用的寻址模型提供了许多内存访问操作(如 Unsafe::getIntputInt),这些操作既适用于堆内也适用于堆外访问。使用 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 优化。

说明

外部内存访问 API 以名为 jdk.incubator.foreign孵化器模块 的形式提供,并在同名的包中引入了三个主要抽象:MemorySegmentMemoryAddressMemoryLayout

  • MemorySegment 模拟具有给定空间和时间边界的连续内存区域。
  • MemoryAddress 模拟地址。通常有两种地址:已检查 地址是给定内存段内的偏移量,而 未检查 地址是空间和时间边界未知的地址,如从本地代码(不安全地)获得的内存地址。
  • MemoryLayout 是内存段内容的程序化描述。

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

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

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

内存段是 空间有界的,意味着它们有下限和上限。任何尝试使用该段访问这些界限之外的内存都将导致异常。正如使用 try-with-resource 结构所证明的,内存段也是 时间有界的,这意味着它们必须在不再使用时被创建、使用和关闭。关闭一个段总是一个明确的操作,并可能导致其他副作用,如释放与该段关联的内存。任何尝试访问已关闭的内存段的操作都将导致异常。空间和时间的界限共同保证了外部内存访问 API 的安全性,从而保证了其使用不会导致 JVM 崩溃。

通过获取 var handle 来取消引用与段关联的内存,var handle 是 Java 9 中引入的数据访问抽象。特别是,段通过 内存访问 var handle 进行取消引用。这种 var handle 具有类型为 MemoryAddress访问坐标,用作取消引用发生的地址。

使用 MemoryHandles 类中的工厂方法获取内存访问 var handles。例如,要设置本地内存段的元素,我们可以使用内存访问 var handle 如下:

java
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 handles 可以获取额外的访问坐标,类型为 long,以支持更复杂的寻址方案,例如对原本平坦的内存段进行多维寻址。通常,通过调用 MemoryHandles 类中定义的组合器方法来获取此类内存访问 var handles。例如,更直接地设置本地内存段元素的方法是通过构造一个 带索引的 内存访问 var handle,如下所示:

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

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

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

java
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);
    }
}

在此示例中,布局对象通过创建 布局路径 来驱动内存访问 var handle 的创建,该布局路径用于从复杂的布局表达式中选择嵌套布局。布局对象还基于从布局中派生的大小和对齐信息来驱动本地内存段的分配。前面示例中的循环常量(25)已被序列布局的元素计数所替代。

已检查和未检查的地址

只有 已检查 的内存地址才能进行解引用操作。在 API 中,已检查的地址很常见,例如从上述代码中的内存段获取的地址(segment.baseAddress())。但是,如果内存地址是 未检查的 并且没有关联的任何段,则无法安全地对其进行解引用,因为运行时无法知道与该地址关联的空间和时间边界。未检查的地址的示例包括:

  • NULL 地址(MemoryAddress::NULL
  • 从长整数值构造的地址(通过 MemoryAddress::ofLong 工厂方法)

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

顾名思义,这种操作本质上是不安全的,必须谨慎使用。因此,外部内存访问 API 仅在 JDK 属性 foreign.restricted 设置为除 deny 以外的值时,才允许调用此工厂方法。该属性的可能值包括:

  • deny - 对每个受限调用引发运行时异常。这是默认值;
  • permit - 允许受限调用;
  • warn - 与 permit 类似,但还会在每个受限调用时打印一行警告。
  • debug - 与 permit 类似,但还会转储与任何给定受限调用对应的堆栈信息。

我们计划在未来使受限操作的访问与模块系统更加集成;也就是说,某些模块可能 需要 受限的本地访问权限;当依赖这些模块的应用程序执行时,用户可能需要为这些模块提供执行受限本地操作的 权限,否则运行时将拒绝构建应用程序的模块图。

封闭性

除了空间和时间边界外,段还具有线程封闭性。也就是说,段由其创建的线程 拥有,其他线程无法访问段上的内容,也无法在段上执行某些操作(如 close)。尽管线程封闭性具有限制性,但对于保证在多线程环境中获得最佳内存访问性能至关重要。如果移除线程封闭性限制,多个线程可能会同时访问和关闭相同的段,这可能会破坏外部内存访问 API 提供的安全保证,除非引入一些非常昂贵的锁定形式来防止访问与关闭的竞争。

外部内存访问 API 提供了两种方法来放宽线程封闭性屏障。首先,线程可以通过执行显式的 交接 操作来协作共享段,其中一个线程释放其对给定段的所有权并将其转移到另一个线程。请考虑以下代码:

java
MemorySegment segmentA = MemorySegment.allocateNative(10); // 由线程 A 封闭
...
var segmentB = segmentA.withOwnerThread(threadB); // 由线程 B 封闭

这种访问模式也被称为 串行封闭,并可能在生产者 / 消费者用例中很有用,其中只有一个线程需要同时访问一个段。请注意,为了使交接操作安全,API销毁 原始段(就像调用了 close,但没有释放底层内存)并返回具有正确所有者的 段。实现还确保在第二个线程访问段之前,第一个线程的所有写入都已刷新到内存中。

其次,仍然可以 并行 处理内存段的内容(例如,使用 Fork/Join 等框架)——通过从内存段中获取 Spliterator 实例。例如,要并行地计算内存段中所有 32 位值的和,我们可以使用以下代码:

java
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 个元素为一组进行排列——这应该使其更易于进行并行处理。

一旦我们有了 spliterator,我们就可以使用它来构建一个并行流,并在并行环境中对段的内容进行求和。尽管这里的段由多个线程并发访问,但访问以常规方式发生:从原始段中创建一个切片,并将其交给一个线程以执行某些计算。外部内存访问运行时知道是否有线程当前正在通过 spliterator 访问段的切片,因此可以通过在相同段上进行并行处理时 允许关闭段来强制安全。

备选方案

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

风险和假设

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

依赖项

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