一文搞懂NIO

前言

前面说过Java中的IO操作,但是传统的IO是阻塞模式的,在高并发的系统中肯定是不可行的,这次我们来介绍Java中提供的另外一种IO操作–NIO

首先,我们要知道应用程序中的IO和操作系统的IO是有区别的,应用程序的IO最终都需要依靠操作系统的IO来完成最终的操作。这时候就需要选择合适的IO模型,常见的IO模型有四种:

  1. 同步阻塞IO(Blocking IO)

    阻塞IO指的是需要内核IO操作彻底完成后,才能继续执行下面的操作。Java中传统的IO和socket默认都是同步阻塞IO

  2. 同步非阻塞IO(Non-blocking IO)

    非阻塞IO指的是不需要等待内核IO执行完,可以立即返回用户空间,继续下面的操作,此时内核会给用户一个状态值。很多人以为Java中的NIO就是Non-blocking IO的缩写,也就是同步非阻塞IO,其实不是的,Java中NIO是new IO,指的是下面IO多路复用模型。

  3. IO多路复用

    IO多路复用指的是一个线程可以监视多个文件描述符,一旦某个文件描述符就绪(可读/可写),内核就会将就绪状态返回给应用程序,应用程序根据就绪状态,进行相应的IO操作。

  4. 异步IO

    异步IO类似于回调模式,用户空间向内核空间注册了各种IO事件的回调函数,由内核去主动调用。

四种IO模式总结:

扫描二维码关注公众号,回复: 11321004 查看本文章

同步阻塞IO模型是Java中传统IO默认使用的,一般系统并发不高,使用此模型还是可以的;同步非阻塞IO模型听起来由原来的同步转成了异步,但是此模型需要用户线程一直去询问内核是否就绪,这就会占用大量的CPU时间,在高并发的情况下此模型显然是不可用的;IO多路复用模型,是现在主流的高并发下的IO模型;异步IO模型,理论上是性能最高的一种IO模型,但是很多操作系统底层还不完善,因此在性能上没有明显的优势。

Java NIO

现在很多主流的框架和中间件都采用了Java的NIO,比如tomcat、Netty等。上面说到,Java中的NIO采用的是IO多路复用模型,此模型就是经典的Reactor反应器模式

Reactor反应器模式

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

  • Channel(通道)
  • Buffer(缓冲区)
  • Selector(选择器)

Channel(通道)

在传统的IO中,所有的IO操作都需要通过输入流和输出流来实现;但是在NIO中,所有的IO操作都是从通道开始的,一个通道既可以输入也可以输出。

Buffer(缓冲区)

应用程序和通道交互就需要通过缓冲区,通道的读取就是将数据从通道写入到缓冲区,通道的写入就是将数据从缓冲区写入到通道中。

Selector(选择器)

Java中的NIO是一种IO多路复用模型,那它是通过什么实现的呢?这就需要依靠它的第三个组件–Selector选择器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。

组件详解

Buffer 缓冲区

Buffer类是一个抽象类,在NIO中有8种缓冲区类,分别如下:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedBuffer。其中使用最广的是ByteBuffer。

Buffer的重要属性

  1. capacity(容量)

    初始化Buffer时,需要指定缓冲区的容量,当写入的数据超过这个容量时就不能再写入了。缓冲区的容量一旦初始化,就不能再改变了,这是因为Buffer本质上就是一个内存块,相当于一个数组,内存分配好以后,它的大小就不能改变了。capacity容量不是byte[]数组的字节数量,而是写入的数据对象的数量。

  2. position(读写位置)

    在写模式下:刚进入写模式时,position为0,表示从头开始写,每写入一个数据,position位置都往后移一位,当position达到limit-1的时候,就不能再写入了。

    在读模式下:刚进入读模式时,position会重置为0,每读取一个数据时,position位置都往后移一位,当position达到limit-1的时候,就没有数据可读了。

  3. limit(读写的限制)

    在写模式下,limit表示最大上限,等于capacity值。当切换到读模式时,limit会变成切换前写模式时的position位置,而position则重置为0。

Buffer的重要方法

  1. allocate() 创建缓存区并初始化
  2. put() 写入数据
  3. flip() 翻转(进入读模式)
  4. get() 获取数据
  5. rewind() 倒带(重复读)
  6. mark()/reset() 标记/重置(从位置重新读取)
  7. clear() 清除(进入写模式)
public class NioDemo {

    public void test() {
        IntBuffer intBuffer = IntBuffer.allocate(10);
        print("初始化", intBuffer);
        for (int i = 0; i < 6; i++) {
            intBuffer.put(i);
        }
        print("写入6个数据", intBuffer);
        intBuffer.flip();
        print("翻转后进入读模式", intBuffer);
        for (int i = 0; i < 2; i++) {
            intBuffer.get();
        }
        print("读取2个数据", intBuffer);
        intBuffer.rewind();
        print("倒带", intBuffer);
        for (int i = 0; i < 6; i++) {
            if (i == 3) {
                intBuffer.mark();
            }
            intBuffer.get();
        }
        print("读取3个数据,并保存第四个位置", intBuffer);
        intBuffer.reset();
        for (int i = 3; i < 6; i++) {
            intBuffer.get();
        }
        print("reset后读取数据", intBuffer);
        intBuffer.clear();
        print("clear后进入写模式", intBuffer);
    }

    private void print(String name,IntBuffer intBuffer) {
        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>" + name);
        System.out.println(intBuffer.toString());
    }

    public static void main(String[] args) {
        NioDemo demo = new NioDemo();
        demo.test();
    }
}

//输出
>>>>>>>>>>>>>>>>>>>>>>>>>初始化
java.nio.HeapIntBuffer[pos=0 lim=10 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>写入6个数据
java.nio.HeapIntBuffer[pos=6 lim=10 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>翻转
java.nio.HeapIntBuffer[pos=0 lim=6 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>读取2个数据
java.nio.HeapIntBuffer[pos=2 lim=6 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>倒带
java.nio.HeapIntBuffer[pos=0 lim=6 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>读取3个数据,并保存第四个位置
java.nio.HeapIntBuffer[pos=6 lim=6 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>reset后读取数据
java.nio.HeapIntBuffer[pos=6 lim=6 cap=10]
>>>>>>>>>>>>>>>>>>>>>>>>>clear后进入写模式
java.nio.HeapIntBuffer[pos=0 lim=10 cap=10]

Channel 通道

Java NIO中有四种常见的Channel:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。

  1. FileChannel:文件通道,用于文件的读写。
  2. SocketChannel:套接字通道,用于TCP连接的数据读写。
  3. ServerSocketChannel:服务端套接字通道,用于监听TCP连接。
  4. DatagramChannel:数据报通道,用于UDP连接的数据读写。

FileChannel文件通道

FileChannel为阻塞模式,不能设置为非阻塞模式。

下面通过一个简单的复制文件来介绍FileChannel的用法

public class ChannelDemo {

    /**
     * 获取输入通道
     * @param filePath
     * @return
     * @throws Exception
     */
    public FileChannel getInputChannel(String filePath) throws Exception {
        FileInputStream inputStream = new FileInputStream(filePath);
        return inputStream.getChannel();
    }

    /**
     * 获取输出通道
     * @param targetPath
     * @return
     * @throws Exception
     */
    public FileChannel getOutputChannel(String targetPath) throws Exception {
        FileOutputStream outputStream = new FileOutputStream(targetPath);
        return outputStream.getChannel();
    }

    /**
     * 数据从输入通道读取到缓冲区,再从缓冲区读取到输出通道
     * @param inputChannel
     * @param outputChannle
     * @throws Exception
     */
    public void copyFile(FileChannel inputChannel,FileChannel outputChannle) throws Exception {
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        // 初始是写入模式
        while (inputChannel.read(byteBuffer) != -1) {
            System.out.println("byteBuffer写入了"+byteBuffer.position()+"个数据");
            //变成读取模式
            byteBuffer.flip();
            while (outputChannle.write(byteBuffer) != 0) {
                System.out.println("byteBuffer读取到outputChannle完成");
            }
            // 读取完转为写入模式
            byteBuffer.clear();
        }
        inputChannel.close();
        outputChannle.close();
    }

    public static void main(String[] args) throws Exception{
        ChannelDemo demo = new ChannelDemo();
        FileChannel inputChannel = demo.getInputChannel("c:/soft/test/hello.txt");
        FileChannel outputChannel = demo.getOutputChannel("c:/soft/test/hello-copy.txt");
        demo.copyFile(inputChannel, outputChannel);
    }
}

SocketChannel套接字通道

socketChannel负责socket传输,作用于客户端和服务端,支持阻塞和非阻塞模式,一般都是使用非阻塞模式,使用socketChannel.configureBlocking(false)设置为非阻塞模式。

下面通过一个简单的客户端发送socket连接示例来介绍SocketChannel

public class SocketClientDemo {

    public void send(byte[] bytes) throws Exception {
        // 通过SocketChannel的静态方法open()获取socketChannel实例
        SocketChannel socketChannel = SocketChannel.open();
        // 连接服务端的ip和端口
        socketChannel.socket().connect(new InetSocketAddress("127.0.0.1",1234));
        // 设置为非阻塞
        socketChannel.configureBlocking(false);
        // 因为非阻塞的,连接会立即返回,所以此处需要轮询,查看是否完成连接
        while (!socketChannel.finishConnect()) {
            System.out.println("还没连接上,重试。。。");
        }
        System.out.println("连接成功!");
        // 使用缓存区发送数据
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        // 向缓冲区中写入数据
        byteBuffer.put(bytes);
        // 翻转,缓存区变成读取模式
        byteBuffer.flip();
        // 缓冲区中数据写入到socketChannel通道中
        socketChannel.write(byteBuffer);
        System.out.println("写入完成");
        // 关闭连接,正常情况下在finally中关闭,这里只是简单演示
        socketChannel.close();
    }

    public static void main(String[] args) throws Exception {
        SocketClientDemo demo = new SocketClientDemo();
        demo.send("hello".getBytes());
    }
}

ServerSocketChannel是服务端连接socket数据的通道,具体用例结合后面介绍的选择器一起介绍。

DatagramChannel 数据报通道

DatagramChannel通道用来处理UDP协议的,UDP协议和TCP协议不一样,它直接通过IP和端口就可以直接向对方发送数据,不需要建立连接。

下面通过简单的客户端用例来介绍DatagramChannel用法

public class DatagramClient {

    public void send(String data) throws Exception{
        DatagramChannel datagramChannel = DatagramChannel.open();
        datagramChannel.configureBlocking(false);
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        byteBuffer.put(data.getBytes());
        byteBuffer.flip();
        datagramChannel.send(byteBuffer, new InetSocketAddress("127.0.0.1", 4444));
        datagramChannel.close();
    }

    public static void main(String[] args) throws Exception{
        new DatagramClient().send("hello udp");
    }
}

DatagramChannel服务端的用法也是通过下面的选择器一起介绍。

Selector 选择器

Selector是第三个重要的组件,NIO主要是通过这个组件来实现多路复用。一个通道代表一个连接,通过选择器来同时监控多个通道的变化。通道首先通过register完成在选择器上的注册,注册时需要两个参数,一个是选择器实例,一个是要监控的IO事件类型,这个事件类型有下面四种:

  • SelectionKey.OP_READ 可读
  • SelectionKey.OP_WRITE 可写
  • SelectionKey.OP_CONNECT 连接
  • SelectionKey.OP_ACCEPT 接受

下面通过简单的DatagramChannel服务端的代码来介绍Selector的使用

public class DatagramServer {

    public void accept() throws Exception {
        DatagramChannel datagramChannel = DatagramChannel.open();
        datagramChannel.configureBlocking(false);
        datagramChannel.bind(new InetSocketAddress(4444));
        // 通过Selector的open()方法获取选择器实例
        Selector selector = Selector.open();
        // 通道注册到选择器上
        datagramChannel.register(selector, SelectionKey.OP_READ);
        System.out.println("开启监听");
        // 轮询
        while (selector.select() > 0) {
            // 获取选择器上所有变化的选择键
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            // 遍历
            while (iterator.hasNext()) {
                // 获取具体的选择键实例
                SelectionKey selectionKey = iterator.next();
                // 可读事件
                if (selectionKey.isReadable()) {
                    // 下面完成具体可读以后的处理
                    System.out.println("可读");
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    // 和ServerSocketChannel接受数据不一样,这里使用receive
                    datagramChannel.receive(byteBuffer);
                    System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit()));
                }
            }
            // 移除此选择键,防止重复读取
            iterator.remove();
        }
        selector.close();
        datagramChannel.close();
    }

    public static void main(String[] args) throws Exception{
        new DatagramServer().accept();
    }
}

总结一下Selector选择器的使用步骤:首先获取选择器实例,然后将通道注册到选择器实例上,接着选出监听的IO事件,最后进行具体的处理逻辑。

下面介绍一下平时常用的Socket服务端程序,看看通过Selector选择器怎么实现多路复用,也就是一个线程完成多个IO事件的处理程序。

public class SocketServerDemo {

    public void accept() throws Exception {
        // 获取选择器实例
        Selector selector = Selector.open();
        // 获取ServerSocketChannel实例
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 监听5555端口
        serverSocketChannel.bind(new InetSocketAddress(5555));
        // ServerSocketChannel通道注册到选择器上,并监听可连接的事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        // 轮询
        while (selector.select() > 0) {
            // 所有选择键
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()) {
                // 获取具体选择键实例
                SelectionKey selectionKey = iterator.next();
                // 如果有连接事件,说明有客户端通过此端口连接到服务端
                if (selectionKey.isAcceptable()) {
                    // 进行可连接的处理逻辑
                    handlerAccept(selectionKey);
                } else if (selectionKey.isReadable()) {
                    handlerRead(selectionKey);
                }
            }
            // 移除,防止重复消费
            iterator.remove();
        }
        serverSocketChannel.close();
    }

    private void handlerAccept(SelectionKey selectionKey) throws IOException {
        System.out.println("处理连接");
        // 获取SocketChannel实例,用来出来数据传输
        SocketChannel socketChannel = ((ServerSocketChannel) selectionKey.channel()).accept();
        // 设置为非阻塞模式
        socketChannel.configureBlocking(false);
        // 将SocketChannel通道注册到选择器上,并监听可读取事件
        socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ);
    }

    private void handlerRead(SelectionKey selectionKey) throws Exception {
        System.out.println("处理数据");
        // 获取选择键上的SocketChannel
        SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
        // 设置为非阻塞模式
        socketChannel.configureBlocking(false);
        // 数据读取到缓冲区中
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        if (socketChannel.read(byteBuffer) == -1) {
            System.out.println("无数据,关闭");
            socketChannel.close();
            return;
        }
        String s = new String(byteBuffer.array()).trim();
        // 打印客户端传过来的数据
        System.out.println(s);
        System.out.println("读取完成");
    }

    public static void main(String[] args) throws Exception {
        new SocketServerDemo().accept();
    }
}

总结

Java的NIO主要通过Buffer、Channel和Selector三个组件来实现多路复用的IO操作,首先将通道注册到选择器上,然后查询选择器上对应的选择键,获取监听的IO事件,接着将通道中数据读取或写入缓冲区中,完成对应的事件处理。

上面主要是介绍了Java中NIO的简单操作,里面还有很多待优化的地方,比如监听逻辑和读写逻辑都注册在同一个选择器上,如果读写耗时,还是会阻塞监听逻辑的。后面通过介绍Reactor模式来解决这些问题。


扫一扫,关注我

猜你喜欢

转载自blog.csdn.net/weixin_43072970/article/details/106848721