12 | 外部内存接口:零拷贝的障碍还有多少?

你好,我是范学雷。今天,我们来讨论Java的外部内存接口。

Java的外部内存接口这个新特性,现在还在孵化期,还没有发布预览版。我之所以选取了这样一个还处于孵化期的技术,主要是因为这个技术太重要了。我们需要提前认识它;然后在这项技术出来的时候,尽早地使用它。

我们从阅读案例开始,看一看Java在没有外部内存接口的时候,是怎么支持本地内存的;然后,我们再看看外部内存接口能够给我们的代码带来什么样的变化。

阅读案例

在我们讨论代码性能的时候,内存的使用效率是一个绕不开的话题。像TensorFlow、 Ignite、 Flink以及Netty这样的类库,往往对性能有着偏执的追求。为了避免Java垃圾收集器不可预测的行为以及额外的性能开销,这些产品一般倾向于使用JVM之外的内存来存储和管理数据。这样的数据,就是我们常说的堆外数据(off-heap data)。

使用堆外存储最常用的办法,就是使用ByteBuffer这个类来分配直接存储空间(direct buffer)。JVM虚拟机会尽最大努力直接在直接存储空间上执行IO操作,避免数据在本地和JVM之间的拷贝。

由于频繁的内存拷贝是性能的主要障碍之一。所以为了极致的性能,应用程序通常也会尽量避免内存的拷贝。理想的状况下,一份数据只需要一份内存空间,这就是我们常说的零拷贝。

下面的这段代码,就是用ByteBuffer这个类来分配直接存储空间的方法。

public static ByteBuffer allocateDirect(int capacity);

ByteBuffer所在的Java包是java.nio。从这个Java包的命名我们就能感受到,ByteBuffer设计的初衷是用于非阻塞编程的。的确,ByteBuffer是异步编程和非阻塞编程的核心类,几乎所有的Java异步模式或者非阻塞模式的代码,都要直接或者间接地使用ByteBuffer来管理数据。

非阻塞和异步编程模式的出现,起始于对于阻塞式文件描述符(File descriptor)(包括网络套接字)读取性能的不满。而诞生于2002年的ByteBuffer,最初的设想也主要是用来解决当时文件描述符的读写性能的。所以,它的设计也不能跳脱出当时的客观需求。

如果站在现在的角度重新审视这个类的设计,我们会发现它主要有两个缺陷。

第一个缺陷是没有资源释放的接口。一旦一个ByteBuffer实例化,它占用了内存的释放,就会完全依赖JVM的垃圾回收机制。使用直接存储空间的应用,往往需要把所有潜在的性能都挤压出来。依赖于垃圾回收机制的资源回收方式,并不能满足像Netty这样的类库的理想需求。

第二个缺陷是存储空间尺寸的限制。ByteBuffer的存储空间的大小,是使用Java的整数来表示的。所以,它的存储空间,最多只有2G。这是一个无意带来的缺陷。在网络编程的环境下,这并不是一个问题。可是,超过2G的文件,一定会越来越多;2G以上的文件,映射到ByteBuffer上的时候,就会出现文件过大的问题。而像Memcahed这样的分布式内存,也会让应用程序需要控制的内存超越2G的界限。

这两个缺陷,也是横隔在“零拷贝”这个理想路上的两个主要设计障碍。

对于第一个缺陷,我们还可以在ByteBuffer的基础上修改,并且保持这个类的优雅。但是第二个缺陷,由于ByteBuffer类里到处都在使用的整数类型,我们就很难找到办法既保持这个类的优雅,又能够突破存储空间的尺寸限制了。

一个合理的改进,就是重新建造一个轮子。这个新的轮子,就是外部内存接口。

外部内存接口

外部内存接口沿袭了ByteBuffer的设计思路,但是使用了全新的接口布局。我们先来看看使用外部内存接口的代码看起来是什么样子的。下面的这段代码,要分配一段外部内存,并且存放4个字母A。

try (ResourceScope scope = ResourceScope.newConfinedScope()) {
    MemorySegment segment = MemorySegment.allocateNative(4, scope);
    for (int i = 0; i < 4; i++) {
        MemoryAccess.setByteAtOffset(segment, i, (byte)'A');
    }
}

现在,我们通过这个小例子,来看看外部内存接口的布局。

第一行的ResourceScope这个类,定义了内存资源的生命周期管理机制。这是一个实现了AutoCloseable的接口。我们就可以使用try-with-resource这样的语句,及时地释放掉它管理的内存了。这样的设计,就解决了ByteBuffer的第一个缺陷。

第二行的MemorySegment这个类,定义和模拟了一段连续的内存区域。第三行的MemoryAccess这个类,定义了可以对MemorySegment执行读写操作。在ByteBuffer的设计里,内存的表达和操作,是在ByteBuffer这一个类里完成的。在外部内存接口的设计里,把对象表达和对象的操作,拆分成了两个类。这两类的寻址数据类型,使用的是长整形(long)。这样,长整形的寻址类型,就解决了ByteBuffer的第二个缺陷。

超预期的演进

无论是在我们生活的现实世界里,还是在软件的虚拟世界里,只要我们超前迈出了第一步,后续的发展往往会超出我们的预料。外部内存接口的出现,虽然还处在孵化期,也带来了远远超出预期的精彩局面。

在计算机的世界里,代码主要和两类计算资源打交道。一类是负责控制和运算的处理器;一类是临时存放运算数据的存储器。表现到编程语言的层面,就是函数和内存。函数之间的数据传递,也是用过内存的形式进行的。

现在,外部内存接口为我们提供了一个统一的内存操作接口。对应地,外部函数之间的数据传递问题也就有了思路。既然能够解决函数之间的数据传递问题,那么,不同语言间的函数调用能不能变得更简单、更有效率呢?

这个问题,就是我们下一次要讨论的内容。如果说,设计外部内存接口的最初动力是为了解决ByteBuffer的两个缺陷。那研发的持续推进,则给外部内存接口赋予了更大的责任和能量。

总结

好,到这里,我来做个小结。前面,我们讨论了Java的外部内存接口这个尚处于孵化阶段的新特性,对外部内存接口这个新特性有了一个初始的印象。

设计外部内存接口的最初动力,是为了解决ByteBuffer的两个缺陷。也就是ByteBuffer占用的资源不能及时释放,以及它的寻址空间太小这两个问题。但是外部内存接口的更大使命,是和外部函数接口联系在一起的。我们下一次再讨论这个更大的使命。

如果外部内存接口正式发布出来,现在使用ByteBuffer的类库(比如Flink和Netty,甚至JDK本身),应该可以考虑切换到外部内存接口来获取性能的提升。

这一次学习的主要目的,就是让你对外部内存接口有一个基本的印象。由于外部内存接口尚处于孵化阶段,现在我们还不需要学习它的API。只要知道Java有这个发展方向,能够了解ByteBuffer的这两个缺陷能够给你的程序带来的影响就足够了。

如果面试中聊到了ByteBuffer,你应该可以聊一聊零拷贝,以及ByteBuffer的这两个缺陷,还有未来的Java要做的改进。

思考题

其实,今天的这个新特性,也是练习使用JShell快速学习新技术的一个好机会。我们在前面的讨论里,分析了下面的这段代码。为了方便你阅读,我把这段代码重新拷贝到下面了。

try (ResourceScope scope = ResourceScope.newConfinedScope()) {
    MemorySegment segment = MemorySegment.allocateNative(4, scope);
    for (int i = 0; i < 4; i++) {
        MemoryAccess.setByteAtOffset(segment, i, (byte)'A');
    }
}

虽然我们提到了使用try-with-resource这样的语句,可以及时地释放掉它管理的内存。但是,我们并没有验证这一说法。你能不能使用JShell,快速地验证它的资源释放效果呢?

需要注意的是,要想使用孵化期的JDK技术,需要在JShell里导入孵化期的JDK模块。就像下面的例子这样。

$ jshell --add-modules jdk.incubator.foreign -v
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro

jshell> import jdk.incubator.foreign.*;

欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!

注:本文使用的完整的代码可以从GitHub下载,你可以通过修改GitHubreview template代码,完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见,请提交一个 GitHub的拉取请求(Pull Request),并把拉取请求的地址贴到留言里。这一小节的拉取请求代码,请在外部内存接口专用的代码评审目录下,建一个以你的名字命名的子目录,代码放到你专有的子目录里。比如,我的代码,就放在memory/review/xuelei的目录下面。

精选留言

  • 松松

    2021-12-10 14:38:56

    jshell> try (ResourceScope scope = ResourceScope.newConfinedScope()) {
    ...> MemorySegment segment = MemorySegment.allocateNative(4, scope);
    ...> for (int i = 0; i < 4; i++) {
    ...> MemoryAccess.setByteAtOffset(segment, i, (byte)'A');
    ...> }
    ...> for (int i = 0; i < 4; i++) {
    ...> System.out.println(MemoryAccess.getByteAtOffset(segment, i));
    ...> }
    ...> }
    65
    65
    65
    65

    jshell> MemorySegment segment;
    segment ==> null
    | 已创建 变量 segment : MemorySegment

    jshell> try (ResourceScope scope = ResourceScope.newConfinedScope()) {
    ...> segment = MemorySegment.allocateNative(4, scope);
    ...> for (int i = 0; i < 4; i++) {
    ...> MemoryAccess.setByteAtOffset(segment, i, (byte)'A');
    ...> }
    ...> }

    jshell> for (int i = 0; i < 4; i++) {
    ...> System.out.println(MemoryAccess.getByteAtOffset(segment, i));
    ...> }
    | 异常错误 java.lang.IllegalStateException:Already closed
    | at ConfinedScope.checkValidState (ConfinedScope.java:57)
    | at ScopedMemoryAccess.getByteInternal (ScopedMemoryAccess.java:480)
    | at ScopedMemoryAccess.getByte (ScopedMemoryAccess.java:470)
    | at MemoryAccessVarHandleByteHelper.get (MemoryAccessVarHandleByteHelper.java:114)
    | at MemoryAccess.getByteAtOffset (MemoryAccess.java:105)
    | at (#6:2)

    在 try-with-resource 内阔以访问,否则报 IllegalStateException: Already closed。
    如果更严谨些,阔以从 MemoryAddress 判断。
    作者回复

    像是范例,谢谢!

    2021-12-10 17:07:42

  • 小飞同学

    2021-12-14 12:39:29

    为什么ByteBuffer最大内存是2G?不应该是2^32/2^30=4GB的内存么,没有找到一个合适的解释
    作者回复

    有一位要留给符号,所以只有31位。

    2021-12-15 07:28:08

  • aoe

    2021-12-10 12:38:18

    知道了大名鼎鼎的ByteBuffer有两个缺陷:
    1. 没有资源释放的接口
    2. 存储空间尺寸的限制。ByteBuffer 的存储空间的大小,是使用 Java 的整数来表示的。所以,它的存储空间,最多只有 2G。

    虽然没有直接使用过ByteBuffer,但依然感觉很厉害的样子。
    外部内存接口看起来更厉害啊!
    作者回复

    虽然还在孵化期,还是值得期待的。

    2021-12-10 17:05:06

  • ABC

    2021-12-13 23:17:19

    老师,我看这两天Log4j2遇到的漏洞就跟JNI有关,可以大概讲一下吗,谢谢.
    作者回复

    这几天太忙了,没顾上看细节。 等我忙过了这一段时间,看看能不能了解一下这个漏洞的技术细节。

    2021-12-14 11:57:06

  • ABC

    2021-12-12 22:50:40

    我看到外部内存接口在JDK18开启了第二阶段孵化,变化包括:

    * 在内存访问var句柄中支持更多的载体,例如booleanand MemoryAddress;
    * 更通用的取消引用 API,可在MemorySegment和MemoryAddress接口中使用;
    * 一个更简单的 API 来获取向下调用方法句柄,MethodType不再需要传递参数;
    * 一个更简单的 API 来管理资源范围之间的时间依赖性;
    * 用于将 Java 数组复制到内存段和从内存段复制的新 API。

    到正式发布估计还要一段时间.

    有个疑问,想请教一下老师,一般在业务系统中不推荐直接操作外部内存把?

    另外还有个有意思的特性,JDK计划在后续版本中删除JDK中的所有finalize()方法.其中最著名的是: java.lang.Object.finalize().
    大概要经历下面的步骤:

    JDK 的未来版本可能包括以下部分或全部更改:

    1. 在使用终结器时在运行时发出警告,
    2. 默认情况下禁用终结,可以选择重新启用它,
    3. 稍后,删除终结机制,同时保留不起作用的 API,
    4. 最后,在迁移大部分代码后,删除上述最终弃用的方法,包括Object::finalize.

    我觉得Python自带的IDLE像一个简单的代码编辑器,不清楚JDK后续是否会提供一个类似IDLE的工具.
    作者回复

    “第二阶段孵化”,接口会越来越简单。孵化期的接口,验证可行性是一个重要的目标。

    “一般在业务系统中不推荐直接操作外部内存吧?”,
    这个问题,看我们怎么定义业务系统。一般情况下,会有一层封装,隐藏起来实现的细节。

    IDLE这样的工具,很难出现在JDK里。市面已经有非常出色的IDE了,比如IDEA,不再需要另起炉灶了。JDK要解决的,主要还是编程语言和标准类库,其他的事情,应该交给社区和生态来做。

    2021-12-13 04:59:56

  • 周周周文阳 ༽

    2022-12-18 11:48:01

    https://blog.csdn.net/qq_43284469/article/details/126551473 该篇博客除作者名字变更,其他只字未动
    作者回复

    哈哈,这个喜欢吃黄桃的阿昌作者还是一个超级搬运工,把这个专栏的文章搬的差不多了。

    2022-12-19 12:56:37

  • ifelse

    2022-10-12 11:52:04

    理想的状况下,一份数据只需要一份内存空间,这就是我们常说的零拷贝。--记下来
  • 发光如星

    2022-01-14 14:11:51

    说实话,jdk11的新特性都不太清楚有哪些,老师有没有这方面的资料
    作者回复

    看JDK 11的release notes

    2022-01-15 02:55:32

  • Jxin

    2021-12-14 15:49:54

    1.提个问题:为什么新的直接内存管理,将生命周期/数据承接/数据操作拆分成了3个类来实现?分成3个类也行,但使用方感知3个类是不是有点不合适?
    java因为有jvm对对象做统一的生命周期管理,所以一般java的对象并不包含生命周期这块的逻辑。ResourceScope 的命名其实没有局限在直接内存,所以感觉这个可能是一个公用的独立做对象生命周期管理的玩意。如果是公用的,那么这个独立出来确实是合理的。但是数据承接和数据操作分两个类就有点看不懂了,行为跟着数据走,为啥要拆开?也许行为的实现相对独立,例如迭代器,单独类也合理。但暴露给使用方操作两个类就不合理了,因为操作可以也应该包在MemorySegment内部。或许是为了灵活组合不同操作?在可读性优先的前提下,有时保持愚钝的通过多个子类分隔逻辑,达到隔离场景的效果比灵活更有价值。

    2.外部函数之间的数据传递问题,之前考虑过这个问题。服务网格边车模式依赖网络拦截重发实现两个进程间的交互。如果借助外部直接内存,是否可以实现同样的效果?感觉可以,而且可以不再占用网络资源。应该是挺大的提升。但马上又有问题冒出来了,进程间直接内存抢占怎么办?安全问题怎么办?
    作者回复

    第一个问题,虽然我觉得数据和操作拆分各有利弊,现在我还看不到拆分成两个类的优势。也许是为了操作的可扩展性/灵活性,但是现在的设计还没有体现出来需要这样的灵活性。孵化期的API,离最终定版还有很大的一段距离。

    第二个问题,这些资源竞争和安全的问题,都可以通过实现细节屏蔽起来。这也是我能想到的第一个问题里类拆分的一个可能性,但是我还没有看到朝着个方向走的样子。

    2021-12-15 11:53:24

  • Ankhetsin

    2021-12-12 08:20:16

    java和别的不同语言是不是用JNI和JNA?
    作者回复

    JNI就是我们说的本地接口。

    2021-12-13 02:31:29