【网络通信】4、零拷贝、DMA、系统调用、消息队列原理

一、硬中断软中断

我们知道系统为了安全,在调用一些指令时需要进行用户态和内核态转换,如只有在内核态才能执行读写命令,这样保证了系统的安全。

注:程序里的read()函数和系统指令read()是两个东西,第二个才是内核态执行的东西,第一个只是进入内核态

为了能进行用户态和内核态状态转换,需要进行cpu中断,才能从用户态进行内核态,而中断分为硬中断和软中断:

  • 硬中断:外部设备产生,可能发生在任意时间。比如网卡接收到报文,报文被DMA(网卡上的内嵌设备)映射报文到内存的网卡缓冲区,网卡向CPU发起中断信号IRQ iterrupt request。cpu挂起正在执行的进行,进入内核态,去处理网卡的中断程序。执行完后cpu从内核态切回用户态,根据进程描述符加载被挂起的进程继续执行
    • 挂起是保存到进程描述符。寄存器里保存的比如代码段入口、行号、堆栈地址、操作数1、操作数2。
  • 软中断:如程序运行过程中自己产生的一些中断
    • 如要进行系统调用system_call,则发起0X80中断
    • 如程序执行碰到除0异常

这里说说80中断

系统调用:80中断

系统调用指的是:比如用户想要读取硬盘上的文件,发起read调用,这个read只是内核态的库函数api,该api会发起系统调用中断后去调用真正的底层read。

因为系统调用system_call函数所对应的中断指令编号是0X80(十进制就是8×16=128),而该指令编号对应的就是系统调用程序的入口,所以我们称系统调用未80中断。

其他中断程序映射可以见下表

系统调用过程
系统调用流程
  • 在cpu寄存器里存一个系统调用号,(表示哪个系统函数,比如read)。
  • 将cpu的当前寄存器信息都保存到thread_info中(恢复到用户态时用)
    • 把CPU寄存器信息保存到进程描述符PCB里
  • 把CPU堆栈地址的指向指向内核空间。非的持续性
  • 然后执行80中断处理程序,找到刚刚存的系统调用号(比如read),先检查缓存中有没有对应的数据,没有就去磁盘中加载到内核缓冲区,然后从内核缓冲区拷贝到用户空间
  • 然后恢复到用户态,恢复现场,用户态就知道从哪继续执行。

用户内核态切换

  • 用户空间:用户代码、用户堆栈
  • 内核空间:内核代码、内核调度程序、进程描述符(内核堆栈、thread_info进程描述符)
    • 进程描述符和用户的进程是一一对应的
    • SYS_API系统调用:,如read、write。系统调用就是0X80中断
    • 进程描述符pd:进程从用户态切换到内核态时,需要保存用户态时的上下文信息,
      • 线程上下文:用户程序基地址,程序计数器、cpu cache、寄存器。。。方便程序从内核态切换回用户态时恢复现场。
    • 内核堆栈:系统调用函数也是要创建变量的,这些变量在内核堆栈上分配,

进程空间

二、早期IO

进行数据处理和网络数据处理时,考虑到硬件读取速度慢,都不能直接在硬件上处理,所以需要在内存中进行处理。

主要流程为:

  • 读:用户态切换到内核态,先看内核态缓冲区有没有,有就直接读到,没有就交给DMA去读。DMA控制器从磁盘、网卡、其他IO设备中读。CPU在这个期间可以执行其他进程。DMA加载到内核缓冲区后告诉CPU,CPU把数据拷贝到用户态,CPU把该进程从阻塞队列移到到运行队列
  • 写:缓存区满了之后,写操作阻塞,缓冲区有一个等待队列,记录阻塞的进程(java的轻量级进程),DMA把缓冲区数据写到网卡后告诉CPU,中断CPU,把该进程移动到运行队列。

三、DMA直接内存拷贝

DMA( direct memory access) :直接内存拷贝

特点:减少使用CPU

定义:DMA的出现就是为了解决批量数据的输入/输出问题。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。这样数据的传送速度就取决于存储器和外设的工作速度。

场景:要把内存数据发送到网卡然后发出去时:

  • 没有DMA时候怎么办:CPU读内存数据到CPU的高速缓存,再写到网卡。这样就把CPU的速度拉低到和网卡一个速度。
  • 有了DMA:把内存数据读到socket内核缓存区(CPU复制),CPU就不管了,告诉DMA开始接管。DMA开始把内核缓冲区的数据写到网卡。DMA读socket缓冲区,读到DMA缓冲区,然后写到网卡中。不停写到网卡。

补充知识:kernel buffer和socket buffer的区别

kernel buffer和socket buffer的区别

kernel buffer —>socket buffer

但从操作系统的角度来看,这已经是零拷贝,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。 但是,如果硬件支持scatter-and-gather,这是可以避免的。

引入DMA后的传统IO

DMA相关阅读:https://blog.csdn.net/z69183787/article/details/104334247

通常系统总线是由CPU管理的,在DMA方式时,就希望CPU把这些总线让出来,即CPU连到这些总线上的线处于第三态(高阻状态),而由DMA控制器接管,控制传送的字节数,判断DMA是否结束,以及发出DMA结束信号。因此DMA控制器必须有以下功能:

1、能向CPU发出系统保持(HOLD)信号,提出总线接管请求

2、当CPU发出允许接管信号后,负责对总线的控制,进入DMA方式;

3、能对存储器寻址及能修改地址指针,实现对内存的读写;

4、能决定本次DMA传送的字节数,判断DMA传送是否借宿。

5、发出DMA结束信号,使CPU恢复正常工作状态。

img

DMA传输将从一个地址空间复制到另外一个地址空间。当CPU初始化这个传输动作,传输动作本身是由DMA控制器来实行和完成。 典型例子—移动一个外部内存的区块(硬盘)到芯片内部更快的内存区(内核缓冲区)。

DMA接收数据

DMA方式是一种完全由硬件进行组信息传送的控制方式,具有中断方式的优点,即在数据准备阶段,CPU与外设并行工作。

一个完整的DMA传输过程必须经历DMA请求、DMA响应、DMA传输、DMA结束4个步骤。

①CPU读指定的数据:

  • 先检查内核缓冲区里是否有指定的数据
    • 如果有:直接就可以读
    • 如果没有:CPU就交给DMA,DMA负责把硬盘读到缓冲区

②DMA读完数据后:

  • 读完后DMA发起CPU中断(硬中断),告诉CPU移动完了,这样CPU就知道socket内核缓冲区又空出来了
  • CPU从用户态切换到内核态,执行中断处理程序,将socket缓冲区阻塞的进程移回到运行队列。
  • 比如要发送的数据是100k,但是内核缓冲区就50k,这样第二次50k也能发出去了。

对于实现DMA传输,它是由DMA控制器直接掌管总线(地址总线、数据总线和控制总线),因此,存在一个总线控制权转移问题

DMA发送数据

四、传统IO

初学 Java 时,我们在学习 IO 和 网络编程时,会使用以下代码:

需求:调用 read 方法读取 index.html 的内容—— 变成字节数组,然后调用 write 方法,将 index.html 字节流写到 socket 中

// 打开文件流
File file = new File("index.html");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

// 把流读到字节数组
byte[] arr = new byte[(int) file.length()];
raf.read(arr);

// 把数组把给socket
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

那么,我们调用这两个方法,在 OS 底层发生了什么呢?我这里借鉴了一张其他文字的图片,尝试解释这个过程。

上图中,上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作。下面说说他们的步骤:

  • \1. read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。
  • \2. 发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
  • \3. 发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
  • \4. 第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
  • \5. write 方法返回,再次从内核态切换到用户态。

如你所见,复制拷贝操作太多了。如何优化这些流程?零拷贝技术

场景:消息队列持久化后,有消费者来了,要进行消费,那么我们需要将消息从硬盘读取到网卡中发送出去。

进程去读,先看内核缓冲区有没有,没有就告诉DMA去读到内核缓冲区,然后把进程放到内核的阻塞队列中,DMA读好后发起硬件中断告诉CPU,CPU唤醒阻塞进程,从内核缓冲区读到用户数据缓冲区,然后再切换到内核态进程写操作,写到socket缓冲区后,告诉DMA把socket缓冲区的数据写到网卡。复制了4次,进程切换了4次。

切换具体流程:

  1. JVM发出read() 系统调用。
  2. OS上下文切换到内核模式(第一次上下文切换)并将数据从网卡或硬盘等通过DMA读取到内核空间缓冲区。(第一次拷贝:hardware网卡 ----> kernel buffer内核缓存区
  3. OS内核然后将数据复制到用户空间缓冲区(第二次拷贝: kernel buffer内核缓存区 ——> user buffer用户缓冲区),然后之前的read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换(第二次上下文切换)。
  4. JVM处理代码逻辑并发送write()系统调用。
  5. OS上下文切换到内核模式(第三次上下文切换)并从用户空间缓冲区复制数据到内核空间缓冲区(第三次拷贝: user buffer ——> kernel buffer)。
  6. write系统调用返回,导致内核空间到用户空间的再次上下文切换(第四次上下文切换)。将内核空间缓冲区中的数据写到hardware(第四次拷贝: kernel buffer ——> hardware)

数据流转:

img
传统IO总结

总的来说,传统的I/O操作进行了4次用户空间与内核空间的上下文切换,以及4次数据拷贝。显然在这个用例中,从内核空间到用户空间内存的复制是完全不必要的,因为除了将数据转储到不同的buffer之外,我们没有做任何其他的事情。所以,我们能不能直接从hardware读取数据到kernel buffer后,再从kernel buffer写到目标地点不就好了。为了解决这种不必要的数据复制,操作系统出现了零拷贝的概念。

另外一张图

img

五、零拷贝

注意,不同的操作系统对零拷贝的实现各不相同。在这里我们介绍linux下的零拷贝实现。

零拷贝基本介绍:

  1. 零拷贝是网络编程的关键,很多性能优化都离不开。
  2. 在 Java 程序中,常用的零拷贝有 mmap(内存映射)sendFile
  3. 另外我们看下 NIO 中如何使用零拷贝

零拷贝是为了不要在内核缓冲区和用户空间之间拷贝

1、mmap零拷贝

mmap(Memory Mapped Files)

思想:内核空间和用户空间共享内存,省去用户态和内核态之间的拷贝

内核空间就是内核缓存冲

img

有别的地方这么说,我觉得不对:将磁盘文件映射到内存, 用户通过修改内存就能修改磁盘文件。

原理:直接利用操作系统的Page来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同步到硬盘上(DMA操作系统在适当的时候)。

DMA把磁盘上的文件映射到内存,用户空间和内核空间共享同一块物理地址,这样就无需进程用户空间和内核空间的来回复制。写到网卡的时候,共享空间的内容拷贝到socket缓冲区(CPU复制),然后告诉DMA发送到网卡。3次复制(2次DMA,一次CPU复制)

缺陷:不可靠,落盘时,写到mmap中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用flush的时候才把数据真正的写到硬盘。(很多中间件都是这么做的,不直接刷盘,而是系统根据规则决定干什么时候冲刷内核缓冲区到磁盘)

根据上面的描述,cpu都不用管理进行拷贝了,从硬盘到内核空间有DMA进行操作,而内核空间到用户空间有共享内存作为支撑无需拷贝。

mmap流程

第一步:mmap系统调用使DMA引擎文件内容(硬盘)复制到内核缓冲区中。然后与用户进程共享缓冲区,而无需在内核和用户内存空间之间执行任何复制。

第二步:写系统调用使内核将数据从原始内核缓冲区kernel buffer复制到与套接字关联的内核缓冲区socket buffer中。

第三步:第三份复制发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。

  1. 发出mmap系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件(或网卡)中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
  2. mmap系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区,而不需要将数据从内核空间拷贝到用户空间。因为用户空间和内核空间共享了这个缓冲区数据,所以用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据。
  3. 发出write系统调用,导致用户空间到内核空间的上下文切换(第三次上下文切换)。将数据从内核空间缓冲区拷贝到内核空间socket缓冲区(第二次拷贝: kernel buffer ——> socket buffer)。
  4. write系统调用返回,导致内核空间到用户空间的上下文切换(第四次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)

mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。

mmap 示意图

如上图,user buffer 和 kernel buffer 共享 index.html。如果你想把硬盘的 index.html 传输到网络中,再也不用拷贝到用户空间,再从用户空间拷贝到 Socket 缓冲区。

现在,你只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。

3次拷贝 3次切换

NIO使用mmap

Java NIO,提供了一个 MappedByteBuffer 类可以用来实现mmap内存映射。

MappedByteBuffer只能通过调用FileChannel.map()取得,再没有其他方式。

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

应用案例见下面

kafka使用mmap

kafka的落盘技术使用了mmap,所以Kafka提供了一个参数——producer.type来控制是不是主动flush;如果Kafka写入到mmap之后就立即flush然后再返回Producer叫同步(sync);写入mmap之后立即返回Producer不调用flush异步(async)。

落盘消费
kafkammapsendfile
RocketMQmmap

2、send-file

磁盘文件通过网络发送(Broker 到 Consumer)

定义:打开文件的文件描述符fd+socket的fd告诉sendfile,也是经过和上面一样的3次复制。不过只进行了2次用户态和内核态的切换

原理:数据根本不经过用户态,直接从内核缓冲区进入到Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换

如上图,我们进行 sendFile 系统调用时,数据被 DMA 引擎从文件复制到内核缓冲区,然后调用,然后掉一共 write 方法时,从内核缓冲区进入到 Socket,这时,是没有上下文切换的,因为在一个用户空间。

最后,数据从 Socket 缓冲区进入到协议栈。

此时,数据经过了 3 次拷贝,3 次上下文切换。

那么,还能不能再继续优化呢? 例如直接从内核缓冲区拷贝到网络协议栈?

实际上,Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。

sendfile改进

而 Linux 2.4+ 内核通过 sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核态 Buffer 后,直接通过 DMA 拷贝到 NIO Buffer(socket buffer),无需 CPU 拷贝。这也是零拷贝这一说法的来源。除了减少数据拷贝外,因为整个读文件 - 网络发送由一个 sendfile 调用完成,整个过程只有两次上下文切换,因此大大提高了性能。零拷贝过程如下图所示。

引进了sendfile2.4之后,sendfile实现了更简单的方式,不同之处在于,文件到达内核缓冲区后,不必再将数据全部复制到socket buffer缓冲区,而只将记录数据位置和长度相关的数据保存到socket buffer(代替复制),而数据实际由DMA模块直接发送给协议相关引擎,再次降低了复制操作。

Linux 在 2.4 版本中,做了一些修改,避免了从 内核缓冲区kernel buffer到 Socket buffer 的拷贝,直接从内核缓冲区拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图和小结:

  1. 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
  2. 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket buffer缓冲区当中。该描述符包含了两方面的信息:
    1. a) kernel buffer的内存地址;
    2. b) kernel buffer的偏移量。
  3. sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。

kernel buffer拷贝到socket buffer只拷贝少量信息

  1. 这里其实有 一次 cpu 拷贝:kernel buffer -> socket buffer
    但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略

现在,index.html 要从文件进入到网络协议栈,只需 2 次拷贝:第一次使用 DMA 引擎从文件拷贝到内核缓冲区,第二次从内核缓冲区将数据拷贝到网络协议栈;内核缓存区只会拷贝一些 offset 和 length 信息到 SocketBuffer,基本无消耗。

等一下,不是说零拷贝吗?为什么还是要 2 次拷贝?

答:首先我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据,sendFile 2.1 版本实际上有 2 份数据,算不上零拷贝)。例如我们刚开始的例子,内核缓存区和 Socket 缓冲区的数据就是重复的。

而零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

senfile流程
  1. 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver ——> kernel buffer)。
  2. 然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
  3. sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)。

通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。

NIO使用sendFile

java NIO对sendfile的支持就是FileChannel.transferTo()/transferFrom()。

fileChannel.transferTo( position, count, socketChannel);

把磁盘文件读取OS内核缓冲区后的fileChannel,直接转给socketChannel发送;底层就是sendfile。消费者从broker读取数据,就是由此实现。

3、消息队列零拷贝

kafka作为MQ也好,作为存储层也好,无非是两个重要功能,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据;我们把它简化成如下两个过程:

1、网络数据持久化到磁盘 (Producer 到 Broker)

2、磁盘文件通过网络发送(Broker 到 Consumer)

下面,先给出“kafka用了磁盘,还速度快”的结论

落盘消费
kafkammapsendfile
RocketMQmmap

1、顺序读写

磁盘顺序读或写的速度400M/s,能够发挥磁盘最大的速度。

随机读写,磁盘速度慢的时候十几到几百K/s。这就看出了差距。

kafka将来自Producer的数据,顺序追加在partition,partition就是一个文件,以此实现顺序写入。

Consumer从broker读取数据时,因为自带了偏移量,接着上次读取的位置继续读,以此实现顺序读。

顺序读写,是kafka利用磁盘特性的一个重要体现。

2、 sendfile(in,out)消费

数据直接在内核完成输入和输出,不需要拷贝到用户空间再写出去。

kafka数据写入磁盘前,数据先写到进程的内存空间。

3、mmap落盘

只支持文件;mmap的使用场景是仅保存,不处理

在进程 的堆外内存开辟一块内存空间,和OS内核空间的一块内存进行映射,

kafka数据写入、是写入这块内存空间,但实际这块内存和OS内核内存有映射,也就是相当于写在内核内存空间了,且这块内核空间、内核直接能够访问到,直接落入磁盘。

落盘

这里,我们需要清楚的是:内核缓冲区的数据,flush就能完成落盘。

我们来重点探究 kafka两个重要过程、以及是如何利用两个零拷贝技术sendfile和mmap的。

网络数据持久化到磁盘 (Producer 到 Broker)

传统方式实现:

先接收生产者发来的消息,再落入磁盘。

数据落盘通常都是非实时的,kafka生产者数据持久化也是如此。Kafka的数据并不是实时的写入硬盘,它充分利用了现代操作系统分页存储来利用内存提高I/O效率。

kafka的零拷贝:

对于kafka来说,Producer生产的数据存到broker,这个过程读取到socket buffer的网络数据,其实可以直接在OS内核缓冲区,完成落盘。并没有必要将socket buffer的网络数据,读取到应用进程缓冲区;在这里应用进程缓冲区其实就是broker,broker收到生产者的数据,就是为了持久化。

在此特殊场景下:接收来自socket buffer的网络数据,应用进程不需要中间处理、直接进行持久化时。——可以使用mmap内存文件映射。

重要的是不处理

消费

应用的场景非常多,如Tomcat、Nginx、Apache等web服务器返回静态资源等,将数据用网络发送出去,都运用了sendfile。

简单理解 sendfile(in,out)就是,磁盘文件读取到操作系统内核缓冲区后、直接扔给网卡,发送网络数据。

Java NIO对sendfile的支持就是FileChannel.transferTo()/transferFrom()。

fileChannel.transferTo( position, count, socketChannel);

把磁盘文件读取OS内核缓冲区后的fileChannel,直接转给socketChannel发送;底层就是sendfile。消费者从broker读取数据,就是由此实现。

具体来看,Kafka 的数据传输通过 TransportLayer 来完成,其子类 PlaintextTransportLayer 通过Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法实现零拷贝。

注: transferTo 和 transferFrom 并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供 sendfile 这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。

4、NIO零拷贝

imgimg

Java NIO引入了用于通道的缓冲区的ByteBuffer。 ByteBuffer有三个主要的实现:

1) HeapByteBuffer

在调用ByteBuffer.allocate()时使用。 它被称为堆,因为它保存在JVM的堆空间中,因此你可以获得所有优势,如GC支持和缓存优化。 但是,它不是页面对齐的,这意味着如果你需要通过JNI与本地代码交谈,JVM将不得不复制到对齐的缓冲区空间。

ByteBuffer.allocate()堆内空间

// 非直接缓冲区
public static ByteBuffer allocate(int capacity) {
    if (capacity < 0)
        throw new IllegalArgumentException();
    return new HeapByteBuffer(capacity, capacity);//堆
}
//在堆内存开辟空间,即开辟数组
HeapByteBuffer(int cap, int lim) {  // package-private
    super(-1, 0, lim, cap, new byte[cap], 0);
    /*
       hb = new byte[cap];
       offset = 0;
      */
}

非直接缓冲区的写入步骤:

- 创建一个临时的ByteBuffer对象。
- 将非直接缓冲区的内容复制到临时缓冲中。
- 使用临时缓冲区执行低层次I/O操作。
- 临时缓冲区对象离开作用域,并最终成为被回收的无用数据。
2) DirectByteBuffer堆外

NIO的buffer的作用是堆外不影响GC:

buffer数组,NIO把byte数组的位置和长度发给内核态,内核空间可以访问用户空间,这叫跨传输。如果发送GC,stw,线程都停止,会回收垃圾对象,会进行碎片整理,所以位置会变。NIO选择在堆外创建一个同样大小的buffer,先从用户空间拷贝到堆外空间(cpu拷贝),再发送write系统调用,这时候发送堆外的位置+长度,堆外是不发生GC的,然后再拷贝到内核缓冲区。NIO合理使用堆外内存可以避免堆内到堆外的一次拷贝,可以直接放到堆外buffer。

  • buf.isDerect()查看是否在直接缓存区
  • ByteBuffer.allocateDirect() 堆外空间
直接缓冲区在JVM内存外开辟内存,在每次调用基础操作系统的一个本机IO之前或者之后,虚拟机都会避免将缓冲区的内容复制到中间缓冲区(或者从中间缓冲区复制内容),缓冲区的内容驻留在物理内存内,会少一次复制过程,如果需要循环使用缓冲区,用直接缓冲区可以很大地提高性能。虽然直接缓冲区使JVM可以进行高效的I/O操作,但它使用的内存是操作系统分配的,绕过了JVM堆栈,建立和销毁比堆栈上的缓冲区要更大的开销
// 直接缓冲区  堆内空间
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}
//源码
DirectByteBuffer(int cap) {  // package-private
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();//可以看出直接调用了内存页,让操作系统开辟缓存空间。
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);
    long base = 0;
    // 分配堆外内存,返回堆外地址base
    base = unsafe.allocateMemory(size);
    。。。;
    // 将该堆外空间初始化置位0
    unsafe.setMemory(base, size, (byte) 0);
    。。。;
    address = base + ps - (base & (ps - 1));
    // 创建Cleaner对象  负责清理堆外内存 // 里面传了个Deallocator释放器,他的run()里有`unsafe.freeMemeory(address)`。
    // Cleaner继承了PhantomReference虚引用。
    cleaner = Cleaner.create(this堆外内存引用, new Deallocator(base, size, cap));
    att = null;
}

因为堆外内存不是由JVM管理的,所以你的内存空间是页面对齐的,不受GC影响,这使得它成为处理本地代码的完美选择。 然而,你要C程序员一样,自己管理这个内存,必须自己分配和释放内存来防止内存泄漏。

3) MappedByteBuffer

Java NIO,提供了一个 MappedByteBuffer 类可以用来实现mmap内存映射。

MappedByteBuffer只能通过调用FileChannel.map()取得,再没有其他方式。

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

使用 MappedByteBuffer类要注意的是:mmap的文件映射,在full gc时才会进行释放。当close时,需要手动清除内存映射文件,可以反射调用sun.misc.Cleaner方法。

在NIO中使用了IOUtil.write(),会判断是否是堆外内存if(var instanceof DirectBuffer){return writeFromNativeBuffer();},否则是堆内,Util.getTemporaryDirectorBuffer(),创建临时堆外空间,大小和当前堆内buffer大小一样大,然后从堆内buffer拷贝到堆外buffer : var堆外.put(var堆内),然后writeFromNativeBuffer(),从堆外写到内核缓冲区。

如何释放堆外空间:

合理使用堆外内存可以减少拷贝。但是堆外不受JVM管理,如何释放?JVM中根据可达性算法从根对象跟踪引用对象。JVM的栈里指向了一个堆内的对象,该堆内对象指向堆外内存,该堆内对象代理操作堆外内存。如果不可达了后,判断该代理对象成为垃圾,回收的时候会去释放堆外空间。

java-NIO 零拷贝案例

案例要求:

  1. 使用传统的 IO 方法传递一个大文件
  2. 使用 NIO 零拷贝方式传递(transferTo)一个大文件
  3. 看看两种传递方式耗时时间分别是多少

传统堆内:

//NewIOServer.java
package com.atguigu.nio.zerocopy;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
//服务器
public class NewIOServer {
    public static void main(String[] args) throws Exception {
        InetSocketAddress address = new InetSocketAddress(7001);
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(address);
        // 在堆中创建 buffer // 堆外是使用ByteBuffer.allocateDirect() 
        ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
        while (true) {
            // 接收socket
            SocketChannel socketChannel = serverSocketChannel.accept();
            int readcount = 0;
            while (-1 != readcount) {
                try {
                    readcount = socketChannel.read(byteBuffer);
                }catch (Exception ex) {
                    // ex.printStackTrace();
                    break;
                }
                //
                byteBuffer.rewind(); //倒带 position = 0 mark 作废
            }
        }
    }
}

零拷贝客户端:transferTo 底层使用到零拷贝

//NewIOClient.java
package com.atguigu.nio.zerocopy;
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
public class NewIOClient {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost", 7001));
        String filename = "protoc-3.6.1-win32.zip";
        //得到一个文件 channel
        FileChannel fileChannel = new FileInputStream(filename).getChannel();
        //准备发送
        long startTime = System.currentTimeMillis();
        //在 linux 下一个 transferTo 方法就可以完成传输
        //在 windows 下 一次调用 transferTo 只能发送 8m , 就需要分段传输文件, 而且要主要
        //传输时的位置 =》 课后思考...
        //transferTo 底层使用到零拷贝
        long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 实际使用中,要计算size后自己除以8m然后计算位置
        System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() -
                                                                    startTime));
        //关闭
        fileChannel.close();
    }
}

5、零拷贝总结

  1. 我们说零拷贝,是从 操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。

  2. 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

mmap 和 sendFile 的区别

  1. mmap 适合小数据量读写,sendFile 适合大文件传输
  2. mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
  3. sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。

在这个选择上:rocketMQ 在消费消息时,使用了 mmap。kafka 使用了 sendFile。

2、sendfile 是将读到内核空间的数据,转到socket buffer,进行网络发送;

3、mmap将磁盘文件映射到内存,支持读和写,对内存的操作会反映在磁盘文件上。

我觉得mmap适合写到硬盘,sendfile适合写到网卡

6、堆外内存代码

案例:

@Test
public void testChannel1(){ // 非直接缓冲区,利用通道完成文件的复制,:
    FileInputStream inputStream = null;
    FileOutputStream outputStream = null;
    FileChannel inputChannel = null;
    FileChannel outputChannel = null;
    try {
        inputStream = new FileInputStream(new File("H:\\img\\1.jpg"));
        //完成文件的复制
        outputStream = new FileOutputStream(new File("H:\\img\\2.jpg"));
        // 获取通道
        inputChannel = inputStream.getChannel();
        outputChannel = outputStream.getChannel();

        // 分配缓冲区 // 非直接缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        // 将输入通道中数据存入缓冲区
        while(inputChannel.read(byteBuffer) != -1){
            // 将缓存区切换成读取数据的模式
            byteBuffer.flip();//limit设置为写的position;把读的position置为0
            // 缓冲区中数据写到输出通道中去
            outputChannel.write(byteBuffer);
            // 清空缓冲区
            byteBuffer.clear();//position变为0,limit变为capacity
        }
        System.out.println("读写成功");
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        //关闭通道,if!=null,就关闭一下,下面简写
        outputchannel.close();
        inputchannel.close();
        inputStream.close();
        outputStream.close();
        System.out.println("数据关闭成功");
    }
}

直接缓冲区完成文件的复制

@Test //直接缓冲区完成文件的复制(只有byteBuffer支持直接缓冲区)
public void channelTest2() throws IOException {
    //我们改用方式2 open方式进行
    FileChannel inputChannel = FileChannel.open(Paths.get("H:\\img\\9.jpg"),StandardOpenOption.READ);
    FileChannel outputChannel = 
        FileChannel.open(Paths.get("H:\\img\\10.jpg"),
                         StandardOpenOption.WRITE,
                         StandardOpenOption.READ,
                         StandardOpenOption.CREATE);//不存在就创建,存在也不报错。另外还有CREATE_NEW不存在就创建,如果存在就报错
    // 内存映射文件//原来是read、write
    //读到内存直接缓冲区
    MappedByteBuffer inputBuffer = inputChannel.map(FileChannel.MapMode.READ_ONLY,
                                                    0,inputChannel.size());//从0读size大小
    MappedByteBuffer outputBuffer = outputChannel.map(FileChannel.MapMode.READ_WRITE,
                                                      0,inputChannel.size());
    byte [] bytes = new byte[inputBuffer.limit()];
    inputBuffer.get(bytes);//通道读到
    outputBuffer.put(bytes);//缓冲写到通道

    //简写了try 
    inputChannel.close();
    outputChannel.close();
}

使用直接缓冲区对文件的存储性能会有极大的提升,但是直接缓冲区的内容可以驻留在常规的垃圾回收堆之外,因此,它们对应用程序的内存需求量造成的影响可能并不明显。所以,建议将直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们。

通道之间的数据传输(也是利用的直接缓冲器的方式)

transferFrom();
transferTo();
@Test
public void ChannelTest3() throws IOException {
    FileChannel inputChannel = FileChannel.open(Paths.get("H:\\img\\12.jpg"), StandardOpenOption.READ);
    FileChannel outputChannel = FileChannel.open(Paths.get("H:\\img\\haha.jpg"), StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);
    // 两种方式都行
    //inputChannel.transferTo(0,inputChannel.size(),outputChannel);
    outputChannel.transferFrom(inputChannel,0,inputChannel.size());

    inputChannel.close();
    outputChannel.close();
}
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileChannel.MapMode;

import org.junit.Test;

/**
 * 比较IO操作的性能比较
 * 1.内存映射最快
 * 2.NIO读写文件
 * 3.加了缓存流的IO
 * 4.普通IO
 * 使用文件通道的方式复制文件
 */
public class ChannelDemo {

	public static void main(String[] args) throws Exception {
		String src = "F:/022_37分钟零基础开发桌球小游戏项目~1.mp4";// 文件源路径,测试文件大小为145828字节
		String dest = "F:/022_37分钟零基础开发桌球小游戏项目~1bak.mp4";// 文件目的地路径

		copyFile(src, dest);// 总耗时:5457ms
		randomAccessFileCopy(src, dest);// 总耗时:1135ms
	}

	@Test
	private static void randomAccessFileCopy(String src, String dest) throws IOException {
		long start = System.currentTimeMillis();

		// 获得输入输出流的文件通道
		FileChannel fcIn = new RandomAccessFile(src, "r").getChannel();
		FileChannel fcOut = new RandomAccessFile(dest, "rw").getChannel();

		// 输入流的字节大小
		long size = fcIn.size();

		// 输入输出流映射到缓冲区
		MappedByteBuffer inBuf = fcIn.map(MapMode.READ_ONLY, 0, size);
		MappedByteBuffer outBuf = fcOut.map(MapMode.READ_WRITE, 0, size);

		// 目的:将输入流缓冲区的内容写到输出流缓冲区就完成了文件的复制
		// 操作的是缓冲区
		for (int i = 0; i < size; i++) {
			outBuf.put(inBuf.get());
		}

		// 关闭(关闭通道时会写入数据块)
		fcIn.close();
		fcOut.close();
		System.out.println("复制成功");

		long end = System.currentTimeMillis();
		System.out.println("总耗时:" + (end - start) + "ms");
	}

	@Test
	private static void copyFile(String src, String dest) throws Exception {

		long start = System.currentTimeMillis();

		// 获得输入流输出流的文件通道
		FileChannel fcIn = new FileInputStream(src).getChannel();
		FileChannel fcOut = new FileOutputStream(dest).getChannel();

		// 申请缓冲空间为1024个字节
		ByteBuffer buf = ByteBuffer.allocate(1024);
		while (fcIn.read(buf) != -1) {
			buf.flip();
			fcOut.write(buf);
			//清除缓存
			buf.clear();
		}

		//关闭通道
		fcIn.close();
		fcOut.close();
		System.out.println("复制成功");

		long end = System.currentTimeMillis();
		System.out.println("总耗时:" + (end - start) + "ms");
	}

}

推荐阅读

kafka专栏,有时间得读读:https://blog.csdn.net/z69183787/category_7665372.html

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值