NIO 的起源

同步阻塞IO(也就是 BIO)在网络通信当中有很多缺点:

  • 线程的创建和销毁开销大
  • 线程本身占用用内存较大
  • 线程切换成本很高

高并发的需求却越来越普通,随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理组件——这就是Java的NIO编程组件。

NIO 简介

在1.4版本之前,JavaIO类库是阻塞式IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。
Java NIO 类库的目标,就是要让 Java 支持非阻塞 IO,基于这个原因,更多的人喜欢称 Java NIO 为非阻塞 IO(Non-Block IO),称“老的”阻塞式 Java IO 为 OIO(Old IO)。总体上说,NIO 弥补了原来面向流的 OIO 同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲区的 IO。

NIO 的好处
主要应用在网络通信中,能够合理利用资源,提高系统的并发效率。支持高并发的系统访问。

NIO 的三个核心组件

Channel (通道)

在 NIO 中,一个网络连接使用一个通道表示,所以的 NIO 的 IO 操作都是通过连接通道完成的。
Channel和Stream的一个显著的不同是:Stream是单向的,譬如InputStream是单向的只读流,OutputStream是单向的只写流;而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。

NIO 的 Channel 主要实现有:

  • FileChannel:用于文件的 IO 操作
  • DatagramChannel:用于 UDP 的 IO 操作
  • SocketChannel:用于 TCP 连接的传输操作
  • ServerSocketChannel:用于 TCP 连接连接监听操作

Buffer (缓冲区)

实际上是一个容器,底层是一个数组。

Channel 提供从文件、网络读取数据的渠道,但是读写的数据都必须经过Buffer。

Channel 读取或者写入数据,都要写到 Buffer 中,才可以被程序操作。

因为 Channel 是双向的,没有方向性,所以 Buffer 为了区分读写,引入了写模式和读模式。


java库在哪里 java nio库_java库在哪里

Buffer 是一个抽象类,位于 java.nio 包中,它有如下子类:

  1. ByteBuffer
  2. CharBuffer
  3. DoubleBuffer
  4. FloatBuffer
  5. IntBuffer
  6. LongBuffer
  7. ShortBuffer
  8. MappedByteBuffer

其中最常用的是 ByteBuffer。

Selecter (选择器)

Selector选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。

selector 单从字面意思不好理解,需要结合服务器的设计演化来理解它的用途:

  1. 多线程版设计:

    一个连接请求对应一个线程,会有如下缺点:
  • 内存占用高
  • 线程上下文切换成本高
  • 线程不能无限创建,所有只适合连接数较少的场景
  1. 线程池版设计:

    缺点:
  • 阻塞模式下,线程仅能处理一个 socket 连接
  • 仅适合短连接场景
  1. selector 版设计:

    调用 selector 的 select() 会阻塞直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理。当 channel 没有读写事件时,线程可以去处理其他 channel。

详解 NIO Buffer 类

Buffer 类的重要属性

为了记录读写的状态和位置,Buffer 类额外提供了一些重要的属性:

  • capacity(容量)
  • limmit(读写的限制)
  • position(读写位置)

capacity 属性

capacity 属性表示内容容量的大小,一旦写入的对象数量超过了容量,缓存区就满了,就不能在写入。
Buffer 的 capacity 属性一旦初始化,就不能在更改。原因是什么呢?Buffer类的对象在初始化时,会按照capacity分配内部数组的内存,在数组内存分配好之后,它的大小当然就不能改变了。
capacity容量并不是指内部的内存块byte[]数组的字节数量,而是指能写入的数据对象的最大限制数量。

limmit 属性

Buffer类的limit属性,表示可以写入或者读取的最大上限,其属性值的具体含义,也与缓冲区的读写模式有关。
调用 Buffer 的 limmit() 方法可以获取 limmit 值。
在不同的模式下,limit的值的含义是不同的,具体分为以下两种情况:
(1)在写入模式下,limit属性值的含义为可以写入的数据最大上限。在刚进入到写入模式时,limit的值会被设置成缓冲区的capacity容量值,表示可以一直将缓冲区的容量写满。
(2)在读取模式下,limit的值含义为最多能从缓冲区中读取到多少数据。

position 属性

Buffer类的position属性,表示当前的位置。position属性的值与缓冲区的读写模式有关。在不同的模式下,position属性值的含义是不同的,在缓冲区进行读写的模式改变时,position值会进行相应的调整。
调用 Buffer 的 position() 方法可以获取 position 值。

在写入模式下,position的值变化规则如下:
(1)在刚进入到写入模式时,position值为0,表示当前的写入位置为从头开始。
(2)每当一个数据写到缓冲区之后,position会向后移动到下一个可写的位置。
(3)初始的position值为0,最大可写值为limit–1。当position值达到limit时,缓冲区就已经无空间可写了。

在读模式下,position的值变化规则如下:
(1)当缓冲区刚开始进入到读取模式时,position会被重置为0。
(2)当从缓冲区读取时,也是从position位置开始读。读取数据后,position向前移动到下一个可读的位置。
(3)在读模式下,limit表示可以读上限。position的最大值,为最大可读上限limit,当position达到limit时,表明缓冲区已经无数据可读。

Buffer的读写模式具体如何切换呢?当新建了一个缓冲区实例时,缓冲区处于写入模式,这时是可以写数据的。在数据写入完成后,如果要从缓冲区读取数据,这就要进行模式的切换,可以使用(即调用)flip()翻转方法,将缓冲区变成读取模式。

在从写入模式到读取模式的flip翻转过程中,position和limit属性值会进行调整,具体的规则是:
(1)limit属性被设置成写入模式时的position值,表示可以读取的最大数据位置;
(2)position由原来的写入位置,变成新的可读位置,也就是0,表示可以从头开始读。

capacity、limmit、position值变化图解:

Buffer 中切换读写模式的方法:

  • flip():切换为读模式。
  • clear():切换为写模式。
  • compact():切换为写模式,同时将未读写完的数据向前压缩。

Buffer 分配空间后,处于写模式下:

java库在哪里 java nio库_网络_02



向 Buffer 中写入数据后:


java库在哪里 java nio库_java库在哪里_03



上图中向 Buffer 中写入了四个字节,调用 filp() 方法切换为读模式,position 更改为读取位置,limit 更改为读取限制:

java库在哪里 java nio库_java库在哪里_04



position 指向的是将要读取的位置,读取四个字节后变为:


java库在哪里 java nio库_nio_05

调用 clear() 方法切换为写模式,调用后 position 会指向初始位置,但是不会清空数据(图中没表示出来),写入时数据会覆盖之前的:


java库在哪里 java nio库_nio_06



compact() 方法,是把未读完的部分向前压缩,然后切换至写模式:


java库在哪里 java nio库_网络_07

Buffer 相关的重要方法

分配空间

  • allocate 方法

使用 allocate 方法为 ByteBuffer 分配空间,其它 buffer 类也有该方法:

Bytebuffer byteBuffer = ByteBuffer.allocate(20);

一个缓冲区在创建后,处于写模式,position 属性的值为 0;容量 capacity 的值为 20,limmit 值也为 20。

向 Buffer 中写入数据

  1. Buffer 自己的 put() 方法
buffer.put((byte)127);
  1. channel 的 read 方法
int readBytes = channel.read(buf);

从 Buffer 中读取数据

  1. buffer 自己的 get 方法
byte b = buffer.get();

调用 get() 方法,每次从 position 的位置上读取一个数据,读取后 position 的值会更改为下一个数据的位置(加1)。
也可以传入 position 获取指定位置的值,比如 get(1),但是这样 position 的值不会发生改变。

  1. channel 的 write 方法
int writeBytes = channel.write(buf);

flip 方法

向缓冲区写入数据过后,还不能直接读取数据,因为现在缓冲区还处于写模式,需要调用 flip() 方法切换到读模式。

compact 方法

compact 方法,是把未读完的部分向前压缩,然后切换至写模式。

rewind 方法

已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。

mark 方法和 reset 方法

mark 是在读取时,做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置。
注意:rewind 和 flip 都会清除 mark 位置

clear 方法

在读模式下,调用 clear() 方法清空缓冲区,并切换为写模式。
调研该方法后,会讲 position 属性的值更改为 0;

详解 NIO Channel 类

FileChannel 文件通道

FileChannel是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入到文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式

  • 获取 FileChannel 通道
  • 通过文件的输入输出流获取 FileChannel 通道
//  输入流获取的 channel 只能读
FileInputStream fis = new FileInputStream(srcFile);
FileChannel inChannel = fis.getChannel();
//  输出流获取的 channel 只能写
FileOutputStream fos = new FileOutputStream(destFile);
FileChannel outchannel = fos.getChannel();
  • 通过RandomAccessFile文件随机访问类,获取FileChannel文件通道实例:
RandomAccessFile rFile = new RandomAccessFile(“filename.txt”,“rw”);
FileChannel channel = rFile.getChannel();
  • 读取 FileChannel 通道

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

RandomAccessFile aFile = new RandomAccessFile(fileName, “rw”);

//获取通道(可读可写)
FileChannel channel= aFile.getChannel();

//获取一个字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(CAPACITY);

int length;
//调用通道的read方法,读取数据并买入字节类型的缓冲区
while ((length = channel.read(buf)) != -1) {

//……省略buf中的数据处理

}
  • 写入 FileChannel 通道

写入数据到通道,在大部分应用场景,都会调用通道的write(ByteBuffer)方法,此方法的参数是一个ByteBuffer缓冲区实例,是待写数据的来源。

  • 关闭 FileChannel 通道

Channel 使用完后必须关闭,不过调用输入输出流的 close() 方法会间接的关闭 Channel。

黏包、半包

网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为

  • Hello,world\n
  • I’m zhangsan\n
  • How are you?\n

变成了下面的两个 byteBuffer

  • Hello,world\nI’m zhangsan\nHo
  • w are you?\n

第一条数据结尾包含了下一条数据的部分,叫做黏包,第二条数据前半部分不完成,叫做半包。
要将数据还原为原始得三条数据,需要在读取数据时做处理黏包和半包:

public class Test {
    public static void main(String[] args) {
        ByteBuffer buffer = ByteBuffer.allocate(32);
        buffer.put("Hello,world\nI'm zhangsan\nHo".getBytes());
        split(buffer);

        buffer.put("w are you?\nhaha!\n".getBytes());
        split(buffer);
    }
    // 处理 黏包、半包
    private static void split(ByteBuffer buffer) {
        // 切换为读模式
        buffer.flip();
        int oldLimit = buffer.limit();
        for(int i = 0; i < oldLimit; i ++) {
            if(buffer.get(i) == '\n') {
                ByteBuffer target = ByteBuffer.allocate(i + 1 - buffer.position());
                // 将 limit 属性设置为 \n 的位置
                buffer.limit(i + 1);

                // 将 buffer 中的数据读到 target 中
                // limit 从新设置过了,所有会读到 \n 的位置
                target.put(buffer);
                target.flip();
                while (target.hasRemaining()) {
                    System.out.print((char)target.get());
                }
                // 将 limit 设置为 oldLimit
                buffer.limit(oldLimit);
            }
        }
        // 最后一个 \n 后的数据没有读取,所以调用 compact 方法将数据压缩到最前面
        buffer.compact();
    }
}

文件操作

  1. 读取文件内容
@Test
public void testReadFile() throws Exception {
    FileInputStream fis = new FileInputStream("niotest1.txt");
    FileChannel channel = fis.getChannel();

    ByteBuffer buffer = ByteBuffer.allocate(20);

    while(channel.read(buffer) != -1) {
        // 设置 buffer 为读模式
        buffer.flip();
        // 读取 buffer 中的数据
        while (buffer.hasRemaining()) {
            byte b = buffer.get();
            System.out.print( (char) b);
        }
        // 读完之后设置 buffer 为写模式
        buffer.clear();
    }

    fis.close();
}
  1. 写入文件内容
@Test
public void testWriteFile() throws Exception{
    FileChannel channel = new FileOutputStream("data.txt").getChannel();
    ByteBuffer buffer = Charset.forName("UTF-8").encode("1234567890");

    int write = channel.write(buffer);
    System.out.println("write = " + write);

    channel.close();
}
  1. 文件复制
@Test
public void testCopyFile() throws Exception {
    FileChannel from = new FileInputStream("data.txt").getChannel();
    FileChannel to = new FileOutputStream("data2.txt").getChannel();

	// transferTo 方法最大限制2g, 如果文件大小超过 2g,文件会复制不全
    // left 为剩余要复制内容的数量
    long left = from.size();
    while (left > 0) {
        // transferTo 返回值为剩余为复制的内容数量
        left = left - from.transferTo(from.size()-left, left, to);
    }

    to.close();
    from.close();
}