JEP 352: 非易失性映射字节缓冲区
概述
新增 JDK 特定的文件映射模式,以便 FileChannel
API 可用于创建引用非易失性内存的 MappedByteBuffer
实例。
目标
这个 JEP 提议对 MappedByteBuffer
进行升级,以支持访问非易失性内存 (NVM)。唯一需要的 API 更改是一个新的枚举,FileChannel
客户端使用它来请求映射位于 NVM 支持的文件系统上的文件,而不是传统的文件存储系统。最近对 MappedByteBufer
API 的更改意味着它支持所有允许直接内存更新的行为,并提供高级 Java 客户端库实现持久数据类型(例如,块文件系统、带日志的记录、持久对象等)所需的持久性保证。FileChannel
和 MappedByteBuffer
的实现需要进行修订,以识别这种映射文件的新后备类型。
此 JEP 的主要目标是确保客户端能够从 Java 程序中高效且连贯地访问和更新 NVM(非易失性内存)。这一目标的关键要素是确保对缓冲区的单个写入(或少量连续写入组)能够以最小的开销被提交,即确保可能仍在缓存中的任何更改都被写回到内存中。
第二个从属目标是使用类 Unsafe
中定义的受限 JDK 内部 API 来实现此提交行为,从而允许除 MappedByteBuffer
之外可能需要提交 NVM 的其他类重用该行为。
最后一个相关目标是,允许通过 NVM 映射的缓冲区被现有的监控和管理 API 跟踪。
注意:目前已经可以将 NVM 设备文件映射到 MappedByteBuffer
,并通过当前的 force()
方法提交写入操作,例如使用 Intel 的 libpmem
库作为设备驱动程序,或者通过调用 libpmem
作为本地库来实现。然而,使用当前的 API,这两种实现方式都属于“一刀切”解决方案。force
无法区分干净和脏的缓存行,并且每次回写都需要通过系统调用或 JNI 调用来实现。基于这两个原因,现有的功能无法满足本 JEP 的效率要求。
此 JEP 的目标操作系统/ CPU 平台组合为 Linux/x64 和 Linux/AArch64。这一限制出于两个原因。此功能仅在支持 mmap
系统调用 MAP_SYNC
标志的操作系统上有效,该标志允许非易失性内存的同步映射。最近的 Linux 版本符合这一要求。此外,它也只适用于支持用户空间控制下缓存行写回的 CPU。x64 和 AArch64 都提供了满足该要求的指令。
非目标
本 JEP 的目标仅限于提供对非易失性内存(NVM)的访问和持久性保证,并不包括其他重要行为,例如 NVM 的原子更新、读写隔离或独立持久化内存状态的一致性。
近期的 Windows/x64 版本确实支持 mmap 的 MAP_SYNC
标志。然而,为该操作系统/处理器组合(或任何其他可能的平台)提供此功能的目标已推迟到后续更新。
成功指标
效率目标很难精确量化。然而,将数据持久化到内存的成本应该相对于现有的两种替代方案显著降低。首先,它应该显著改善将数据同步写入传统文件存储所产生的成本,即包括确保单个写入保证命中磁盘所需的通常延迟。其次,相对于使用基于驱动程序的解决方案(依赖于如 libpmem
等系统调用)写入非易失性内存(NVM)所产生的成本,该成本也应该显著降低。相对于同步文件写入,成本合理预期可以降低一个数量级,而相对于使用系统调用,成本则可能降低两倍。
动机
NVM 为应用程序员提供了在程序运行之间创建和更新程序状态的机会,而无需承担通常输出到持久介质或从持久介质输入所隐含的显著复制和/或转换成本。这对于事务性程序尤其重要,因为为了实现崩溃恢复,需要对不确定状态进行定期持久化。
现有的 C 库(例如 Intel 的 libpmem
)为 C 程序提供了在基础层级上对 NVM(非易失性内存)的高效访问。它们还以此为基础,支持对多种持久化数据类型的简单管理。目前,即使仅从 Java 使用基础库也是昂贵的,因为频繁需要进行系统调用或 JNI 调用以调用确保内存更改持久化的原语操作。同样的问题限制了高级库的使用,并且由于 C 中提供的持久化数据类型是分配在 Java 无法直接访问的内存中,这一问题变得更加严重。这使得 Java 应用程序和中间件(例如,Java 事务管理器)相较于 C 或可以低成本链接到 C 库的语言处于严重的劣势。
该提案试图通过允许对映射到 ByteBuffer
的 NVM 进行高效的写回操作来解决第一个问题。由于 ByteBuffer
映射的内存可被 Java 直接访问,因此可以通过实现等效于 C 语言中提供的客户端库来解决第二个问题,以管理不同持久化数据类型的存储。
描述
初步更改
此 JEP 利用了 Java SE API 的两项相关增强功能:
-
支持实现定义的映射模式(JDK-8221397)
-
MappedByteBuffer::force
方法指定范围(JDK-8221696)
建议的 JDK 特定 API 更改
- 在新模块中通过公共 API 暴露新的
MapMode
枚举值
一个新的模块 jdk.nio.mapmode
将导出一个同名的新包。此包中将添加一个公共扩展枚举 ExtendedMapMode
:
package jdk.nio.mapmode;
. . .
public class ExtendedMapMode {
private ExtendedMapMode() { }
public static final MapMode READ_ONLY_SYNC = . . .
public static final MapMode READ_WRITE_SYNC = . . .
}
新的枚举值在调用 FileChannel::map
方法时使用,分别创建只读或读写 MappedByteBuffer
,映射到 NVM 设备文件上。如果在不支持 NVM 设备文件映射的平台上传递这些标志,将抛出 UnsupportedOperationException
异常。在支持的平台上,只有当目标 FileChannel
实例是从通过 NVM 设备打开的文件派生时,才适合将这些新值作为参数传递。在任何其他情况下,将抛出 IOException
异常。
- 发布一个跟踪持久化
MappedByteBuffer
统计信息的BufferPoolMXBean
ManagementFactory
类提供了一个方法 List<T> getPlatformMXBeans(Class<T>)
,可以用来检索 BufferPoolMXBean
实例的列表,这些实例跟踪现有类别中映射或直接字节缓冲区的 count
、total_capacity
和 memory_used
。该方法将被修改为返回一个额外的新 BufferPoolMXBean
,名称为 "mapped - 'non-volatile memory'"
,它将跟踪所有当前以 ExtendedMapMode.READ_ONLY_SYNC
或 ExtendedMapMode.READ_WRITE_SYNC
模式映射的 MappedByteBuffer
实例的上述统计信息。现有的名称为 mapped
的 BufferPoolMXBean
将继续仅跟踪当前以 MapMode.READ_ONLY
、MapMode.READ_WRITE
或 MapMode.PRIVATE
模式映射的 MappedByteBuffer
实例的统计信息。
拟议的内部 JDK API 更改
-
在类
jdk.internal.misc.Unsafe
中新增方法writebackMemory
public void writebackMemory(long address, long length)
调用此方法可确保对从 address
开始并在地址范围(但不一定包括)address + length
内的内存所做的任何修改,都能保证已从缓存写回到内存。实现必须保证当前线程在调用点之前所有 i) 挂起的存储操作,并且 ii) 针对目标范围内的内存,都被包含在写回操作中(即调用者在调用前无需执行任何内存屏障操作)。同时,它还必须保证在返回之前,所有目标字节的写回已完成(即调用者在调用后无需执行任何内存屏障操作)。
回写内存操作将使用 JIT 编译器识别的少量内置函数来实现。目标是通过一种转换为处理器缓存行回写指令的内置函数,对指定地址范围内的每个连续缓存行执行回写操作,从而将数据持久化的成本降至最低。设想的设计还采用了预回写和回写后内存同步的内置函数。这些内置函数可能会转换为内存同步指令或无操作指令,具体取决于处理器回写(x64 有三个可能的候选指令)所选指令的具体情况以及该选择所涉及的排序要求。
注意:在 Unsafe
类中实现此功能的一个很好的理由是,它可能会有更广泛的用途,例如用于采用非易失性内存的替代数据持久化实现。
替代方案
在最初的原型中测试了两种替代方案。
一个选项是使用 libpmem
的驱动模式,即:1) 将 libpmem
安装为 NVM 设备的驱动程序,2) 像映射其他 MappedByteBuffer
一样映射文件,3) 依赖 force
方法来进行更新。
第二种替代方案是使用 libpmem
(或其某些部分)作为 JNI 本地库,以提供所需的缓冲区映射和写回行为。
两种选择都被证明是非常不令人满意的。第一个选择受到系统调用的高成本以及强制整个映射缓冲区而不是其某个子集所带来的开销的影响。第二个选择受到 JNI 接口高成本的影响。第二种方法的连续迭代(首先添加注册的本地方法,然后将它们实现为内联函数)为当前的草案实施提供了类似的性能优势。
考虑的第三种替代方案是等待 Project Panama 提供对映射到 NVRAM 的外部库和外部数据类型的访问,而无需承担 JNI 的开销。尽管这仍被视为未来的一个有价值的选择,但决定当前提案值得推进,原因有二:首先,允许用户立即试验从 Java 使用 NVRAM,因为它开始变得可用;其次,通过支持一种源于现有的、熟悉的 MappedByteBuffer
API 的 NVRAM 使用模型,来简化这种过渡所涉及的过程。
测试
测试将需要一个配备 NVM 设备并运行适当更新的 Linux 内核(4.16)的 x64 或 AArch64 主机。
在适合此架构的 NVM 设备出现之前,可能无法在 AArch64 上进行测试。作为一种替代方案,测试可能需要通过映射易失性内存并使用它来模拟 NVM 设备的行为来进行。
在两个目标架构上进行测试可能会很困难;特别是,它可能会遇到误报。写回代码中的故障只有在可以杀死具有未刷新待处理更改的 JVM 并在重新启动时检测到该遗漏时才能被检测到。
在使用正常的 JVM 退出时,这种情况可能难以安排(正常关闭最终可能会导致这些挂起的更改被写回)。鉴于 JVM 并未完全掌控内存系统的运行,在执行异常退出(例如 kill -KILL
终止)时,甚至可能难以检测到问题。
风险与假设
该实现允许通过 ByteBuffer
将 NVM(非易失性内存)作为堆外资源进行管理。一个相关的增强功能 JDK-8153111 正在研究将 NVM 用于堆数据的使用情况。此外,可能还需要考虑使用 NVM 来存储 JVM 元数据。这些不同的 NVM 管理模式在组合使用时可能会不兼容,或者可能只是不适当。
所提出的 API 只能处理最大为 2GB 的映射区域。可能需要修改所提出的实现,以符合 JDK-8180628 中提出的更改,从而克服这一限制。
ByteBuffer
API 主要聚焦于基于位置(光标)的访问,这限制了对独立缓冲区区域进行并发更新的机会。正如 JDK-5029431 中详述的那样,更新期间需要对缓冲区进行锁定,该问题也在此得到了一种解决方法的实现。通过提供在绝对索引上操作且不依赖光标的原始值访问器,从而允许无锁访问;此外还可以通过使用 ByteBuffer
切片和 MethodHandles
来执行原始值的并发写入/读取操作,在一定程度上缓解了这个问题。