外部内存访问和 NIO 通道 - 更进一步
Chris Hegarty 于 2021 年 4 月 21 日发布Java 平台的 NIO 通道目前仅支持对受限内存段中具有字节缓冲区视图的同步通道的 I/O 操作。虽然这在某种程度上是一种限制,但这反映了对 API 约束的务实解决方案,同时还推动了外部内存访问 API本身的设计。
照片由 Thomas Tucker 拍摄
随着外部内存访问 API 的最新演变(针对 JDK 17),内存段的生命周期被推迟到更高级别的抽象,即资源范围。资源范围管理一个或多个内存段的生命周期,并具有几个不同的特征。我们详细了解一下这些特征,但最值得注意的是,现在有一种方法可以将共享内存段暂时呈现为不可关闭。鉴于此,我们可以重新审视当前对可与 NIO 通道一起使用的内存段类型以及可利用段视图的通道类型的限制。
本文介绍了资源范围抽象,描述了它的特征,最后介绍了如何利用它来提供与不同类型的 NIO 通道的更好的互操作性。虽然许多细节都特定于 NIO 通道,但此处描述的许多问题和方法都足够通用,因此也可能适用于使用字节缓冲区的其他低级框架或库。
在讨论 NIO 通道如何利用资源范围和内存段视图之前,我们首先需要了解资源范围抽象。
资源范围
资源范围对一个或多个关联资源(例如内存段)的生命周期进行建模。新创建的资源范围是活动的,这意味着可以安全地访问其所有关联资源。资源范围可以关闭,这意味着不再允许访问其关联资源。关闭后,其所有关联资源都将被释放,例如释放与本机内存段关联的内存。资源范围具有许多特征,概述如下
-
限制
-
受限 - 线程限制,只有所有者线程才能操作与此类资源范围关联的资源
-
共享 - 无线程限制,任何线程都可以操作与此类资源范围关联的资源
资源范围要么是受限范围,要么是共享范围。
-
-
清理
有六个静态工厂 API 点,允许检索或创建与上述特征组合相对应的资源作用域对象。它们如下
API 点 | 限制 | 清理 | |
---|---|---|---|
1 | newConfinedScope() |
受限 | 显式 |
2 | newConfinedScope(Cleaner) |
受限 | 显式 |
3 | newSharedScope() |
共享 | 显式 |
4 | newSharedScope(Cleaner) |
共享 | 显式 |
5 | newImplicitScope() |
共享 | 隐式 |
6 | globalScope() |
共享 | 隐式 |
我们可以看到,对于具有隐式清理功能的受限资源作用域,没有 API 点。此类作用域并不是很有用,因为它们受线程限制,并且可以轻松地确定性地关闭。
虽然全局作用域具有隐式清理功能,但实际上是不可关闭的,因为它始终保证可以强劲访问(因此永远不会关闭)。
NIO 通道
NIO 通道使用字节缓冲区执行 I/O 操作。这些字节缓冲区可以由 Java 堆中的内存、堆外(直接)或内存段上的视图支持。
执行(读/写)I/O 操作的 NIO 通道有两大类
- 同步通道 - DatagramChannel、FileChannel、SocketChannel
- 异步通道 - AsynchronousFileChannel、AsynchronousSocketChannel
第一类,同步通道;读写操作以同步形式在 API 中显示。在线程T
上启动的 I/O 操作将 i) 成功完成并返回适当的返回值,或 ii) 如果发生错误,则抛出异常,两种结果都发生在线程T
上。当在通道上调用读或写操作时,方法调用会分别传递字节缓冲区或字节缓冲区集合以读入或写入。在方法调用(read
或write
)时,会发生逻辑控制转移,传递的字节缓冲区实际上在方法调用完成之前都由通道控制,此时控制权将传递回调用方。所有这些都同步发生在线程T
上。
第二类,异步通道;读写操作以异步形式在 API 中浮出水面。在线程 T
上启动的 I/O 操作可能会安排该 I/O 操作在稍后的时间在 T
以外的某个线程上完成。与同步通道类似,当在异步通道上调用读或写操作时,方法调用会分别传递一个字节缓冲区或字节缓冲区的聚合,以供读入或写出。在方法调用(read
或 write
)时,会进行逻辑控制传输,传递的字节缓冲区实际上在操作完成之前由通道控制,此时字节缓冲区的控制权将传递回用户代码。与同步通道不同,异步通道上的 I/O 操作通常不会立即完成,而是在稍后的时间并在启动 I/O 操作的线程以外的线程上完成。
了解了 NIO 通道的两大类以及上一节中资源范围的各种特征,我们现在可以讨论如何让它们很好地协同工作。
同步通道
同步通道比其同类异步通道更直接,因为“所有操作”都在单个线程上以同步方式进行。也就是说,一旦启动 I/O 操作并将字节缓冲区的控制权移交给通道,字节缓冲区就不应该再受到其他用户代码的影响 - 如果出现这种情况,则用户代码中会出现错误。不过,Java 平台提供了强有力的安全保证 - 它永远不会崩溃。为了遵守此保证,同步通道实现需要在访问字节缓冲区支持的内存时保护自身。
由 ByteBuffer
类中的工厂方法创建的字节缓冲区(常规字节缓冲区)没有确定性的释放 - 只有当缓冲区变得不可达时,其支持的内存才会被释放。一旦通道实现对缓冲区持有强引用以进行 I/O 操作,就可以确保支持缓冲区的内存不会被释放。
对于内存段上的字节缓冲区视图,事情会变得稍微复杂一些,因为段的支持内存与资源范围相关联。与隐式范围关联的段不能显式关闭,因此持有对缓冲区(以及对资源范围的传递)的强引用就足够了,类似于常规字节缓冲区。具有受限范围的内存段只能由所有者线程访问,因此一旦缓冲区的控制权被转移到通道,就不能在 I/O 操作期间被另一个线程关闭。到目前为止,一切都很好。这给我们留下了与共享范围关联的内存段上的字节缓冲区视图。对于这些类型的缓冲区,通道实现可以获取资源范围句柄以暂时使范围不可关闭。这可以防止与范围关联的资源的支持内存被释放以进行 I/O 操作。之后,将释放资源范围句柄。
通过此方式,同步通道可以对与所有不同类型的资源范围关联的段上的字节缓冲区视图执行 I/O 操作。当前限制(截至 JDK 16)是同步通道仅支持对受限内存段上的字节缓冲区视图执行 I/O 操作,此限制可以消除。
异步通道
异步通道与线程限制本质上不一致,因为在一个线程上启动的 I/O 操作通常在另一个线程上完成。因此,NIO 异步通道无法很好地处理与线程受限范围关联的段上的字节缓冲区视图。实际上,使用此类字节缓冲区启动的 I/O 操作应立即失败(抛出带有适当详细信息的消息的异常)。这样就只剩下共享范围了。
与隐式范围关联的段上的字节缓冲区视图要求通道实现对缓冲区(以及对资源范围的传递引用)保持强引用,类似于同步通道。这可以防止在 I/O 操作挂起时释放内存,无论 I/O 操作是否在启动它的线程之外的线程上完成。与显式范围关联的段上的字节缓冲区视图,同样类似于同步通道,可以获取资源范围句柄以暂时使范围不可关闭。这可以防止与范围关联的资源的内存因 I/O 操作而被释放,之后可以释放句柄以使范围再次可关闭。获取和释放可以在不同的线程上进行。
通过此方式,异步通道可以对与共享资源范围(不受限)关联的段上的字节缓冲区视图执行 I/O 操作。这是对 JDK 16 的改进,其中异步通道不支持对段上的任何字节缓冲区视图执行 I/O 操作。
实现细节
到目前为止,我们已经概述了不同类型的通道如何与与资源范围关联的段上的缓冲区视图进行交互,但(一如既往)代码的实用性和对“尚未证明的”微优化表示认可,这些都会影响决策。通过应用一些小的限制和简化,我们可以更轻松地编写直接的实现,而不会妨碍可用性。这些简化包括
-
始终为与显式范围关联的段上的字节缓冲区视图获取资源范围句柄。如上所述,对于同步通道来说这不是严格必要的,但会简化代码路径。如果存在足够的证据表明存在问题,则稍后可以取消此限制。
-
对于来自多个显式范围的段上的字节缓冲区视图的散射和收集 I/O 操作,将资源范围句柄保留为可运行/可关闭项的简单链表状结构。通常所有缓冲区都来自单个范围,在这种情况下,单个可运行/可关闭项就足够了。
-
无条件获取显式范围的资源范围句柄,即使已经持有特定范围的句柄。同样,这是一个简化,有助于保持代码统一,但如果被证明存在问题,则稍后可以重新考虑。
比较执行(散射/收集)I/O 操作的各个方面的微基准将用于调查实现的性能方面。
以下 Pull Request 跟踪代码更改:https://github.com/openjdk/panama-foreign/pull/512
结论
Panama 外部内存访问 API(在 JDK 17 中)的增强,最显着的是资源范围抽象,极大地改善了内存段和 NIO 通道上的字节缓冲区视图的互操作。NIO 通道实现现在可以支持逻辑上适用于通道提供的编程模型的所有字节缓冲区视图。
最初发布在 panama-dev 上。