NIO网络编程

905 阅读28分钟

NIO

始于java1.4提供了新的Java Io操作非阻塞API,其用意是替代Java IO和java Networking相关的API

有很多人了解过NIO还有另一种说法叫做new io,因为它是新出来的IO API操作所以它有个名字叫新IO

在网络编程中我们更偏向于叫他非阻塞IO

NIO中有三个核心组件

组件 含义
Buffer 缓冲区
Channel 通道
Selector 选择器

这三个核心组件联合使用为我们提供了高性能网络编程的基础组件,也就是NIO网络编程都是基于这三大组件进行开发的

Buffer 缓冲区

缓冲区本质是一个可以写入数据的内存块(类似数组)可以写入也可以读取.此内存块包含在NIO Buffer对象中,该对象提供了一组方法,可以更轻松地使用内存块

相比较直接对数组的操作Buffer API更加容易操作和管理

使用Buffer进行数据写入与读取,需要进行如下四个步骤:

  • 将数据写入缓冲区
  • 调用buffer.flip()转为读取模式
  • 缓冲区读取数据
  • buffer.clear()或buffer.compact()清除缓冲区

对于Buffer的使用本文进行详情的代码演示和讲解

工作原理

在讲解具体的代码演示之前我们还是来看一下一些理论知识,主要是关于Buffer的工作原理.都说Buffer操作方便但是从这四个步骤来看还没看出方便在哪里,

目前所知道的是Buffer可以写入也可以读取但是我们的数组也可以写入和读取,它到底带来了什么改变这些改变又是什么原理呢?

Buffer三个重要属性

属性 说明
capacity容量 作为一个内存块,Buffer具有一定的固定大小,也成为容量
position位置 写入模式时代表写数据的位置,读取模式时代表读取数据的位置
limit限制 写入模式,限制等于buffer的容量.读取模式下,limit等于写入的数据量

对于这三个重要属性的说明也比较抽象,下面准备了一张图针对这三个属性进行一个知识梳理

capacity容量

不管是数据还是内存块都是用来存储一些数据,存储数据的这个内存块终归都需要有一个固定长度的大小,这样才能合理明确的分配内存

可以理解成数组的固定长度

position位置

可以理解为是一个游标,记录着当前操作的具体位置

值得注意的点是当从写入模式转换为读取模式时position位置会归零

写的时候是从头到尾一个个往下写的,那么读的时候也需要从头到尾一个个往下读

limit限制

理解为字面意思主要是对内存块操作的限制

写入模式下限制了最大只能够写到capacity容量的位置,就是容量多少才能写入多少的意思

读取模式下限制了最大只能读到position写入的具体位置,就是写了多少才能读到多少的意思

代码演示

package com.mdn.like.controller;

import java.nio.ByteBuffer;

/**
 * @author pangbohuan
 * @date 2020/7/2 0002 9:58
 * @description Buffer使用
 */
public class BufferDemo {

    public static void main(String[] args) {
        System.out.println("=================初始化-默认写入模式下查看三个重要的指标=========================");
        // 申请一个三字节大小的堆内存缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocate(3);
        // 堆外内存 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3);
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());

        // 注意:写入模式下最大只能够写到limit限制的位置,而写入模式下的limit等于capacity容量大小
        System.out.println("=================写入两个字节-查看三个重要的指标=========================");
        byteBuffer.put((byte) 1);
        byteBuffer.put((byte) 2);
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());

        // 注意:转换为读取模式(不调用flip方法,也是可以读取数据的,但是position记录读取的位置不对)
        // 因为当前的position记录位置为2,如果不转换成读取模式,而是直接get读取的话.往后读,读取到3下标的数据,这就导致读到数据不对了呀
        // 而由于limit在读取模时等于capacity容量大小所以是3,立马读的话不会出现下标越界导致报错的情况
        // 因为position=2,limit=3 position还没达到limit限制的大小,但是读取到的数据不对会读到0 字节数组没有值的情况下默认为0
        System.out.println("=================转换为读取模式-查看三个重要的指标=========================");
        byteBuffer.flip();
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());


        // 注意:从头往下读,因为调用flip转为读取模式时,position归零,limit=position
        System.out.println("=================读取第一个值-查看三个重要的指标=========================");
        System.out.println("读取到第一个值:" + byteBuffer.get());
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());


        System.out.println("=================读取第二个值-查看三个重要的指标=========================");
        System.out.println("读取到第二个值:" + byteBuffer.get());
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());


        // 此时在读取模式下,如果想要再次写入数据,需要调用clear或compact方法
        // clear方法清除整个缓冲区 compact方法仅清除已阅读的数据.转为写入模式
        System.out.println("=================compact仅清除已阅读的数据转换为写入模式-查看三个重要的指标=========================");
        byteBuffer.compact();
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());



        // 注意:写入模式下最大只能够写到limit限制的位置,而写入模式下的limit等于capacity容量大小
        System.out.println("=================写入两个字节-查看三个重要的指标=========================");
        byteBuffer.put((byte) 1);
        byteBuffer.put((byte) 2);
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());

        // 注意:转换为读取模式(不调用flip方法,也是可以读取数据的,但是position记录读取的位置不对)
        // 因为当前的position记录位置为2,如果不转换成读取模式,而是直接get读取的话.往后读,读取到3下标的数据,这就导致读到数据不对了呀
        // 而由于limit在读取模时等于capacity容量大小所以是3,立马读的话不会出现下标越界导致报错的情况
        // 因为position=2,limit=3 position还没达到limit限制的大小,但是读取到的数据不对会读到0 字节数组没有值的情况下默认为0
        System.out.println("=================转换为读取模式-查看三个重要的指标=========================");
        byteBuffer.flip();
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());


        // 注意:从头往下读,因为调用flip转为读取模式时,position归零,limit=position
        System.out.println("=================读取第一个值-查看三个重要的指标=========================");
        System.out.println("读取到第一个值:" + byteBuffer.get());
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());


        System.out.println("=================读取第二个值-查看三个重要的指标=========================");
        System.out.println("读取到第二个值:" + byteBuffer.get());
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());


        // 注意:当读取数据时最大只能够读到limit限制的位置,而读取模式下的limit等于写入模式下的position位置.
        // 比如说上面写了两次,那么position位置等于2,转换为读取模式position=0,limit=2
        // 读取了两次position位置等于2,limit=2,position达到了limit限制的大小,再读下去就下标越界导致报错了呀
        System.out.println("=================读取第三个值-查看三个重要的指标=========================");
        System.out.println("读取到第三个值:" + byteBuffer.get());
        System.out.println("capacity容量:" + byteBuffer.capacity());
        System.out.println("position位置:" + byteBuffer.position());
        System.out.println("limit限制:" + byteBuffer.limit());
    }
}

还有Buffer的使用还有几个比较重要的API

byteBuffer.rewind(); //重置position为0
byteBuffer.mark(); //标记position的位置
byteBuffer.reset(); //重置position为上次mark()标记的位置

仔细看完BufferDemo的代码后发现Buffer本质上似乎是在对数组的封装

从下标越界、没有写入时读取到的数据为0 这两个特征看出来它内部很像是一个数组

为了满足心中的疑惑,还是忍不住的进去看一下ByteBuffer的源码

查看源码步骤如下

  • 查看ByteBuffer.allocate(3) 内部给我们返回的具体对象是什么

    可以看到ByteBuffer.allocate(3) 方法给我们返回了一个叫HeapByteBuffer的对象,从名字来看它想表达的意思是堆字节缓冲区

    这就有点尴尬,java中对象的实例不都是放在堆内存里面吗,为啥要说明一下这个对象是堆字节缓冲区,难道还有非堆字节缓冲区

    迷路了,哈哈,我们是要来看HeapByteBuffer内部实现原理是否是对一个数组的操作进行了封装,至于这个堆内还是堆外咋们后面再说

  • 查看HeapByteBuffer.put方法

    从这里面看到了里面声明了一个hb数组,那咋们看下nextPutIndex方法吧看起来很像是在获取下一个下标

  • 查看HeapByteBuffer.get方法

    一波操作下来,已经发现了,ByteBuffer里面就是对于数组操作的一些封装,为了更加方便的让我们使用对于数组的操作

内存类型

前面查看源码时发现了堆外内存,ByteBuffer为性能关键型代码提供了直接内存(direct堆外)和非常直接内存(heap堆)两种实现

非直接内存就是存放在java堆里面的数组.堆内存获取的方式:ByteBuffer.allocate(3)

堆外内存就是在jvm之外直接向操作系统申请堆外内存.堆外内存获取的方式:ByteBuffer.allocateDirect(3)

对于这两种获取内存的方式在使用上是没有任何区别的,下面聊一下这两种方式的不同之处

堆内存

堆内存的话在申请的时候是一个数组,这个相信大家都能够理解,本质是通过记录的方式去操作这个数组

堆外内存

堆外内存就有点区别了,目的是为了获取更高的性能。好处体现在以下两点

  • 在进行网络IO或者文件IO时比HeapByteBuffer少一次拷贝(file/socket ---OS memory --- jvm heap)

    GC会移动对象内存,在写file或socket的过程中,JVM的实现中,先会把数据复制到堆外,再进行写入

    • 什么叫少一次拷贝?

      Java中操作文件或是网络的时候需要调用操作系统的API直接去操作文件的写入,写入的过程中

      把内存地址传递过去.而java的实现方式是先将堆内存复制一份数据到堆外得到一个新的内存地址

      然后再通过新的内存地址写入到文件中去

      为什么要这么麻烦需要将数据复制一份到堆外去呢,因为java中的垃圾回收机制有一个特性会移动对象内存地址如果说直接使用堆里面的数据写入到文件里面去的话,很有可能在经过一次垃圾回收之后它的内存地址发生了改变,这样就会导致操作系统在写入文件的时候找不到数据具体所在的内存地址所以要实现这个功能为了防止操作系统和GC产生冲突,在JVM底层实现中会先把数据复制一遍到堆外再进行写入

      而如果直接用堆外内存会少一次拷贝,就根本就没有堆内存一说也不怕GC回收后改变数据的内存地址

      因为数据就一开始就放在堆外内存,GC不会去操作堆外内存

  • GC范围之外降低GC压力,但实现了自动管理.DirectByteBuffer中有一个Cleaner对象(PhantomReference)

    Cleaner被GC前会执行clean方法,触发DirectByteBuffer中定义的Deallocator

    • 怎么做的自动回收

      DirectByteBuffer这个对象是受GC管理的只不过是它申请的这块内存不受管理而已

      所有它在这里面取了一个巧当GC回收Cleaner的时候会先执行clean方法

      Cleaner.clean里面调用了DirectByteBuffer中定义的Deallocator对象中的run方法

      Deallocator.run方法里面就是回收DirectByteBuffer申请到的堆外内存具体实现

建议

什么情况下需要用到堆外内存

1.机器性能确实可观的时候才去分配

2.(网络传输、文件读写场景)分配给大型、长寿命的对象,比如说大文件读取 大数据处理这么做确实能够提高不少性能

3.通过虚拟机参数MaxDirectMemorySize限制大小防止耗尽整个机器的内存

Channel通道

Buffer缓冲区是用来封装数组或者内存块的操作,但是这个Buffer并不仅仅只是一个API,Buffer主要是提供给channel通道使用的

我们先看一下BIO和NIO的对比,如下图

BIO

BIO编程中涉及到jdk的IO包和net包相关的API

一切网络的操作都是通过两个对象进行操作的:分别是输出流outputStream输入流inputStream

通过Socket+IO两者API组合实现的网络数据交互

所以发送和接收数据需要通过这两个流来实现

NIO

NIO提供了一些新的网络编程API,只涉及到jdk的nio包

nio包提供了一个对象叫Channel,提供了通道的概念.通道可以用来创建网络连接同时也可以传输数据

Channel的API包含了UDP/TCP网络文件IO:落地实现如下几个类

说明
FileChannel 文件通道,能写入和读取文件的channel
DatagramChannel 数据报通道,能发送和接收UDP数据包的channel
SocketChannel 客户端用来基于TCP通信的通道
ServerSocketChannel 服务端用来基于TCP通信的通道

和标准IO Stream操作的区别

  • 一个通道内进行读取和写入标准IO中的stream通常是单向的(input或output)
  • 可以非阻塞读取和写入通道标准IO操作时会阻塞
  • 通道始终读取或写入缓冲区而标准IO需要和byte数组组合使用

接下来主要讲解关于网络连接相关的ChannelAPI

SocketChannel

用来建立TCP网络连接类似java.net.Socket

有两种创建SocketChannel形式:

  • 客户端主动发起和服务端的连接
  • 服务端获取的新连接

客户端使用代码如下:

public static void main(String[] args) throws IOException {
    SocketChannel socketChannel = SocketChannel.open();
    // 默认为阻塞模式,调用以下api可设置为非阻塞模式
    socketChannel.configureBlocking(false);
    // 连接服务端
    socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));

    //发送请求数据-向通道写入数据
    ByteBuffer allocate = ByteBuffer.allocate(1);
    socketChannel.write(allocate);

    //读取服务端返回-读取缓冲区的数据
    int read = socketChannel.read(allocate);

    //关闭连接
    socketChannel.close();
}

API区别

从这个API的使用上来看就应该能够明白为什么它的用意是替代网络编程之前的一些API

因为它没有那么复杂了更加简单明了,建立一个通道在这个通道上进行读写,概念意义上来说也比较清晰

功能区别

Channel在使用方面不仅只是API的调整更优势的是功能上的区别

因为读和写的方法都变成了一个非阻塞的

write写: write()在尚未写入任何内容时就可能返回了,需要在循环中调用write()

read读: read()方法可能直接返回而根本不读取任何数据,根据返回的int值判断读取了多少字节

小结

这个是对于SocketChannel的理解,如果说不能理解的话,可以直接把它对标到java.net.Socket中,这样就会比较清晰了

在使用的时候无论是网络操作还是IO操作全部都是在一套API里面,使用起来很方便

ServerSocketChannel

可以监听新建的TCP连接通道,类似java.net.ServerSocket

服务端使用代码如下:

public static void main(String[] args) throws IOException {
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 设置为非阻塞模式
    serverSocketChannel.configureBlocking(false);
    // 绑定端口
    serverSocketChannel.socket().bind(new InetSocketAddress(8080));

    // 监听客户端
    while (true) {
        SocketChannel accept = serverSocketChannel.accept();
        if (accept == null) {
            continue;
        }
    }
}

对于ServerSocketChannel中阻塞和非阻塞模式区别在于accept方法

如果该通道处于非阻塞模式,那么如果没有挂起的连接,该方法将立刻返回null.所有必须检查返回的SocketChannel是否为null

Selector选择器

Selector是一个Java NIO组件,可以检查一个或多个NIO通道,并确定哪些通道已准备好进行读取或写入

实现单个线程可以管理多个通道,从而管理多个网络连接

一个线程使用Selector监听多个channel的不同事件

事件 监听
Connect连接 SelectionKey.OP_CONNECT
Accept准备就绪 SelectionKey.OP_ACCEPT
Read读取 SelectionKey.OP_READ
Write写入 SelectionKey.OP_WRITE

事件驱动机制

实现一个线程处理多个通道的核心概念理解:事件驱动机制

非阻塞的网络通道下,开发者通过Selector注册对于通道感兴趣的事件类型

线程通过监听事件来触发相应的代码执行

拓展:更底层是操作系统的多路复用机制

import java.io.IOException;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

/**
 * @author pangbohuan
 * @date 2020-07-12 15:15
 * @description selector 选择器
 */
public class SelectorDemo {

    public static void main(String[] args) throws IOException {

        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);

        Selector selector = Selector.open();

        // 注册感兴趣的事件
        socketChannel.register(selector, SelectionKey.OP_READ);

        // 由accept轮询,变成了事件通知的方式
        while (true) {
            // 收到新的事件,方法才会返回
            int select = selector.select();
            if (select == 0) {
                continue;
            }


            // 判断不同的事件类型,执行对应的逻辑处理
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                if (selectionKey.isAcceptable()) {

                }

                if (selectionKey.isConnectable()) {

                }

                if (selectionKey.isReadable()) {

                }

                if (selectionKey.isWritable()) {

                }

                iterator.remove();
            }
        }
    }
}

NIO网络编程

现在我们已经了解到了NIO中的网络编程相关的一些API和两个重要的组件,有了这两个组件就可以编写出基于NIO的网络编程,可以试着写一下

初次编写

服务端

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

/**
 * @author pangbohuan
 * @date 2020/7/10 0010 15:16
 * @description nio服务端
 */
public class NioServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));
        System.out.println("服务端启动成功");


        while (true) {
            // 监听新tcp连接通道
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel == null) {
                continue;
            }
            System.out.println("收到一个新的连接");
            // 设置为非阻塞模式
            socketChannel.configureBlocking(true);

            // 接收数据
            ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
            while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
                if (requestBuffer.position() > 0) {
                    break;
                }
            }
            // 转为读取模式
            requestBuffer.flip();

            // 将requestBuffer中的数据读取到字节数组,然后打印出来
            byte[] content = new byte[requestBuffer.limit()];
            requestBuffer.get(content);

            System.out.println(new String(content));
            System.out.println("收到数据,来自:" + socketChannel.getRemoteAddress());

            // 响应客户端
            String response = "HTTP/1.1 200 OK\r\n" +
                    "Content-Length: 11\r\n\r\n" +
                    "Hello World";
            ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes());
            while (responseBuffer.hasRemaining()) {
                socketChannel.write(responseBuffer);
            }
        }
    }
}

客户端

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;

/**
 * @author pangbohuan
 * @date 2020/7/10 0010 15:35
 * @description nio客户端
 */
public class NioClient {

    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));

        // 判断是否连接上,没连接上一直等待
        while (!socketChannel.finishConnect()) {
            Thread.yield();
        }

        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入发送的内容:");
        // 发送内容
        String next = scanner.next();
        ByteBuffer byteBuffer = ByteBuffer.wrap(next.getBytes());
        while (byteBuffer.hasRemaining()) {
            socketChannel.write(byteBuffer);
        }

        // 读取响应
        System.out.println("收到服务端响应:");
        ByteBuffer responseBuffer = ByteBuffer.allocate(1024);
        while (socketChannel.isOpen() && socketChannel.read(responseBuffer) != -1) {
            if (responseBuffer.position() > 0) {
                break;
            }
        }
        // 转为读取模式
        responseBuffer.flip();
        // 将requestBuffer中的数据读取到字节数组,然后打印出来
        byte[] content = new byte[responseBuffer.limit()];
        responseBuffer.get(content);
        System.out.println(new String(content));
    }
}

执行一下代码看看效果

1.启动NioServer服务端

2.启动NioClient客户端

可以看到程序是正常运行的,也可以通讯的

之前说到NIO非阻塞模式能通过一个线程处理多个请求,我们试一下看看效果

1.启动NioServer服务端

2.启动NioClient客户端

3.再次启动NioClient客户端

这就尴尬了,两个客户端连接的时候,服务端为啥只处理了一个客户端的连接

官方说通过一个线程可以管理多个Channel,难道是哪里写的不对吗

我们回去仔细阅读下服务端的代码,问题点出在于以下几个点

// 接收数据
ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
    if (requestBuffer.position() > 0) {
        break;
    }
}

// 响应客户端
String response = "HTTP/1.1 200 OK\r\n" +
    "Content-Length: 11\r\n\r\n" +
    "Hello World";
ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes());
while (responseBuffer.hasRemaining()) {
    socketChannel.write(responseBuffer);
}

虽然说我们没有用到阻塞的API但是却人工实现了不断地判断数据是否已经读取完毕,而且还是最低效率的where循环

我们既然用到了非阻塞API目的就是为了去除阻塞的写法,在设计上我们可以和BIO有很大的不同

我们试着改造一下,将NIO程序进行一次优化

第一次优化

服务端

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

/**
 * @author pangbohuan
 * @date 2020/7/10 0010 15:16
 * @description nio服务端
 */
public class NioServer {

    private static List<SocketChannel> socketChannels = new ArrayList<>();

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));
        System.out.println("服务端启动成功");


        while (true) {
            // 监听新tcp连接通道
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel != null) {
                System.out.println("收到一个新的连接");
                // 设置为非阻塞模式
                socketChannel.configureBlocking(false);
                // 加入通道集合
                socketChannels.add(socketChannel);
            } else {
                // 在没有新连接进来的时候
                // 遍历通道集合,处理现有连接的数据,处理完就删掉
                Iterator<SocketChannel> iterator = socketChannels.iterator();
                while (iterator.hasNext()) {
                    SocketChannel channel = iterator.next();
                    // 接收数据-判断当前连接是否有数据需要读取
                    // 如果没有数据,就处理下一个连接.没必要死死的等
                    ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
                    if (channel.read(requestBuffer) == 0) {
                        continue;
                    }

                    while (channel.isOpen() && channel.read(requestBuffer) != -1) {
                        if (requestBuffer.position() > 0) {
                            break;
                        }
                    }
                    // 转为读取模式
                    requestBuffer.flip();

                    // 将requestBuffer中的数据读取到字节数组,然后打印出来
                    byte[] content = new byte[requestBuffer.limit()];
                    requestBuffer.get(content);

                    System.out.println(new String(content));
                    System.out.println("收到数据,来自:" + channel.getRemoteAddress());

                    // 响应客户端
                    String response = "HTTP/1.1 200 OK\r\n" +
                            "Content-Length: 11\r\n\r\n" +
                            "Hello World";
                    ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes());
                    while (responseBuffer.hasRemaining()) {
                        channel.write(responseBuffer);
                    }

                    iterator.remove();
                }
            }
        }
    }
}

执行一下代码看看效果

1.启动NioServer服务端

2.启动NioClient客户端

3.再次启动NioClient客户端

可以发现,现在我们的服务端在单线程的情况下可以处理多个客户端请求

我们换了一种新的思路

当有新连接进来的时候将新连接加入到一个列表中去

当没有新连接进来的时候会去处理列表中现有连接的数据,而处理现有连接的数据时检查一下现有连接是否有需要处理的数据,这就是非阻塞API的妙用channel.read(requestBuffer).即使读取不到数据也会立刻返回0,我们可以通过这样的方式去检查一下客户端是否有数据需要处理,如果没有就跳过 没必要死死的等

但是在这里我们使用的方式是循环检查去检测通道具体数据传输的状态

这种方式在高并发环境下面是比较低效的

NIO中已经为我们考虑到了这个问题,它提供了一种方式帮助我们去避免循环检查的工作

第二次优化

服务端

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

/**
 * @author pangbohuan
 * @date 2020-07-12 13:36
 * @description NIO服务端 结合Selector选择器实现的非阻塞服务端(放弃对channel的轮询,借助消息通知机制)
 */
public class NioServer {

    public static void main(String[] args) throws IOException {
        // 1.创建网络服务端ServerSocketChannel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);

        // 2.构建一个Selector选择器,并且将channel注册上去
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT, serverSocketChannel);

        // 3.绑定端口
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));
        System.out.println("服务端启动成功");


        while (true) {

            // 不再轮询通道,改用下面轮询事件的方式
            // select方法有阻塞效果,直到有事件通知才会有返回
            selector.select();

            // 获取事件遍历查询结果
            Iterator<SelectionKey> selectionKeyIterator = selector.selectedKeys().iterator();
            while (selectionKeyIterator.hasNext()) {
                SelectionKey selectionKey = selectionKeyIterator.next();
                selectionKeyIterator.remove();

                // 关注accept和read两个事件
                if (selectionKey.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) selectionKey.attachment();

                    // 将拿到的客户端连接通道,注册到selector上面
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ, socketChannel);
                    System.out.println("收到一个新的连接:" + socketChannel.getRemoteAddress());
                }

                if (selectionKey.isReadable()) {
                    try {
                        SocketChannel socketChannel = (SocketChannel) selectionKey.attachment();
                        ByteBuffer requestBuffer = ByteBuffer.allocate(1024);

                        while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
                            if (requestBuffer.position() > 0) {
                                break;
                            }
                        }
                        // 转为读取模式
                        requestBuffer.flip();

                        // 将requestBuffer中的数据读取到字节数组,然后打印出来
                        byte[] content = new byte[requestBuffer.limit()];
                        requestBuffer.get(content);

                        System.out.println(new String(content));
                        System.out.println("收到数据,来自:" + socketChannel.getRemoteAddress());

                        // 响应客户端
                        String response = "HTTP/1.1 200 OK\r\n" +
                                "Content-Length: 11\r\n\r\n" +
                                "Hello World";
                        ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes());
                        while (responseBuffer.hasRemaining()) {
                            socketChannel.write(responseBuffer);
                        }
                    } catch (IOException e) {
                        // 取消事件订阅
                        selectionKey.cancel();
                    }
                }
            }
            selector.selectNow();
        }
    }
}

执行一下代码看看效果

1.启动NioServer服务端

2.启动NioClient客户端

3.再次启动NioClient客户端

可以发现,现在我们的服务端在放弃对channel的轮询情况下

通过借助Selector选择器消息通知机制也可以实现一个线程处理多个客户端请求而且比轮询的效率更高

NIO对比BIO

讲到这里就已经介绍和演示完NIO的三大核心组件了

接下来我们来对NIO做一个总结,将NIO和BIO做一个对比

BIO线程模型

专门分配一个线程负责处理一个连接

一个线程只能处理一个连接所以需要很多线程才能支撑高并发

虽然给了那么多线程但是线程的利用率是很低的换句话说就是资源分配的很多但是干的活却很少

NIO线程模型

所有客户端和服务端进行连接并不是直接就分配线程进行处理

会有个事件通知机制进行一次分发

我们会去处理这样的一个事件,然后用单个线程处理多个连接

根据事件的类型去处理不同的事件,只需要一个线程就可以完成BIO多线程完成的工作

由于NIO是非阻塞IO,所以它的线程利用率会高很多

毕竟一个线程处理了多个连接事件,从而服务器性能也会更加强大

如果程序需要支撑大量的连接使用NIO是最好的方式

Tomcat8中已经完全去除BIO相关的网络处理代码,默认采用NIO进行网络处理

NIO与多线程结合

在真正的生产环境中,比如说tomcat8或者说其他的NIO网络应用中

它的一个编写方式并不像我们刚刚第二次优化服务端编写的这么简单,相对来说会复杂一些

我们在第二次优化服务端编写的时候通过一个线程处理了所有的事件

但是在真正的生产环境中我们会遇到高并发、海量连接的情况

一个线程能做的事情是有限的,所以当事件很多、并发量大的时候一个线程就处理不过来了

而且一个线程不停地在运作也没有充分利用到服务器多核的特性

单线程处理在性能方面会成为瓶颈,对于这样的瓶颈我们需要做一下NIO和多线程结合的改造

提到NIO和多线程结合不得不提一篇著作,Doug Lea写的一篇著名的文章《Scalable IO in Java》

这篇文章的主要目的就是描述了如何去构建一个NIO网络服务以及一些事件处理的相关描述

还提到了如何用Reactor模式结合NIO的一些API实现高性能的NIO服务

JDK有很多java类都是由Doug Lea编写的

所以这篇文章也代表了在java开发中NIO和多线程结合的标准做法

文章地址:

[http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf]:

这两幅是我从Doug Lea的这篇文章抠出来的,它的意义在于很形象的阐述了Reactor模式到底该怎么去做

单Reactor模式

他的定义是单Reactor模式,所谓的单Reactor模式就是它定义了两种线程(Reactor线程处理连接请求线程)

Reactor线程

主要负责网络数据接收以及网络连接的处理,比如说TCP层到底接收了哪些数据他会全部交由该线程来处理

处理连接请求线程

接收到数据以后,接下来的操作:解析协议和接收数据它会由单独的线程池中的线程来进行执行

它实际上就是将底层的基础网络处理和应用层逻辑处理进行了一个分离,

落地实现为两种不同的线程进行不同的处理

这种模式也能够提高程序的处理效率,毕竟将网络处理和耗时一点的应用层处理分开之后,

多核服务器的特点也能够得到运用

多Reactor模式

多Reactor模式是将Reactor分为了多种,前面讲到Reactor是负责网络数据接收以及网络连接的处理

而多Reactor模式将网络连接交由一个Reactor去做数据读取又交给另一个Reactor去做

其他和单Reactor模式没有区别

本质上来说多了一种Reactor实际上只是在底层网络处理方面多了一次分发而已

就是将读取数据的事件交给新开的Reactor

理解代码

代码演示

对于NIO与多线程结合是有一定的难度的,不然Doug Lea也不会直接在《Scalable IO in Java》文章中写一些代码示例了,正是因为难以理解所以他不仅提供了图还提供了代码示例

我根据这篇文章的思路编写出NIO与多线程结合的示例代码, 希望读者在看文章的同时也看一下具体代码的实现

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;


/**
 * @author pangbohuan
 * @date 2020-07-12 13:36
 * @description NIO selector 多路复用reactor线程模型
 */
public class NioServer {

    private ServerSocketChannel serverSocketChannel;

    /**
     * 处理业务操作的线程
     */
    private static ExecutorService workPool = Executors.newCachedThreadPool();


    /**
     * 创建多个线程 - accept处理reactor线程 (accept线程)
     */
    private ReactorThread[] mainReactorThreads = new ReactorThread[1];
    /**
     * 2、创建多个线程 - io处理reactor线程  (I/O线程)
     */
    private ReactorThread[] subReactorThreads = new ReactorThread[8];

    /**
     * 封装了selector.select()等事件轮询的代码
     */
    abstract class ReactorThread extends Thread {

        Selector selector;
        LinkedBlockingQueue<Runnable> taskQueue = new LinkedBlockingQueue<>();

        /**
         * Selector监听到有事件后,调用这个方法
         */
        public abstract void handler(SelectableChannel channel) throws Exception;

        private ReactorThread() throws IOException {
            selector = Selector.open();
        }

        volatile boolean running = false;

        @Override
        public void run() {
            // 轮询Selector事件
            while (running) {
                try {
                    // 执行队列中的任务
                    Runnable task;
                    while ((task = taskQueue.poll()) != null) {
                        task.run();
                    }
                    selector.select(1000);

                    // 获取查询结果
                    Set<SelectionKey> selected = selector.selectedKeys();
                    // 遍历查询结果
                    Iterator<SelectionKey> iter = selected.iterator();
                    while (iter.hasNext()) {
                        // 被封装的查询结果
                        SelectionKey key = iter.next();
                        iter.remove();
                        int readyOps = key.readyOps();
                        // 关注 Read 和 Accept两个事件
                        if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
                            try {
                                SelectableChannel channel = (SelectableChannel) key.attachment();
                                channel.configureBlocking(false);
                                handler(channel);
                                if (!channel.isOpen()) {
                                    key.cancel(); // 如果关闭了,就取消这个KEY的订阅
                                }
                            } catch (Exception ex) {
                                key.cancel(); // 如果有异常,就取消这个KEY的订阅
                            }
                        }
                    }
                    selector.selectNow();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        private SelectionKey register(SelectableChannel channel) throws Exception {
            // 为什么register要以任务提交的形式,让reactor线程去处理?
            // 因为线程在执行channel注册到selector的过程中,会和调用selector.select()方法的线程争用同一把锁
            // 而select()方法实在eventLoop中通过while循环调用的,争抢的可能性很高,为了让register能更快的执行,就放到同一个线程来处理
            FutureTask<SelectionKey> futureTask = new FutureTask<>(() -> channel.register(selector, 0, channel));
            taskQueue.add(futureTask);
            return futureTask.get();
        }

        private void doStart() {
            if (!running) {
                running = true;
                start();
            }
        }
    }

    /**
     * 初始化线程组
     */
    private void newGroup() throws IOException {
        // 创建IO线程,负责处理客户端连接以后socketChannel的IO读写
        for (int i = 0; i < subReactorThreads.length; i++) {
            subReactorThreads[i] = new ReactorThread() {
                @Override
                public void handler(SelectableChannel channel) throws IOException {
                    // work线程只负责处理IO处理,不处理accept事件
                    SocketChannel ch = (SocketChannel) channel;
                    ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
                    while (ch.isOpen() && ch.read(requestBuffer) != -1) {
                        // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
                        if (requestBuffer.position() > 0) {
                            break;
                        }
                    }
                    // 如果没数据了, 则不继续后面的处理
                    if (requestBuffer.position() == 0) {
                        return;
                    }
                    requestBuffer.flip();
                    byte[] content = new byte[requestBuffer.limit()];
                    requestBuffer.get(content);
                    System.out.println(new String(content));
                    System.out.println(Thread.currentThread().getName() + "收到数据,来自:" + ch.getRemoteAddress());

                    // TODO 业务操作 数据库、接口...
                    workPool.submit(() -> {
                    });

                    // 响应结果 200
                    String response = "HTTP/1.1 200 OK\r\n" +
                            "Content-Length: 11\r\n\r\n" +
                            "Hello World";
                    ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
                    while (buffer.hasRemaining()) {
                        ch.write(buffer);
                    }
                }
            };
        }

        // 创建mainReactor线程, 只负责处理serverSocketChannel
        for (int i = 0; i < mainReactorThreads.length; i++) {
            mainReactorThreads[i] = new ReactorThread() {
                AtomicInteger incr = new AtomicInteger(0);

                @Override
                public void handler(SelectableChannel channel) throws Exception {
                    // 只做请求分发,不做具体的数据读取
                    ServerSocketChannel ch = (ServerSocketChannel) channel;
                    SocketChannel socketChannel = ch.accept();
                    socketChannel.configureBlocking(false);
                    // 收到连接建立的通知之后,分发给I/O线程继续去读取数据
                    int index = incr.getAndIncrement() % subReactorThreads.length;
                    ReactorThread workEventLoop = subReactorThreads[index];
                    workEventLoop.doStart();
                    SelectionKey selectionKey = workEventLoop.register(socketChannel);
                    selectionKey.interestOps(SelectionKey.OP_READ);
                    System.out.println(Thread.currentThread().getName() + "收到新连接 : " + socketChannel.getRemoteAddress());
                }
            };
        }


    }

    /**
     * 初始化channel,并且绑定一个eventLoop线程
     *
     * @throws IOException IO异常
     */
    private void initAndRegister() throws Exception {
        // 1、 创建ServerSocketChannel
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        // 2、 将serverSocketChannel注册到selector
        int index = new Random().nextInt(mainReactorThreads.length);
        mainReactorThreads[index].doStart();
        SelectionKey selectionKey = mainReactorThreads[index].register(serverSocketChannel);
        selectionKey.interestOps(SelectionKey.OP_ACCEPT);
    }

    /**
     * 绑定端口
     *
     * @throws IOException IO异常
     */
    private void bind() throws IOException {
        //  1、 正式绑定端口,对外服务
        serverSocketChannel.bind(new InetSocketAddress(8080));
        System.out.println("启动完成,端口8080");
    }

    public static void main(String[] args) throws Exception {
        NioServer nioServer = new NioServer();
        // 1、 创建main和sub两组线程
        nioServer.newGroup();
        // 2、 创建serverSocketChannel,注册到mainReactor线程上的selector上
        nioServer.initAndRegister();
        // 3、 为serverSocketChannel绑定端口
        nioServer.bind();
    }
}

总结

NIO为开发者提供了功能丰富及强大的IO处理API,但是在应用于网络应用开发的过程中直接使用JDK提供的API是比较繁琐的。而且要想将性能进行提升光有NIO还不够,需要将多线程技术与之结合起来

因为网络编程本身的复杂性,以及JDK API开发的使用难度较高,所以在开源社区中,涌现出很多对JDK NIO进行封装、增加后的网络编程框架,例如:Netty、Mina等