一 简介
1 三大模型是什么?
IO三大模型之一,BIO,AIO,还有我们的主角NIO(non-blocking-io),也就是同步非阻塞式IO。这三种模型到底是干什么的?其实这三种模型都是对于JAVA的一种I/O框架,用来进行输入,输出操作。另一方面,以上三种模型提供的是统一的API操作,可以理解为JAVA针对于各种操作系统的IO模型进行的封装,从而具有一定的跨平台性等。作为开发者,我们不需要再了解有关操作系统层面的知识,直接使用这三种框架封装的API进行使用即可!
2 NIO
NIO(New Input/Output)是Java中的一种I/O(输入/输出)框架,主要用于处理异步I/O操作。它在Java 1.4版本中引入,旨在提高I/O操作的性能和可扩展性。
NIO的主要特点:
-
非阻塞I/O:NIO允许线程在发起I/O操作后继续执行其他任务,而不必等待I/O操作完成,从而提高了应用的响应性。
-
选择器(Selector):NIO引入了选择器,允许单个线程管理多个通道(Channel),从而减少了资源消耗和上下文切换的开销。
-
通道(Channel):NIO使用通道进行数据的读写,通道是双向的,可以同时进行读和写操作。
-
缓冲区(Buffer):NIO使用缓冲区来存储数据,缓冲区在读写过程中充当数据的临时存储区。
-
文件通道(FileChannel):NIO还提供了文件通道,可以用于高效地处理文件I/O。
NIO特别适合于需要处理大量并发连接的网络应用,如Web服务器和聊天应用等。通过非阻塞I/O和选择器,NIO能够更好地利用系统资源,提高整体性能。
二 NIO三大组件
1.Channel(通道)
相当于我们之前学习IO流操作中的输入输出流,起到一个通道的作用。但是在这里这个更加全面,既可以充当输入流,又可以充当输出流。是为双向通道。channel比Stream更底层,双向。
2.Buffer(缓冲区)
缓冲区,作为一个在网络或者是本地文件与计算机之间的桥梁,任何数据在进入通道Channel之前都需要经过Buffer。前者可以将数据读入Buffer,也可以将Buffer当中的数据写入Channel当中
3.Selector(选择器)
选择器是NIO中用于处理非阻塞I/O的核心组件。它允许单个线程管理多个通道。
我认为将其与各种服务器设计模式相结合进行介绍,会更加清晰。
我们以BIO(同步阻塞IO)举例,这样更加方便理解
我们都知道,BIO是一种同步阻塞IO,在服务端与客户端之间,其会建立一个一个通道(Socket)以进行彼此之间的数据输入和输出,但是对于一个服务端,如果想要与多个客户端之间建立连接,那么就需要建立多个线程,也就是一个线程对应一个客户端。如下图:
但是这样存在很大的弊端,如果有很多的客户端都进行访问,那么就会创建多个线程与其连接,就会导致CPU资源被占用许多,甚至直接宕机,导致客户端无法被外部访问。各个线程上下文之间的来回切换,线程创建,也会消耗大量资源。(这也就是为什么要创建线程池了。。。。)另一方面,BIO还有一个特点,那就是阻塞!如果一个客户端没有发送消息,那么对应的线程就会一直等待,直到客户端发送新的消息,才会继续进行执行。在此之前,等待的过程,就会造成CPU资源的大量占用。所以这种方式只适用于比较少的客户端访问。其实这就是服务器的设计之一 ----- 多线程设计模式
有的大佬就会想到,可以创建线程池来处理问题,没错,我们上面面对的一个大问题就是:线程会随着客户端的访问而不断的创建。从而导致资源的大量占用。使用线程池可以设定线程的最大数量,用以解决客户端访问过多造成的问题。但是这种解决方式也存在很大问题,至于为什么,大家可以想一想。
线程池设计模式。顾名思义,就是针对之前一个线程对应一个客户端的情形进行该进,变为一个线程针对多个客户端进行处理,如下:
这种设计模式相比多线程有所提高,但是因为在阻塞模式下,依旧是一个线程在处理时仅仅只能够处理一个客户端,也就是Socket连接,因此其适合一个线程下连接的Scoket比较少的情况。
Slector版设计模式。selector 的作用就是配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景(low traffic),如下图:
这种设计模式,一个线程对应一个选择器,这个选择器会直接监视其下的所有通道,一旦这些通道有什么动作发生,选择器就会将其汇报给线程,由线程进行解决。
非阻塞的形式,也就是说,我们选择器监视下的各个通道,比如第一个通道,如果其当前没有任务,那么我们的选择器就不会等待,而是直接看下一个通道是否有任务。
适合流量比较低,指的就是,如果我们的通道一下接收一个比较大的数据,那么选择器就会长时间的停留于它,直到它的任务结束,数据如果比较大,也就是如果流量比较大,那么就会影响其他通道的正常使用,因此适用于流量比较低的场景。
由此可见,选择器适合于事件驱动的应用程序,可以有效地处理大量并发连接,减少线程上下文切换的开销。
三 Buffer - ByteBuffer
上面我们简单说了几个比较缓冲区的Buffer,但是比较常用的实际上就一个ByteBuffer,所以我们拿这个作为详细介绍
1.基本使用
@Slf4j
public class ChannelDemo1 {public static void main(String[] args) {try (RandomAccessFile file = new RandomAccessFile("helloword/data.txt", "rw")) {FileChannel channel = file.getChannel();ByteBuffer buffer = ByteBuffer.allocate(10);do {// 向 buffer 写入int len = channel.read(buffer);log.debug("读到字节数:{}", len);if (len == -1) {break;}// 切换 buffer 读模式buffer.flip();while(buffer.hasRemaining()) {log.debug("{}", (char)buffer.get());}// 切换 buffer 写模式buffer.clear();} while (true);} catch (IOException e) {e.printStackTrace();}}
}
以上使用通道FileChannel与Buffer进行了一个简单的文件读取,以及对应文件内容的日志打印。
2.BetyBuffer使用说明:
-
向 buffer 写入数据,例如调用 channel.read(buffer)
-
调用 flip() 切换至读模式
-
从 buffer 读取数据,例如调用 buffer.get()
-
调用 clear() 或 compact() 切换至写模式
-
重复 1~4 步骤
3.ByteBuffer结构
其内部实际上是一个类似数组的存储结构,数组的长度也就是我们在使用allocate方法的时候自定义的。
ByteBuffer主要有三个比较重要的属性:
position(当前位置)
limit(限制)
capaciy(为数组容量)
刚开始写入数据操作
在我们刚开始进行数据写入操作的时候,position从头开始,后两个都在最后,每写入一个数据,对应的position指针就会向后移动一位,如图。
获取数据
使用filp获取数据的时候,对应的position指针就会从头开始,索引归零,取而代之的是limit,用来代替之前写入的数据的最大索引位置,为了防止读取出来的数据为空。
clear动作发生之后,会将数组内部的所有元素都清空,变为空,从头来过,重新进行写操作。
还有一种写操作,为compact动作,比如我们因为某种原因,之前没有读取完的数据,那么就会直接将其进向前压缩,并且更新对应读取的时候的Position以及Limit的位置。从而达到写的操作。