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