本篇主要介绍NIO中的三大组件:Channel、Buffer、Selector的理论知识
1、NIO基本概念
NIO(non-blocking io 或 new io)区别于传统IO,是一种面向缓冲区的非阻塞IO操作,在传统IO中,数据是以字节或字符为单位从流中顺序读写,并且所有的读写操作都是阻塞的。例如,当一个线程调用read()方法时,该线程会被阻塞,直到数据被读入或发生错误。
NIO相比于传统的IO有以下的特点和优势:
-
非阻塞IO: NIO允许通道在非阻塞模式下工作,这意味着一个线程可以发起IO操作而不必等待操作完成,从而使得同一个线程可以同时处理多个IO操作。
-
面向缓冲区: NIO通过缓冲区进行数据的读写操作,而不是直接操作流。这种方式更接近底层操作系统的IO模型,能够提高效率。
-
多路复用: 通过选择器,一个线程可以监控多个通道的IO事件,如连接请求、读写数据等。这种机制使得NIO非常适合于实现高性能的服务器和网络应用。
NIO又包含了三大组件:
缓冲区(Buffer):
缓冲区是一个容器对象,用于包含特定基本类型的数据。所有的数据都通过缓冲区进行读写操作。常见的缓冲区类型包括:
- ByteBuffer:字节缓冲区
- CharBuffer:字符缓冲区
- IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer:对应于其他基本数据类型的缓冲区
缓冲区的核心属性包括:
- Capacity:缓冲区能容纳的数据元素的总数
- Position:下一个要读取或写入的元素的位置
- Limit:写入/读取限制
- Mark:一个备忘位置,通过reset()方法可以恢复到这个位置
通道(Channel):
通道类似于传统IO中的流(Stream),但不同的是,通道可以进行双向操作(既可以读也可以写)。通道用于从数据源(如文件、网络套接字)读数据或将数据写入到数据源。
- 通道可以进行异步读写
- 通道可以与缓冲区直接交互
- 通道可以映射到文件、网络套接字等
常见的通道类型包括:
- FileChannel:用于文件的通道
- SocketChannel:用于TCP连接的通道
- ServerSocketChannel:用于监听TCP连接的通道
- DatagramChannel:用于UDP连接的通道
选择器(Selector):
选择器是Java NIO的核心组件之一,用于管理多个通道的IO事件。通过选择器,一个线程可以处理多个通道的读写事件,从而实现高效的多路复用,如果使用传统的IO:
假设每个socket连接都要通过新创建一个线程去处理,第一随着连接数的增加,线程的数量势必也会不断增加,第二,如果线程数量大于cpu核心数,会频繁发生上下文切换问题,很影响性能。
如果采用线程池的方式改进,一个线程可以服务多个socket连接,但是如果在阻塞模式下,线程依旧仅能处理一个 socket 连接。例如一个餐馆服务员面对A,B两个客人,A一直在看菜单,服务员无法利用这段时间去兼顾B。
2、传统IO面向流和NIO面向缓冲区的区别
首先面向流是一种逐个字节或字符处理的思想:
数据是按顺序一个字节一个字节地读取或写入的,像流水一样。处理时不关心数据的整体,只关注当前读取或写入的字节。
并且读写操作是阻塞的,一个读写操作没有完成,当前线程会一直等待。例如,当你从一个输入流中读取数据时,线程会被阻塞,直到数据被完全读取。
而面向缓冲区是一种数据块处理的思想:
数据通过缓冲区(Buffer)批量读取或写入。缓冲区是内存中的一个块,数据在缓冲区中处理,然后通过通道(Channel)整体读入或写出。
通道可以配置为非阻塞模式,允许线程在数据还未准备好时继续执行其他操作。例如,使用选择器(Selector)来管理多个通道,一个线程可以处理多个连接。
3、通道(Channel)和缓冲区(Buffer)的联系
缓冲区在读写操作过程中会维护三个关键属性:position(指针)、limit(限制)和capacity(容量)。
- position:指示下一个要读取或写入的元素位置。
- limit:在读模式下,limit表示缓冲区可读数据的大小;在写模式下,limit表示缓冲区总的容量。
- capacity:缓冲区的总大小,在缓冲区创建时设定,不可改变。(不同于集合,没有扩容机制)
在获取缓冲区实例时,通常需要指定容量,假设我们指定的容量是10:
然后向缓冲区放入了4个字节的数据:
通过filp方法切换至读取模式,此时position指针代表的是当前从索引为几的地方开始读,limit也变成了可以最多读取多少个元素。
在读取了4个字节后:
最后调用clear方法切换回写入模式:(clear会清除所有元素)
compact方法是将未读完的元素向前压缩,然后切换至写模式
那么数据是如何在通道和缓冲区之间进行读写操作的呢?
读操作:当从通道读取数据时,数据被读入到缓冲区中。
- 调用通道的read方法,将数据从通道读取到缓冲区。
- 使用缓冲区的filp方法切换到读取模式。
- 从缓冲区中读取数据。
写操作:当向通道写入数据时,数据从缓冲区写入到通道中。
- 将数据放入缓冲区。
- 使用缓冲区的clear() 或 compact() 方法切换到写入模式。
- 调用通道的write方法,将数据从缓冲区写入到通道。
总结:
- 通道是数据传输的载体,负责从数据源读取数据或将数据写入数据源。
- 缓冲区是数据的临时存储区,用于存放从通道中读入的数据或将要写入通道的数据。
4、选择器(Selector)是如何实现多路复用
在学习选择器(Selector)是如何实现多路复用前,首先需要了解一下什么是多路复用:
使用一个单独的线程同时处理多个I/O操作,而不需要为每个I/O流创建单独的线程或进程,其关键点在于,通过在一个线程中管理多个连接,避免线程创建和上下文切换的开销,并且系统监视多个I/O流,当某个流就绪时(如有数据可读、可以写入等),会通知应用程序处理这个流上的I/O事件。同时/O操作不会阻塞进程或线程,可以同时处理多个I/O请求。
选择器(Selector)实现多路复用,一般需要分为以下几步:
- 将通道(Channel)配置为非阻塞模式,然后注册到选择器上,注册时需要指定需要被监视的事件类型,如连接事件、读事件、写事件。
- 选择器通过select()方法轮询已注册通道的I/O事件。select()方法会阻塞,直到至少有一个通道准备好进行I/O操作(如有数据可读)。一旦有通道就绪,选择器返回已就绪通道的集合,这些通道可以进行I/O操作。
- 程序通过迭代已就绪通道的集合,逐个处理每个通道的I/O事件。
- 根据通道的事件类型(如读就绪、写就绪),执行相应的I/O操作。
在第一步中,所有通道都被配置为非阻塞模式,这意味着I/O操作不会阻塞线程。如果通道当前没有数据可读或不能写入,操作会立即返回而不会阻塞。
在第二步中,选择器使用事件通知机制,监视所有注册通道的状态。当一个或多个通道的状态发生变化(如有数据可读),选择器会通知应用程序。
什么是事件通知机制?
简单来说,每个注册通道在选择器中都有一个对应的键(SelectionKey),键中记录了通道和事件类型,选择器内部维护一个键集合(Key Set),包含所有注册通道的键和事件类型,选择器在轮询时更新键集合,将就绪通道的键放入已就绪键集合(Ready Key Set)。这样选择器在每次轮询时,只处理已就绪通道的I/O事件,未就绪通道不做处理。