NIO
2024-11-05 19:40:40 1 举报
AI智能生成
NIO(New Input/Output)是一个Java API,用于处理I/O操作,特别是文件操作。它提供了一种非阻塞的、高性能的I/O操作方式。NIO的核心组件包括通道(Channel)、缓冲区(Buffer)和选择器(Selector)。Channel用于在文件系统和设备间传输数据,Buffer用于存储数据,Selector用于管理Channel,以实现非阻塞IO。NIO可以提高应用程序的I/O性能,并允许一个线程同时管理多个I/O操作。
作者其他创作
大纲/内容
Socket
服务端
客户端
什么是NIO
NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 BIO 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO被称为 no-blocking io 或者 new io都说得通
N和BIO的主要区别
面向流(Stream)与面向缓冲(Buffer)
Java NIO和IO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据
阻塞与非阻塞
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)
Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)
三大组件
Buffer & ByteBuffer
buffer 则用来缓冲读写数据
JDK NIO是面向缓冲的。Buffer就是这个缓冲,用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用程序再读缓冲的数据。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
NIO通讯的流程图
正确使用步骤
ByteBuffer内部结构
常用方法
allocate
HeapByteBuffer
java堆内存 读写效率较低,受到垃圾回收的影响
DirectByteBuffer
- 直接内存 读写效率高(少一次拷贝)使用的系统内存,不会受gc影响
- 分配内存的效率低,使用不当会造成内存泄漏
put & get [绝对读写]
put(int index, byte b):绝对写,向byteBuffer底层的bytes中下标为index的位置插入byte b,不改变position的值。
get(int index)`: 属于绝对读,读取byteBuffer底层的bytes中下标为index的byte,不改变position
rewind & get(index)
Buffer.rewind() 将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。
mark & reset
通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position
注意:rewind 和 flip 都会清除 mark 位置
equals
当满足下列条件时,表示两个Buffer相等:
- 有相同的类型(byte、char、int等)。
- Buffer中剩余的byte、char等的个数相等。
- Buffer中所有剩余的byte、char等都相同。
compareTo
compareTo()方法比较两个Buffer的剩余元素(byte、char等), 如果满足下列条件,则认为一个Buffer“小于”另一个Buffer:
- 1. 第一个不相等的元素小于另一个Buffer中对应的元素 。
- 2. 所有元素都相等,但第一个Buffer比另一个先耗尽(第一个Buffer的元素个数比另一个少)。
buffer方法总结
字符串与 ByteBuffer
Scattering Reads [分散读]
Gathering Writes [集中写]
Channel
读写数据的双向通道,可以从 channel 将数据读入 buffer,也可以将 buffer 的数据写入 channel
常见的Channel
FileChannel
DatagramChannel 【UDP】
SocketChannel
TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口 到 服务器IP:端口的通信连接
ServerSocketChannel
应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议
所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类
Selector
Selector的英文含义是“选择器”,也可以称为为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行。
Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器(Selectors),然后使用一个单独的线程来操作这个选择器,进而“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器
Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器(Selectors),然后使用一个单独的线程来操作这个选择器,进而“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器
多线程版设计
缺点
线程池版本
缺点
Selector版设计
优点
文件编程
FileChannel
不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 `getChannel` 方法
- 通过 FileInputStream 获取的 channel 只能读
- 通过 FileOutputStream 获取的 channel 只能写
- 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定
强制写入
操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘。可以调用` force(true) ` 方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘
这个缓存是指在内存中与外存映射的一些内存块,也叫做`页缓存`,目的是减少真正的块IO。
这个缓存是指在内存中与外存映射的一些内存块,也叫做`页缓存`,目的是减少真正的块IO。
案例:两个 Channel 传输数据
Path & Paths
Files
get
copy
createDirectory & createDirectorys
delete
walkFileTree
网络编程
阻塞 & 非阻塞
阻塞
阻塞模式下,相关方法都会导致线程暂停
ServerSocketChannel.accept会在没有连接建立时让线程暂停
SocketChannel.read 会在没有数据可读时让线程暂停
阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置
非阻塞
多路复用
I/O多路复用是将多个I/O的阻塞复用到一个select的阻塞上。从而使系统在单线程的情况下可以处理多个客户端请求(Channel读写)
多路复用仅针对网络 IO、普通文件 IO 没法利用多路复用
如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证
- 有可连接事件时才去连接
- 有可读事件才去读取
- 有可写事件才去写入
Selector
优点
一个线程配合 selector 就可以监控多个 channel 的事件,事件发生线程才去处理。避免非阻塞模式下所做无用功
让这个线程能够被充分利用
节约了线程的数量
减少了线程上下文切换
Channel绑定的事件(SelectionKey)
channel 必须工作在非阻塞模式 channel.configureBlocking(false);
FileChannel 没有非阻塞模式,因此不能配合 selector 一起使用
FileChannel 没有非阻塞模式,因此不能配合 selector 一起使用
SelectionKey是一个抽象类,表示selectableChannel在Selector中注册的标识。每个Channel向Selector注册时,都将会创建一个SelectionKey。SelectionKey将Channel与Selector建立了关系,并维护了channel事件。
可以通过`cancel`方法取消键,取消的键不会立即从selector中移除,而是添加到cancelledKeys中,在下一次select操作时移除它。所以在调用某个key时,需要使用`isValid`进行校验.
可以通过`cancel`方法取消键,取消的键不会立即从selector中移除,而是添加到cancelledKeys中,在下一次select操作时移除它。所以在调用某个key时,需要使用`isValid`进行校验.
SelectionKey的关注的事件类型
JAVA NIO共定义了四种:OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT(定义在SelectionKey中),分别对应 读、写、请求连接、接受连接等网络Socket操作
服务端和客户端分别感兴趣的类型
ServerSocketChannel 和 SocketChannel 可以注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时OS会通知channel,下表描述各种Channel允许注册的操作类型,Y表示允许注册,N表示不允许注册,其中服务器SocketChannel指由服务器 ServerSocketChannel.accept() 返回的对象
- 服务器启动ServerSocketChannel,关注`OP_ACCEPT`事件,
- 客户端启动SocketChannel,连接服务器,关注`OP_CONNECT`事件
- 服务器接受连接,启动一个服务器的SocketChannel,这个 SocketChannel 可以关注`OP_READ`、`OP_WRITE`事件,一般连接建立后会直接关注`OP_READ`事件
- 客户端这边的客户端SocketChannel发现连接建立后,可以关注`OP_READ`、`OP_WRITE`事件,一般是需要客户端需要发送数据了才关注`OP_READ`事件
- 连接建立后客户端与服务器端开始相互发送消息(读写),根据实际情况来关注OP_READ、OP_WRITE事件
监听Channel事件
select何时不阻塞?
处理accept事件
事件发生后能否不处理?
事件发生后,要么处理(remove),要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发
处理read事件
为何要 iter.remove()?
cancel 的作用
cancel 会取消注册在 selector 上的 channel,并从 keys 集合中删除 key 后续不会再监听事件
客户端不管是正常断开还是异常断开都会产生一个读事件
客户端不管是正常断开还是异常断开都会产生一个读事件
处理消息边界
ByteBuffer大小分配
每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer
ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer
一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能,参考实现 [http://tutorials.jenkov.com/java-performance/resizable-array.html](http://tutorials.jenkov.com/java-performance/resizable-array.html)
另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗
处理write事件
- 非阻塞模式下,无法保证把 buffer 中所有数据都写入 channel,因此需要追踪 write 方法的返回值(代表实际写入字节数)
- 用 selector 监听所有 channel 的可写事件,每个 channel 都需要一个 key 来跟踪 buffer,但这样又会导致占用内存过多,就有两阶段策略
① 当消息处理器第一次写入消息时,才将 channel 注册到 selector 上
② selector 检查 channel 上的可写事件,如果所有的数据写完了,就取消 channel 的注册(如果不取消,会每次可写均会触发 write 事件)
一次无法写完例子
write 为何要取消?
只要向 channel 发送数据时,socket 缓冲可写,这个事件会频繁触发,因此应当只在 socket 缓冲区写不下时再关注可写事件,数据写完之后再取消关注
更进一步(多线程)
简易流程图
分两组选择器
- 单线程配一个选择器,专门处理 accept 事件
- 创建 cpu 核心数的线程,每个线程配一个选择器,轮流处理 read,write事件
这就是NettyReactor模式的简易版本
如何拿到CPU个数
Runtime.getRuntime().availableProcessors()
NIO vs BIO
直接内存深入辨析
在所有的网络通信和应用程序中,每个TCP的Socket的内核中都有一个发送缓冲区(SO_SNDBUF) 和一个 接收缓冲区(SO_RECVBUF),(默认最小大小为4kb )可以使用相关套接字选项来更改该缓冲区大小。
- 当某个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下 该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),假设该套接字是阻塞的,则该应用进程将被投入睡眠。
- 内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据。
堆外内存
直接内存
在IO读写上,如果是使用堆内存,JDK会先创建一个`DirectBuffer`(应用进程缓冲区),再去执行真正的写操作。这是因为,当我们把一个地址通过JNI传递给底层的C库的时候,有一个基本的要求,就是这个地址上的内容不能失效。然而,在GC管理下的对象是会在Java堆中移动的。也就是说,有可能我把一个地址传给底层的write,但是这段内存却因为GC整理内存而失效了。所以必须要把待发送的数据放到一个GC管不着的地方(堆外内存)。这就是调用native方法之前,数据—定要在堆外内存的原因。
可见,站在网络通信的角度DirectBuffer并没有节省什么内存拷贝,只是Java网络通信里因为HeapBuffer必须多做一次拷贝,使用DirectBuffer就会少一次内存拷贝。相比没有使用堆内存的Java程序,使用直接内存的Java程序当然更快一点。
从垃圾回收的角度而言,直接内存不受 GC(新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到HeapBuffer要小
可见,站在网络通信的角度DirectBuffer并没有节省什么内存拷贝,只是Java网络通信里因为HeapBuffer必须多做一次拷贝,使用DirectBuffer就会少一次内存拷贝。相比没有使用堆内存的Java程序,使用直接内存的Java程序当然更快一点。
从垃圾回收的角度而言,直接内存不受 GC(新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到HeapBuffer要小
为什么要使用直接内存【优点】
直接内存,其实就是不受 JVM 控制的内存。相比于堆内存有几个优势:
1、 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作;
2、 加快了复制的速度因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后再发送,而堆外内存相当于省略掉了这个工作;
3、 可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现;
4、 可以扩展至更大的内存空间比如超过1TB甚至比主存还大的空间;
1、 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作;
2、 加快了复制的速度因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后再发送,而堆外内存相当于省略掉了这个工作;
3、 可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现;
4、 可以扩展至更大的内存空间比如超过1TB甚至比主存还大的空间;
在IO读写上,如果是使用堆内存,JDK会先创建一个`DirectBuffer`(应用进程缓冲区),再去执行真正的写操作。这是因为,当我们把一个地址通过JNI传递给底层的C库的时候,有一个基本的要求,就是这个地址上的内容不能失效`。然而,在GC管理下的对象是会在Java堆中移动的。也就是说,有可能我把一个地址传给底层的write,但是这段内存却因为GC整理内存而失效了。所以必须要把待发送的数据放到一个GC管不着的地方。这就是调用native方法之前,数据—定要在堆外内存的原因。
缺点
分配回收成本较高
不受JVM内存回收管理
直接内存大小可以通过`MaxDirectMemorySize`设置如果不指定,默认与堆的最大值-Xmx参数值一致。
堆外内存的分配
ByteBuffer#allocateDirect
通过该方式分配堆外内存其实最底层还是使用的是Unsafe#allocateMemory进行分配内存,ByteBuffer只是对Unsafe做了一层封装
Unsafe#allocateMemory
堆外内存回收
Unsafe#freeMemory
在Unsafe中提供了freeMemory的实现进行回收堆外内存,但是前提是需要知道被分配的堆外内存地址才可以实现对应的内存回收
JVM回收堆外内存
通过ByteBuffer#allocateDirect分配的堆外内存在JVM中其实也是存在一定的内存占用的,具体关联关系如下
当通过ByteBuffer#allocateDirect分配堆外内存后,会将堆外内存的地址、大小等信息通过DirectByteBuffer进行关联,那么堆内存中就可以关联到堆外内存
- JVM执行Full GC时会将DirectByteBuffer进行回收,回收之后Clearner就不存在引用关系
- 再下一次发生GC时会将Cleaner对象放入ReferenceQueue中,同时将Cleaner从链表中移除
- 最后调用unsafe#freeMemory清除堆外内存
DirectByteBuffer 是存在堆内存中的对象,那么既然存在堆内存中就会发生GC晋级,即晋升到老年代中,在老年代中就会发生Full GC或者Old GC
Stream VS Channel
stream 不会自动缓冲数据,channel 会利用系统提供的发送缓冲区、接收缓冲区(更为底层)
stream 仅支持阻塞 API(BIO),channel 同时支持阻塞、非阻塞 API,网络 channel 可配合 selector 实现多路复用
二者均为全双工,即读写可以同时进行
IO模型
基础概念
模型
同步阻塞IO
同步非阻塞IO
同步多路复用
异步IO
阻塞IO vs 多路复用
零拷贝
传统IO
1. fileInputStream.read(buffer) 操作数据拷贝及状态转换分析
① 硬盘 ( 初始用户态 ) -> 内核缓冲区 ( 内核态 ) : 首先将硬盘中的文件 , 进行 DMA [ 1 ] ^{[1]} [1] 拷贝 , 此处对应 read 方法 , 将文件数据从硬盘中拷贝到 内核缓冲区 ; ( 用户态切换成内核态 )
② 内核缓冲区 ( 内核态 ) -> 用户缓冲区 ( 用户态 ) : 将内核缓冲区中的数据 , 通过 CPU 拷贝 方式 , 拷贝到 用户缓冲区 ; ( 内核态切换成用户态 )
② 内核缓冲区 ( 内核态 ) -> 用户缓冲区 ( 用户态 ) : 将内核缓冲区中的数据 , 通过 CPU 拷贝 方式 , 拷贝到 用户缓冲区 ; ( 内核态切换成用户态 )
2. socket.getOutputStream().write(buffer, 0, readLen) 操作数据拷贝及状态转换分析
① 用户缓冲区 ( 用户态 ) -> Socket 缓冲区 ( 内核态 ) : 将用户缓冲区中的数据 , 再次通过 CPU 拷贝 方式 , 拷贝到 Socket 缓冲区 ; ( 用户态切换成内核态 )
② Socket 缓冲区 ( 内核态 ) -> 协议栈 : 再次使用 DMA [ 1 ] ^{[1]} [1] 拷贝 , 将 Socket 缓冲区中的数据拷贝到 协议栈 ( Protocol Engine ) 中 ;
② Socket 缓冲区 ( 内核态 ) -> 协议栈 : 再次使用 DMA [ 1 ] ^{[1]} [1] 拷贝 , 将 Socket 缓冲区中的数据拷贝到 协议栈 ( Protocol Engine ) 中 ;
4次数据拷贝,3次用户态和内核态的切换【最终如果算上将内核态切换为用户态(执行应用代码逻辑),则为4次】
传统IO拷贝次数分析
开始时数据存储在 硬盘文件 中 , 直接内存拷贝 ( Direct Memory Access ) 到 内核缓冲区 , CPU 拷贝 到 用户缓冲区 , CPU 拷贝 到 Socket 缓冲区 , 直接内存拷贝 ( Direct Memory Access ) 到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> 用户缓冲区 ( 用户空间 ) -> Socket 缓冲区 ( 内核空间 ) -> 协议栈
传统IO状态改变分析
用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态
DMA 全称 ( Direct Memory Access ) , 直接内存拷贝 , 该拷贝通过内存完成 , 不涉及 CPU 参与
NIO优化
DirectByteBuffer 直接内存(Mmap) 【3次copy,3(4)次切换】
将硬盘中的文件映射到 内核缓冲区 , 用户空间中的应用程序也可以访问该 内核缓冲区 中的数据 , 使用这种机制 , 原来的 4 次数据拷贝减少到了 3 次
mmap 数据拷贝过程
① 硬盘文件 -> 内核缓冲区 : 硬盘文件数据 , DMA 拷贝到 内核缓冲区 中 , 应用程序可以直接访问该 内核缓冲区中的数据 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> Socket 缓冲区 ( 内核空间 ) -> 协议栈
mmap 状态切换
其状态切换还是 3 次 , 由初始状态 用户态 , 在拷贝数据到内核缓冲区时 , 切换成内核态 , 访问该内核缓冲区数据时 , 又切换成用户态 , 将数据拷贝到 Socket 缓冲区时 , 切换成内核态 , 最后再切换成用户态 , 执行后续应用程序代码逻辑 ;
用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态
【3次copy ,1(2)次切换】
进一步优化(底层采用了 linux 2.1 后提供的 `sendFile` 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
进一步优化(底层采用了 linux 2.1 后提供的 `sendFile` 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
其实现了由 内核缓冲区 直接将数据拷贝到 Socket 缓冲区 , 该操作直接在内核空间完成 , 不经过用户空间 , 没有用户态参与 , 因此 减少了一次用户态切换
数据拷贝分析
① 硬盘文件 -> 内核缓冲区 : 硬盘文件数据 , DMA 拷贝到 内核缓冲区 中 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> Socket 缓冲区 ( 内核空间 ) -> 协议栈
sendFile 函数 状态切换分析
其状态切换只有 2次 , 由初始状态 用户态 , 在拷贝数据到内核缓冲区时 , 切换成内核态 , 在内核态直接将数据拷贝到 Socket 缓冲区时 , 还是处于内核状态 , 之后拷贝到协议栈时 , 变成用户状态
用户态 -> 内核态 -> 用户态
【2次copy,1(2)次切换】 (全称 DMA 拷贝 , 没有 CPU 拷贝)
进一步优化(linux 2.4) 带有DMA收集拷贝功能的sendfile实现的零拷贝
进一步优化(linux 2.4) 带有DMA收集拷贝功能的sendfile实现的零拷贝
'
少量 CPU 拷贝 : 该机制还存在少量的 CPU 拷贝 , 其 对性能的消耗忽略不计 ; 这些 CPU 拷贝操作是从 内核缓冲区 中将数据的长度 ( Length ) , 偏移量 ( Offset ) 拷贝到 Socket 缓冲区 ;
数据拷贝分析
① 硬盘文件 -> 内核缓冲区 : 硬盘文件数据 , DMA 拷贝到 内核缓冲区 中 ;
② 内核缓冲区 -> -> 协议栈 : 通过 DMA 拷贝 , 将 内核缓冲区 中的数据直接拷贝到 协议栈 ;
② 内核缓冲区 -> -> 协议栈 : 通过 DMA 拷贝 , 将 内核缓冲区 中的数据直接拷贝到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> 协议栈
sendFile 函数 状态切换分析
其状态切换只有 2 次 , 由初始状态 用户态 , 在拷贝数据到内核缓冲区时 , 切换成内核态 , 在内核态直接将数据拷贝到协议栈时 , 变成用户状态 ;
用户态 -> 内核态 -> 用户态
优点
AIO
Java生态圈中的零拷贝
Linux提供的零拷贝技术 Java并不是全支持,支持2种(内存映射mmap、sendfile)
NIO提供的内存映射 MappedByteBuffer
NIO中的 FileChannel.map() 方法其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap() 实现的
将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝
NIO提供的sendfile
Java NIO 中提供的 FileChannel 拥有 `transferTo` 和 `transferFrom` 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或者直接把另外一个 Channel 中的数据拷贝到 FileChannel。该接口常被用于高效的网络 / 文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法
Kafka中的零拷贝
Kafka两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据。
Producer生产的数据持久化到broker,broker里采用mmap文件映射,实现顺序的快速写入;
Customer从broker读取数据,broker里采用sendfile,将磁盘文件读到OS内核缓冲区后,直接转到socket buffer进行网络发送。
Netty的零拷贝实现
网络通信
- 在网络通信上,Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
缓存操作
Netty提供了`CompositeByteBuf` 类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝 通过wrap操作,我们可以将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象,进而避免了拷贝操作。
ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。
ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。
文件传输
Netty 的通过`FileRegion`包装的 `FileChannel.tranferTo` 实现文件传输,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题
0 条评论
下一页