learn-tech/专栏/深入剖析Java新特性/12外部内存接口:零拷贝的障碍还有多少?.md
2024-10-16 06:37:41 +08:00

111 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
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下载你可以通过修改GitHub上review template代码完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在外部内存接口专用的代码评审目录下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在memory/review/xuelei的目录下面。