JEP 393: Foreign-Memory Access API (Third Incubator) | 外部内存访问 API(第三个孵化器)
摘要
引入一个 API,允许 Java 程序安全且高效地访问 Java 堆之外的外部内存。
历史
外部内存访问 API 最初由 JEP 370 提出,并计划在 2019 年底的 Java 14 中作为 孵化 API 发布,之后由 JEP 383 重新孵化,该 JEP 计划在 2020 年中期的 Java 15 中发布。本 JEP 提议根据反馈进行改进,并在 Java 16 中重新孵化该 API。此 API 刷新包括以下更改:
- 在
MemorySegment
和MemoryAddress
接口之间更清晰地分离角色; - 引入新接口
MemoryAccess
,提供通用的静态内存访问器,以在简单情况下尽量减少对VarHandle
API 的需求; - 支持 共享 段;
- 能够将段注册到
Cleaner
中。
目标
通用性: 单一 API 应能够操作各种类型的外部内存(例如,本地内存、持久内存、托管堆内存等)。
安全性: 无论操作哪种内存,API 都不应破坏 JVM 的安全性。
控制: 客户端应有权选择如何释放内存段:显式(通过方法调用)或隐式(当段不再使用时)。
可用性: 对于需要访问外部内存的程序,该 API 应成为对传统 Java API(如
sun.misc.Unsafe
)的有吸引力的替代方案。
非目标
- 目标不是在外部内存访问 API 之上重新实现旧的 Java API,如
sun.misc.Unsafe
。
动机
许多 Java 程序会访问外部内存,例如 Ignite、mapDB、memcached、Lucene 以及 Netty 的 ByteBuf API。通过这样做,它们可以:
- 避免与垃圾回收相关的成本和不可预测性(尤其是在维护大型缓存时);
- 在多个进程之间共享内存;
- 通过将文件映射到内存中来序列化和反序列化内存内容(例如,通过
mmap
)。
不幸的是,Java API 并没有为访问外部内存提供一个令人满意的解决方案:
Java 1.4 中引入的
ByteBuffer
API 允许创建 直接 字节缓冲区,这些缓冲区在堆外分配,因此允许用户直接从 Java 操作堆外内存。然而,直接缓冲区是有限制的。例如,由于ByteBuffer
API 使用基于int
的索引方案,因此无法创建大于两吉字节的直接缓冲区。此外,处理直接缓冲区可能很繁琐,因为与它们关联的内存的释放留给了垃圾收集器;也就是说,只有在垃圾收集器认为直接缓冲区不可达之后,才能释放相关内存。多年来,为了克服这些和其他限制(例如,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(Java Enhancement Proposal)引入了一个安全、受支持和高效的 API,用于访问外部内存。通过为访问外部内存的问题提供有针对性的解决方案,开发人员将摆脱现有 API 的限制和危险。同时,由于新 API 从底层开始设计时就考虑到了 JIT(即时编译器)优化,因此他们还将享受性能上的提升。
描述
Foreign-Memory Access API 作为一个名为 jdk.incubator.foreign
的 孵化器模块(incubator module)提供,位于同名的包中。它引入了三个主要的抽象概念:MemorySegment
、MemoryAddress
和 MemoryLayout
:
MemorySegment
表示具有给定空间和时间界限的连续内存区域,MemoryAddress
表示一个地址,该地址可以位于堆上或堆外,MemoryLayout
是对内存段内容的程序化描述。
可以从多种来源创建内存段,如本地内存缓冲区、内存映射文件、Java 数组和字节缓冲区(无论是直接的还是基于堆的)。例如,可以按以下方式创建本地内存段:
try (MemorySegment segment = MemorySegment.allocateNative(100)) {
...
}
这将创建一个与大小为 100 字节的本地内存缓冲区相关联的内存段。
内存段是 空间有界的,这意味着它们有下限和上限。任何尝试使用此段访问这些界限之外的内存的操作都将导致异常。
正如上面使用 try
-with-resource 结构所证明的那样,内存段也是 时间有界的,这意味着它们必须被创建、使用,然后在不再使用时被关闭。关闭一个段可能会导致额外的副作用,比如释放与该段相关联的内存。任何尝试访问已经关闭的内存段的操作都会导致异常。空间和时间界限的结合保证了 Foreign-Memory Access API 的安全性,从而保证了其使用不会导致 JVM 崩溃。
内存解引用
通过获取 var handle(Java 9 中引入的数据访问抽象)来实现与段相关联的内存的解引用。特别是,使用 内存访问 var handle 来对段进行解引用。这种类型的 var handle 具有一对 访问坐标:
- 类型为
MemorySegment
的坐标——要解引用的内存段, - 类型为
long
的坐标——从段的基地址开始的偏移量,解引用操作在此处发生。
使用 MemoryHandles
类中的工厂方法来获取内存访问 var handle。例如,为了设置本地内存段的元素,我们可以使用如下内存访问 var handle:
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
类提供的一个或多个组合器方法,将普通的内存访问 var handle 组合在一起来表达。通过这些方法,客户端可以例如使用投影 / 嵌入方法句柄对来映射内存访问 var handle 的类型。客户端还可以重新排序给定内存访问 var handle 的坐标,丢弃一个或多个坐标,并插入新的坐标。
为了使 API 更易于访问,MemoryAccess
类提供了一系列静态访问器,这些访问器可用于在不需要构造内存访问 var handle 的情况下对内存段进行解引用。例如,有一个访问器可以在段中的给定偏移量处设置一个 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 handle 的创建,如下例所示:
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);
}
}
在这个例子中,布局对象通过创建 布局路径 来驱动内存访问 var handle 的创建,该布局路径用于从复杂的布局表达式中选择嵌套布局。布局对象还根据从布局中派生的大小和对齐信息来驱动本地内存段的分配。之前示例中的循环常量(25
)已被序列布局的元素计数所替代。
未检查的段
解引用操作仅适用于内存段。由于内存段具有空间和时间界限,运行时可以始终确保与给定段相关联的内存被安全地解引用。但是,在某些情况下,客户端可能只有内存地址;例如,与本地代码交互时经常会出现这种情况。此外,可以通过 long
值(通过 MemoryAddress::ofLong
工厂方法)构造内存地址。在这些情况下,由于运行时无法知道与内存地址相关联的空间和时间界限,因此 API 禁止解引用内存地址。
为了解引用内存地址,客户端有两个选项。如果地址已知位于某个内存段内,则客户端可以执行 重新定位(rebase)操作(MemoryAddress::segmentOffset
)。重新定位操作会重新解释地址相对于段基地址的偏移量,以生成可应用于现有段的新偏移量,然后可以安全地对该段进行解引用。
或者,如果不存在这样的段,则客户端可以使用特殊的 MemoryAddress::asSegmentRestricted
工厂方法不安全地创建一个。此工厂方法实际上为未经检查的地址附加了空间和时间界限,以便允许解引用操作。顾名思义,此操作本质上是不安全的,因此必须谨慎使用。出于这个原因,当 JDK 系统属性 foreign.restricted
被设置为除 deny
以外的值时,外部内存访问 API 才允许调用此工厂方法。此属性的可能值包括:
deny
— 在每次受限调用时抛出运行时异常(这是默认值);permit
— 允许受限调用;warn
— 与permit
相同,但还会在每次受限调用时打印一行警告信息;debug
— 与permit
相同,但还会转储与任何给定受限调用相对应的堆栈信息。
我们计划在未来将受限操作的访问与模块系统相结合。某些模块可能会以某种方式声明它们 需要 受限的本机访问。当执行依赖于这些模块的应用程序时,用户可能需要为这些模块提供执行受限本机操作的权限,否则运行时将拒绝运行该应用程序。
隔离
除了空间和时间界限外,段还具有 线程隔离 特性。即,一个段由创建它的线程 拥有,其他线程无法访问段的内容,也无法在其上执行某些操作(如 close
)。尽管线程隔离具有限制性,但它对于保证即使在多线程环境中也能实现最佳内存访问性能至关重要。
Foreign-Memory Access API 提供了几种方法来放宽线程隔离的界限。首先,线程可以通过执行显式的 交接(handoff)操作来协作共享段,在该操作中,一个线程释放其对给定段的所有权并将其传输给另一个线程。请考虑以下代码:
MemorySegment segmentA = MemorySegment.allocateNative(10); // 限定于线程 A
...
var segmentB = segmentA.withOwnerThread(threadB); // 现在限定于线程 B
这种访问模式也被称为 串行隔离(serial confinement),在生产者 / 消费者用例中非常有用,其中一次只有一个线程需要访问一个段。请注意,为了使交接操作安全,API 会 销毁 原始段(就像调用了 close
一样,但不会释放底层内存),并返回一个具有正确所有者的新段。实现还确保当第二个线程访问段时,第一个线程的所有写入都已刷新到内存中。
如果串行隔离不够,则客户端可以选择移除线程所有权,即将受限段转换为 共享 段,该段可以由多个线程并发访问和关闭。与之前一样,共享段会销毁原始段并返回一个没有所有者线程的新段:
MemorySegment segmentA = MemorySegment.allocateNative(10); // 由线程 A 限定
...
var sharedSegment = segmentA.withOwnerThread(null); // 现在是一个共享段
当多个线程需要并行操作段的内容时(例如,使用 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 确保以常规方式访问:它从原始段中创建切片,并将每个切片交给一个线程以执行所需的计算,从而确保没有两个线程可以同时对同一内存区域进行操作。
在传递段的线程不知道哪个其他线程将继续在该段上工作的情况下,共享段还可以用于执行串行限制,例如:
// 线程 A
MemorySegment segment = MemorySegment.allocateNative(10); // 由线程 A 限制
// 执行一些工作
segment = segment.withOwnerThread(null);
// 线程 B
segment.withOwnerThread(Thread.currentThread()); // 现在由线程 B 限制
// 执行更多工作
多个线程可以竞争获取给定的共享段,但 API 确保只有其中一个线程将成功获取共享段的所有权。
隐式释放
内存段具有确定性释放功能,但它们也可以与 Cleaner
注册,以确保当垃圾收集器确定段不再可达时,释放与段关联的内存资源:
MemorySegment segment = MemorySegment.allocateNative(100);
Cleaner cleaner = Cleaner.create();
segment.registerCleaner(cleaner);
// 执行一些工作
segment = null; // 现在 Cleaner 可能会回收段内存
将一个段与清理器注册并不会阻止客户端显式调用 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(非易失性映射字节缓冲区) 实现。