0%

JAVA基础知识-NIO

Java中IO模型。

常用的四种IO模型

同步阻塞IO

在Java应用程序进程中,默认情况下,所有的socket连接的IO操作都是同步阻塞IO(BlockingIO);在阻塞式IO模型中,Java应用程序从IO系统调用开始,直到系统调用返回,在这段时间内,Java进程是阻塞的。返回成功后,应用进程开始处理用户空间的缓存区数据。
在Java中发起一个socket的read读操作的系统调用,流程大致如下:

  1. 从Java启动IO读read系统调用开始,用户线程就进入阻塞状态。
  2. 当系统内核收到read系统调用,就开始准备数据。一开始,数据可能还没开始到达内核缓冲区(例如,还没有收到一个完整的socket数据包),这个时候内核就要等待。
  3. 内核一直等到完整的数据到达,就会将数据从内核缓冲区中复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)。
  4. 直到内核返回后,用户线程才会接触阻塞的状态,重新运行起来。
    总之,阻塞IO的特点是:在内核进行IO执行的两个阶段,用户线程都被阻塞了。

同步非阻塞NIO

socket连接默认是阻塞模式,在Linux系统下,可以通过设置将socket变成为非阻塞的模式(Non-Blocking)。使用非阻塞模式的IO读写,叫作同步非阻塞IO(None Blocking IO),简称为NIO模式。在NIO模型中,应用程序一旦开始IO系统调用,会出现以下两种情况:

  1. 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为了读取到最终的数据,用户线程需要不断地发起IO系统调用。
  2. 在内核缓冲区中有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。

举个例子。发起一个非阻塞socket的read读操作的系统调用,流程如下:

  1. 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为了读取到最终的数据,用户线程需要不断地发起IO系统调用。
  2. 内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到的用户缓冲区的字节数)。
  3. 用户线程读到数据后,才会解除阻塞状态,重新运行起来。也就是说,用户进程需要经过多次的尝试,才能保证最终真正读到数据,而后继续执行。同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。同步非阻塞IO的优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。同步非阻塞IO的缺点:不断地轮询内核,这将占用大量的CPU时间,效率低下。

IO多路复用模型

举个例子来说明IO多路复用模型的流程。发起一个多路复用IO的read读操作的系统调用,流程如下:

  1. 选择器注册。在这种模式中,首先,将需要read操作的目标socket网络连接,提前注册到select/epoll选择器中,Java中对应的选择器类是Selector类。然后,才可以开启整个IO多路复用模型的轮询流程。
  2. 就绪状态的轮询。通过选择器的查询方法,查询注册过的所有socket连接的就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列表。当任何一个注册过的socket中的数据准备好了,内核缓冲区有数据(就绪)了,内核就将该socket加入到就绪的列表中。当用户进程调用了select查询方法,那么整个线程会被阻塞掉。
  3. 用户线程获得了就绪状态的列表后,根据其中的socket连接,发起read系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
  4. 复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。

IO多路复用模型的优点:与一个线程维护一个连接的阻塞IO模式相比,使用select/epoll的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接(Connection)。系统不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。Java语言的NIO(New IO)技术,使用的就是IO多路复用模型。在Linux系统上,使用的是epoll系统调用。

IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。

异步IO模型 - AIO (Asynchronous IO)

在异步IO模型中,在整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
发起一个异步IO的read读操作的系统调用,流程如下:

  1. 当用户线程发起了read系统调用,立刻就可以开始去做其他事情,用户线程不阻塞。
  2. 内核就开始了IO的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存)。
  3. 内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。
  4. 用户线程读取用户缓冲区的数据,完成后续的业务操作。

异步IO模型的特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的IO操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。正因为如此,异步IO有的时候也被称为信号驱动IO。

异步IO异步模型的缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。

JAVA NIO 模型

Java NIO由以下三个核心组件组成:

  1. Channel (通道)
  2. Buffer (缓冲区)
  3. Selector (选择器)

从Java 1.4版本之后,Java的IO类库从阻塞IO升级为了非阻塞IO,即-JAVA NIO(New IO),底层使用的是IO多路复用模型。

NIO与OIO的区别,主要体现在三个方面:

  1. OIO是面向流的,NIO是面向缓冲区的。

OIO操作中,我们以流式的方式顺序地从一个流(stream)中读取字节,不能随意改变读取指针的位置。在NIO中,引入了Channel和Buffer的概念,读取和写入只需要从通道中读取数据到缓冲区,或将数据从缓冲区中写入到通道中。

  1. OIO的操作是阻塞的,而NIO的操作是非阻塞的。

OIO的阻塞体现在调用一个read方法读取一个文件内容,那么调用read的线程会被阻塞,直到read操作完成。

  1. OIO没有选择器,而NIO是有选择器的概念的。

NIO的实现,是基于底层的选择器的系统调用。NIO的选择器,需要底层操作系统提供支持, 而OIO不需要用到选择器。

NIO Buffer类

Buffer类是一个非线程安全的类,Buffer类是一个抽象类,对应于Java的主要数据类型,在NIO中有8种缓冲区类,分别如下:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。前7种Buffer类型,覆盖了能在IO中传输的所有的Java基本数据类型。第8种类型MappedByteBuffer是专门用于内存映射的一种ByteBuffer类型。
Buffer类的属性:

  • capacity (容量): capacity容量指的是写入的数据对象的数量;

  • positiohn (读写位置): 缓冲区中喜爱一个要被读或者写的元素的索引;

  • limit (上限): 缓冲区当前的数据量;

  • mark (标记):调用mark()方法设置mark=position,再调用reset()可以让position恢复到mark标记的位置即postion=mark;

NIO Buffer的操作方法

  • allocate() 创建缓冲区
  • put() 写入到缓冲区;要写入缓冲区,需要调用put方法。put方法很简单,只有一个参数,即为所需要写入的对象。不过,写入的数据类型要求与缓冲区的类型保持一致。
  • flip() 读写模式反转;调用flip方法后,之前写入模式的position的值会变成可读上限的值,新的读取模式下的position,会变成0,表示从头开始读取。清除之前的mark标记,因为mark保存的是写模式下的临时位置
  • get() 从缓冲区读取;读取操作会改变可读位置position的值,而limit值不会改变。如果position值和limit的值相等,表示所有数据读取完成,position指向了一个没有数据的元素位置,已经不能再读了。此时再读,会抛出BufferUnderflowException异常。
  • rewind() 数据倒带;已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind调整了缓冲区position属性,position重置为0,可以重读缓冲区中所有的数据,limit保持不变,数据量还是一样的,仍然表示能从缓冲区中读取多少个元素。
  • mark()reset() mark()将当前的position的值保存起来,放到mark属性中,reset()方法将mark的值恢复到position中。
  • clear() 清空缓冲区;在读取模式下,调用clear方法将缓冲区切换为写入模式,此方法会将position清零,limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。

NIO Channel类

最为重要的四种Channel(通道)实现:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。

FileChannel文件通道

  • 获取FileChannel文件通道
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 创建文件输入流
    FileInputStream fileInputStream = new FileInputStream("filePath");
    // 获取文件流的通道
    FileChannel inChannel = fileInputStream.getChannel();

    // 创建文件输出流
    FileOutputStream fileOutputStream = new FileOutputStream("filePath");
    // 获取文件流通道
    FileChannel outChannel = fileOutputStream.getChannel();
  • 读取FileChannel通道

在大部分应用场景从通道读取数据都会调用通道的int read(ByteBufferbuf)方法,它从通道读取到数据写入到ByteBuffer缓冲区,并且返回读取到的数据量。

1
2
3
4
// 获取一个字节缓冲区 注意,新建的ByteBuffer默认是写入模式。在读取数据时需要调用flip或者clear方法切换
ByteBuffer byteBuffer = ByteBuffer.allocate(20);
// 调用通道的read方法,读取数据并传入字节类型的缓冲区
int length = inChannel.read(byteBuffer);
  • 写入FileChannel通道

写入数据到通道,在大部分应用场景,都会调用通道的int write(ByteBufferbuf)方法。此方法的参数——ByteBuffer缓冲区,是数据的来源。write方法的作用,是从ByteBuffer缓冲区中读取数据,然后写入到通道自身,而返回值是写入成功的字节数。

1
2
byteBuffer.flip();
int length = outChannel.write(byteBuffer);
  • 关闭通道

当通道使用完成后,必须将其关闭。

1
channel.close();
  • 强制刷新到磁盘

由于性能原因,要保证写入的通道的缓存数据最终都写入磁盘,要调用FileChannel的force()方法。

1
channel.force(true);

SocketChannel套接字通道/ServerSocketChannel

在NIO中,涉及网络连接的通道有两个,一个是SocketChannel负责连接传输,一个ServerSocketChannel负责连接监听。
NIO的SocketChannel对应OIO的Socket类, 一般同时位于服务器端和客户端。对应于一个连接,两端都有一个负责传输的SocketChannel。
NIO的ServerSocket对应OIO的ServerSocket类,一般位于服务器端。
无论是SocketChannel还是ServerSocketChannel都支持阻塞和非阻塞两种模式,调用configureBlocking方法。socketChannel.configureBlocking(false)设置为非阻塞模式,socketChannel.configureBlocking(true)设置为阻塞模式。

  • 获取SocketChannel传输通道
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /* 客户端 */
    // 获得一个套接字传输通道
    SocketChannel socketChannel = SocketChannel.open();
    // 设置为非阻塞模式
    socketChannel.configureBlocking(false);
    // 对服务器的IP和端口发起连接
    socketChannel.connect(new InetSocketAddress("127.0.0.1", 80));
    ```java
    非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect方法就返回了,因此需要不断地自旋,检查当前是否是连接到了主机:
    ```java
    while(!socketChannel.finishConnect()) {
    //...
    }
    1
    2
    3
    4
    5
    6
    7
    /* 服务器端 */
    // 通过事件,获取服务器监听通道
    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
    // 获取新连接的套接字通道
    SocketChannel socketChannel = serverSocketChannel.accept();
    // 设置为非阻塞模式
    socketChannel.configureBlocking(false);
  • 读取SocketChannel传输通道
    1
    2
    3
    4
    // 获取一个字节缓冲区 - 写入模式
    ByteBuffer byteBuffer = ByteBuffer.allocate(20);
    // 如果返回-1,表示读取到了对方的输出结束标志
    int read_length = socketChannel.read(byteBuffer);
  • 写入到SocketChannel传输通道
    1
    2
    3
    // 默认的写入模式切换为读取模式
    byteBuffer.flip();
    socketChannel.write(buffer);

关闭SocketChannel传输通道

1
2
3
4
// 终止输出方法,向对方发送一个输出的结束标志
socketChannel.shutdownOutput();
// 关闭套接字连接
IOUtil.closeQuietly(socketChannel);

DatagramChannel数据报通道

DatagramChannel是采用UDP进行传输的面向非连接的协议,只要直到服务器的IP和端口,就可以直接向对方发送数据。

  • 获取DatagramChannel数据报通道
1
2
3
4
5
// 获取通道
DatagramChannel datagramChannel = DatagramChannel.open();
// 设置为非阻塞模式
datagramChannel.configureBlocking(false);
datagramChannel.bind(new InetSocketAddress(18080));
  • 读取DatagramChannel数据报通道数据

    1
    2
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    SocketAddress address = datagramChannel.receive(byteBuffer);
  • 写入DatagramChannel数据通道

    1
    2
    3
    4
    5
    6
    // 把缓冲区切换到读取模式
    byteBuffer.flip()
    // 调用send方法,把数据发送到目标IP和端口
    datagramChannel.send(byteBuffer, new InetSocketAddress(NIODemoConfig.SOCKET_SERVER_IP, NIODemoConfig.SOCKET+SERVER_PORT));
    // 清空缓冲区,切换到写入模式
    byteBuffer,clear();
  • 关闭DatagramChannel数据报通道

1
datagramChannel.close();

NIO Selector 选择器

选择器的作用是完成IO的多路复用,一个通道代表一个连接通路,通过选择器可以同时监控多个通道的IO状况,选择器和通道的关系,是监控和被监控的关系。
通道和选择器之间的关系,通过register(注册)的方式完成。调用通道的Channel.register(Selector selector, int ops)方法,可以将通道实例注册到一个选择器中。

  • Selector selector: 指定通道注册到的选择器实例;
  • int operation 指定选择器要监控的IO事件类型。可供选择器监控的通道IO事件类型,包括以下四种:
  1. 可读就绪:SelectionKey.OP_READ
  2. 可写就绪:SelectionKey.OP_WRITE
  3. 连接就绪:SelectionKey.OP_CONNECT
  4. 接收就绪:SelectionKey.OP_ACCEPT

除了FileChannel文件通道外,其他选择器都是可选择的。这是因为其他三个通道都继承了一个SelectableChannel这个抽象类。

  • SelectionKey选择键: 指的是被选择器选中的IO事件;一个IO事件发生(就绪状态达成)后,如果之前在选择器中注册过,就会被选择器选中,并放入SelectionKey选择键集合;如果之前没有注册过,即使发生了IO事件,也不会被选择器选中。

选择器使用流程:

  • 获取选择器实例
    Selector选择器的类方法open()的内部,是向选择器SPI(SelectorProvider)发出请求,通过默认的SelectorProvider(选择器提供者)对象,获取一个新的选择器实例。
    1
    2
    // 调用静态工厂方法open()来获取
    Selector selector = Selector.open();
  • 将通道注册到选择器中
    其次,还需要注意:一个通道,并不一定要支持所有的四种IO事件。例如服务器监听通道ServerSocketChannel,仅仅支持Accept(接收到新连接)IO事件;而SocketChannel传输通道,则不支持Accept(接收到新连接)IO事件。如何判断通道支持哪些事件呢?可以在注册之前,可以通过通道的validOps()方法,来获取该通道所有支持的IO事件集合。
    1
    2
    3
    4
    5
    6
    7
    8
    // 获取通道
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 设置为非阻塞
    serverSocketChannel.configureBlocking(false);
    // 绑定连接
    serverSocketChannel.bind(new InetSocketAddress(SystemConfig.SOCKET_SERVER_PORT));
    // 将通道注册到选择器上,并制定监听事件为接收就绪事件
    serverSocketChannel.register(Selector, SelectionKey.OP_ACCEPT);
  • 轮询感兴趣的IO就绪事件
    通过Selector选择器的select()方法,选出已经注册的、已经就绪的IO事件,保存到SelectionKey选择键集合中, 该方法返回int类型的IO事件通道数量;SelectionKey集合保存在选择器实例内部,是一个元素为SelectionKey类型的集合(Set)。调用选择器的selectedKeys()方法,可以取得选择键集合。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 轮询,选择IO就绪事件 有多个重载的实现方法
    while (selector.select() > 0) {
    Set selectKeys = selector.selectedKeys();
    Iterator keyIterator = selectedKeys.iterator();
    while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    // 根据具体IO事件类型,执行对应的业务逻辑
    if(key.isAcceptable()) {
    // IO事件:ServerSocketChannel服务器监听通道有新连接
    } else if (key.isConnectable()) {
    // IO事件:传输通道连接成功
    } else if (key.isReadable()) {
    // IO事件:传输通道可读
    } else if (key.isWritable()) {
    // IO事件:传输通道可写
    }
    //处理完成后,移除选择键
    keyIterator.remove();
    }
    }