【Flink】第十八篇:Direct Memory 一箩筐

2022-03-31 11:15:40 浏览数 (1)

【Flink】第四篇:【迷思】对update语义拆解D-、I 后造成update原子性丢失

【Flink】第五篇:checkpoint【1】

【Flink】第五篇:checkpoint【2】

【Flink】第六篇:记一次Flink状态(State Size)增大不收敛,最终引起OOM问题排查

【Flink】第八篇:Flink 内存管理

【Flink】第九篇:Flink SQL 性能优化实战

【Flink】第十篇:join 之 regular join

【Flink】第十三篇:JVM思维导图

【Flink】第十四篇:LSM-Tree一般性总结

【Flink】第十五篇:Redis Connector 数据保序思考

【Flink】第十六篇:源码角度分析 sink 端的数据一致性

【Flink】第十七篇:记一次牛轰轰的OOM故障排查

Flink的内存管理是基于JVM内存模型的,所以,在内存调优或者解决各种OOM等问题时JVM内存管理是绕不开的话题。本文以Direct Memory为切入点,探索堆外内存、直接内存、以及他们在Java NIO源码中如何体现的。最后,简单介绍Java NIO的零拷贝在Kafka和Netty中的应用。

JVM的可配置内存主要由以下4类:

  • Heap(-Xms、-Xmx)
  • Thread Stack(-Xss)
  • MetaSpace(-XX:MetaspaceSize、-XX:MaxMetaspaceSize)
  • Direct Memory(-XX:MaxDirectMemorySize)

除此之外主要还有CodeCache、JNI分配的内存等。

我们讲的本地内存(native memory)或堆外内存(off-heap memory)一般是指除了堆栈内存(Heap、Thread Stack)外的内存。而Direct Memory属于本地内存的一种。

Java中的Direct Memory

那么,我们先来聊聊Java中的Direct Memory。

在JDK 1.4中引入了Java NIO(注意这个NIO是New I/O,而不是非阻塞Non-blocking IO)。NIO中有三个核心抽象:

  • Channel通道:通道表示与实体的开放连接,例如硬件设备、文件、网络套接字或能够执行一个或多个不同 I/O 操作(例如读取或写入)的程序组件。
  • Buffer缓冲区:特定原始类型数据的容器。
  • Selector选择器:SelectableChannel对象的多路复用器。

以下是Java NIO里关键的Buffer实现:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • ...

Java中NIO的核心缓冲就是ByteBuffer,所有的IO操作都是通过这个ByteBuffer进行的;Bytebuffer有两种:

1. HeapByteBuffer

代码语言:javascript复制
ByteBuffer buffer = ByteBuffer.allocate(int capacity);

2. DirectByteBuffer

代码语言:javascript复制
ByteBuffer buffer = ByteBuffer.allocateDirect(int capacity);

这里的DirectByteBuffer分配使用的内存空间就是前面的DirectMemory

来看一下allocateDirect的源码:

以上源码其实就干了四件事:

  • 计算分配大小,至少是一个内存页pagesize的大小
  • 记录剩余可分配的direct memory大小
  • 用Unsafe的JNI实际分配DirectMemory
  • 基地址赋值给成员变量address

所以,从以上分析就可以得出DirectByteBuffer到底和HeapByteBuffer有什么区别?

Heap原本应该存储对象的实际数据,而对于DirectByteBuffer,DirectByteBuffer里面只存储了一个基地址address,它指向了堆外内存真正存储数据的DirectMemory。HeapByteBuffer的对象则像普通实例一样存储在Heap上,对象中包含了这个Buffer所持有的数据。

这种看起来很有趣的模式的来由是怎么回事呢?接下来就要涉及到一些操作系统的IO知识了。

linux的read/write模式

我们先来看看一次普通的IO是如何完成的:

1. 当调用 read 系统调用时,通过 DMA(Direct Memory Access)将数据 copy 到内核模式

2. 然后由 CPU 控制将内核模式数据 copy 到用户模式下的 buffer 中

3.read 调用完成后,write 调用首先将用户模式下 buffer 中的数据 copy 到内核模式下的 socket buffer 中

4. 最后通过 DMA copy 将内核模式下的 socket buffer 中的数据 copy 到网卡设备中传送

整个流程中:DMA拷贝2次、CPU拷贝2次、用户空间和内核空间切换4次。

数据白白从内核模式到用户模式走了一圈,浪费了两次 copy (第一次,从kernel模式拷贝到user模式;第二次从user模式再拷贝回kernel模式,即上面4次过程的第2和3步骤。),而这两次 copy 都是 CPU copy,即占用CPU资源。

扩展:DMA拷贝和CPU拷贝


DMA(Direct Memory Access,直接存储器访问)。在DMA出现之前,CPU与外设之间的数据传送方式主要是中断传送方式。

中断传送方式是指当外设需要与CPU进行信息交换时,由外设向CPU发出请求信号,使CPU暂停正在执行的程序,转而去执行数据输入/输出操作,待数据传送结束后,CPU再继续执行被暂停的程序。

中断传送是外设主动向CPU发生请求,等候CPU处理,在没有发出请求时,CPU和外设都可以独立进行各自的工作。需要进行断点和现场的保护和恢复,浪费了很多CPU的时间。

DMA的出现就是为了解决批量数据的输入/输出问题。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。I/O设备向DMA控制器发送DMA请求,再由DMA控制器向CPU发送总线请求,用以传输数据。数据传送是由DMA控制器实现,而不是通过CPU,即数据传送阶段是完全由DMA(硬件)来控制的。

总结:DMA基本不需要CPU中断参与数据的拷贝过程,而CPU拷贝需要CPU中断参与数据的拷贝过程,浪费了CPU的时间。

linux的mmap模式

linux mmap函数的作用相当于是内存共享,将内核空间的内存区域和用户空间共享,这样就避免了将内核空间的数据拷贝到用户空间的步骤,通过mmap函数发送数据时上述的步骤如下:

1. 操作系统通过DMA传输将硬盘中的数据复制到内核缓冲区,执行了mmap函数之后,拷贝到内核缓冲区的数据会和用户空间进行共享,所以不需要进行拷贝

2. CPU将内核缓冲区的数据拷贝到内核空间socket缓冲区

3. 操作系统通过DMA传输将内核socket缓冲区数据拷贝给网卡发送数据

mmap内存映射将会经历:3次拷贝: 1次cpu copy,2次DMA copy;以及4次上下文切换。

Java中的mmap

Java NlO中 的 Channel 就相当于操作系统中的内核缓冲区(可能是读缓冲区,可能是网络缓冲区),而Buffer就相当于操作系统中的用户缓冲区。

在Java NIO中的FileChannel.map()方法其实就是采用了操作系统中的内存映射方式,底层就是调用Linux mmap()实现的。将内核缓冲区的内存地址映射到用户缓冲区,使得这块内核缓冲区内存被内核和用户共享。

代码语言:javascript复制
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;

这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝。

FileChannel.map()是抽象方法,具体实现是在 FileChannelImpl.c 可自行查看JDK源码,其map0()方法就是调用了Linux内核的mmap的API。

map方法返回的是一个MappedByteBuffer,UML类图如下

它唯一的一个实现类居然是DirectByteBuffer!

那么,之前描述的mmap的IO模式有了新的认识,即在 Java 程序中由FileChannel.map()得到的内存本质上是内核的一块Buffer。我们操作Heap上的这个DirectByteBuffer实例,实际上操作的是这块内核的Buffer,而这块Buffer又通过mmap映射到硬件上一块存储,这块硬件存储可能是一个文件。

我们读写这个FileChannel.map()返回的MappedByteBuffer,实际上就像是一个提线木偶一样,在读写磁盘上的文件!

linux的sendfile模式

senfile函数的作用是将一个文件描述符的内容发送给另一个文件描述符。而用户空间是不需要关心文件描述符的,所以整个的拷贝过程只会在内核空间操作,相当于减少了内核空间和用户空间之间数据的拷贝过程,而且还避免了CPU在内核空间和用户空间之间的来回切换过程。

1. 通过DMA传输将硬盘中的数据复制到内核页缓冲区

2. 通过sendfile函数将页缓冲区的数据通过CPU拷贝给socket缓冲区

3. 网卡通过DMA传输将socket缓冲区的数据拷贝走并发送数据

整个过程中:DMA拷贝2次、CPU拷贝1次、内核空间和用户空间切换0次。

sendfile模式改进

Linux2.4 内核对sendFile模式进行了改进。需要DMA控制器支持。

改进的sendfile函数还可以直接将文件描述符和数据长度发送给socket缓冲区,然后直接通过DMA传输将页缓冲区的数据拷贝给网卡进行发送即可,这样就避免了CPU在内核空间内的拷贝过程。

1. 通过DMA传输将硬盘中的数据复制到内核页缓冲区

2. 通过sendfile函数将页缓冲区数据的文件描述符和数据长度发送给socket缓冲区

3. 网卡通过DMA传输根据文件描述符和文件长度直接从页缓冲区拷贝数据

整个过程中:DMA拷贝2次、CPU拷贝0次、内核空间和用户空间切换0次

所以整个过程都是没有CPU拷贝的过程的,实现了真正的CPU零拷贝机制。

Java中的sendfile

FileChannel.transferTo()方法直接将当前通道内容传输到另一个通道,没有涉及到Buffer的任何操作,NIO中 的Buffer是JVM堆或者堆外内存,但不论如何他们都是操作系统内核空间的内存

代码语言:javascript复制
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;

transferTo()的实现方式就是通过linux系统调用sendfile。

Java零拷贝示例:

代码语言:javascript复制
class ZeroCopyFile {
public void copyFile(File src, File dest) {
try (FileChannel srcChannel = new FileInputStream(src).getChannel();
             FileChannel destChannel = new FileInputStream(dest).getChannel()) {
            srcChannel.transferTo(0, srcChannel.size(), destChannel);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

linux的slice函数

splice函数的作用是将两个文件描述符之间建立一个管道,然后将文件描述符的引用传递过去,这样在使用到数据的时候就可以直接通过引用指针访问到具体数据。

和sendfile不同的是,splice()不需要硬件支持。

1. 通过DMA传输将文件复制到内核页缓冲区

2. 通过splice函数在页缓冲区和socket缓冲区之间建立管道,并将文件描述符的引用指针发送给socket缓冲区

3. 网卡通过DMA传输根据文件描述符的指针直接访问数据

整个过程中:DMA拷贝2次、CPU拷贝0次、内核空间和用户空间切换0次。

Kafka中的零拷贝

Kafka两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据。

  • Producer生产的数据持久化到broker,采用mmap文件映射,实现顺序的快速写入;
  • Customer从broker读取数据,采用sendfile,将磁盘文件读到OS内核缓冲区后,直接转到socket buffer进行网络发送。

Netty中的零拷贝

Netty中的Zero-copy与上面我们所提到到OS层面上的Zero-copy不太一样, Netty的Zero-copy完全是在用户态(Java层面)的,它的Zero-copy的更多的是偏向于优化数据操作这样的概念。

  • Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。
  • 通过wrap操作,我们可以将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象,进而避免了拷贝操作。
  • ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。
  • 通过FileRegion包装的FileChannel.tranferTo实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。

前三个都是 广义零拷贝,都是减少不必要数据copy;偏向于应用层数据优化的操作。FileRegion包装的FileChannel.tranferTo,才是真正的零拷贝。

0 人点赞