NIO网络编程

188 阅读9分钟

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

JAVA NIO 

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

NIO中有三个核心组件

Buffer 缓冲区

  • 缓冲区本质上是一个可以写入数据的内存块(类似数组),然后可以再次读取。此内存块包含在NIO Buffer 对象中,该对象提供了一组方法,可以更轻松地使用内存块。
    相比较直接对数组的操作,Buffer API更加容易操作和管理

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

  1. 将数据写入缓冲区
  2. 调用buffer.flip() ,转换为读取模式
  3. 缓冲区读取数据调用buffer.clear() 或buffer.compact() 
  4. 清除缓冲区

Buffer工作原理

Buffer 三个重要属性

  1. capacity容量:作为一个内存块,Buffer具有一定的固定大小,也成为“容量”。

  2. position位置:写入模式时代表写数据的位置。读取模式时代表读取数据的位置。

  3. limit限制:写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量。

ByteBuffer内存类型

ByteBuffer为性能关键型代码提供了直接内存(direct堆外)和非直接内存(heap堆,底层是数组的实现方式)两种实现。堆外内存获取的方式:

ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(noBytes);

好处:

  1. 进行网络IO或者文件IO时比heapBuffer少一次拷贝。(file/socket ---OS memory -----jvm heap )GC(垃圾回收机制)会移动对象内存,在写file或socket的过程中,JVM的实现中,会先把数据复制到堆外,再进行写入 。
  2. GC范围之外,降低GC压力,但实现了自动管理。DirectByteBuffer中有一个Cleaner对象(PhantomReference),Cleaner被GC前会执行clean方法,触发DirectByteBuffer中定义的Deallocator

建议:

  1. 性能确实可观的时候才去使用;分配给大型、长寿命;(网络传输、文件读写场景)

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

    public class BufferDemo {
        public static void main(String[] args) {
            // 构建一个byte字节缓冲区,容量是4
            ByteBuffer byteBuffer  =  ByteBuffer.allocateDirect(4);
            // 默认写入模式,查看三个重要的指标
            System.out.println(String.format("初始化:capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                    byteBuffer.position(), byteBuffer.limit()));
            // 写入3字节的数据
            byteBuffer.put((byte) 1) ;
            byteBuffer.put((byte) 2) ;
            byteBuffer.put((byte) 3) ;
            // 再看数据
            System.out.println(String.format("写入3字节后,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                    byteBuffer.position(), byteBuffer.limit()));
    
            // 转换为读取模式(不调用flip方法,也是可以读取数据的,但是position记录读取的位置不对)
            System.out.println("#######开始读取");
            byteBuffer.flip();
            byte a = byteBuffer.get();
            System.out.println("a= "+a);
            byte b = byteBuffer.get();
            System.out.println("b= "+b);
            //读取两个之后查看一下容量。
            System.out.println(String.format("读取两个之后查看一下容量。,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                    byteBuffer.position(), byteBuffer.limit()));
    
            // 继续写入3字节,此时读模式下,limit=3,position=2.继续写入只能覆盖写入一条数据
            // clear()方法清除整个缓冲区。compact()方法仅清除已阅读的数据。转为写入模式
            byteBuffer.compact();
            byteBuffer.put((byte)3);
            byteBuffer.put((byte)4);
            byteBuffer.put((byte)5);
            System.out.println(String.format("清除之后再查看一下容量。,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                    byteBuffer.position(), byteBuffer.limit()));
    
            // rewind() 重置position为0
            // mark() 标记position的位置
            // reset() 重置position为上次mark()标记的位置
        }
    }
    

Channel通道

SocketChannel

SocketChannel用于建立TCP网络连接,类似java.net.Socket。有两种创建socketChannel形式:

  1. 客户端主动发起和服务器的连接

  2. 服务端获取的新连接

        // 客户端主动发起连接的方式
        SocketChannel socketChannel = SocketChannel.open();
        //设置为非阻塞模式
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress("127.0.0.1",80));
        //发送请求数据-向通道写入数据
        channel.write(byteBuffer);
        //读取服务端返回-读取缓冲区的数据
        socketChannel.read(byteBuffer);
        //关闭连接
        socketChannel.close();
    

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

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

ServerSocketChannel

ServerSocketChannel可以监听新建的TCP连接通道,类似ServerSocket 。

        //核心代码
        //创建网络服务端
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        //设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        //绑定端口
        serverSocketChannel.bind(new InetSocketAddress(8080));

        while (true) {
            //获取新tcp连接通道
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel != null) {
                //tcp 请求 读取/响应
            }
        }

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

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();
        }


        System.out.println("请输入:");
        Scanner scanner = new Scanner(System.in);
        String msg = scanner.nextLine();

        ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
        while (buffer.hasRemaining()){
            //发送请求数据-向通道写入数据
            socketChannel.write(buffer);
        }

        //收到服务端响应
        ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
        //读取服务端返回-读取缓冲区的数据
        while (socketChannel.isOpen()&&socketChannel.read(requestBuffer)!=-1){
            // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
            if(requestBuffer.position()>0) break ;
        }

        requestBuffer.flip();
        byte[] content = new byte[requestBuffer.limit()];
        requestBuffer.get(content);
        System.out.println("服务返回的连接内容 : " + new String(content));

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

public class NIOServer {
    //直接基于非阻塞的写法
    public static void main(String[] args) throws Exception {
        //创建网络服务端
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        //设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        //绑定端口
        serverSocketChannel.bind(new InetSocketAddress(8080));

        System.out.println("启动成功");
        while (true) {
            //获取新tcp连接通道
            SocketChannel socketChannel = serverSocketChannel.accept();
            socketChannel.configureBlocking(false); // 默认是阻塞的,一定要设置为非阻塞
            if (socketChannel != null) {
                //tcp 请求 读取/响应
                System.out.println("收到新连接 : "+socketChannel.getRemoteAddress());

                try {
                    ByteBuffer  requestByteBuffer = ByteBuffer.allocate(1024);
                    while (socketChannel.isOpen()&& socketChannel.read(requestByteBuffer)!=-1 ){
                        if (requestByteBuffer.position() > 0)  break; //有接收数据,让程序继续运行
                    }
                    if (requestByteBuffer.position() == 0)  continue;
                    //开启读模式
                    requestByteBuffer.flip();
                    byte[] content = new byte[requestByteBuffer.limit()];
                    requestByteBuffer.get(content);
                    System.out.println("收到数据 : "+new String(content));

                    //响应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()){
                        socketChannel.write(buffer);
                    }
                } catch (IOException e){
                    e.printStackTrace();
                }
            }
        }
    }
}

为什么会只收到一个连接显示呢,明明用的是非阻塞NIO的写法 ?

//因为这段代码的写法导致,程序被阻塞了,所以看起来像BIO 。这两个客户端只发送了连接,并没有发送内容。
while (socketChannel.isOpen()&& socketChannel.read(requestByteBuffer)!=-1 ){
    if (requestByteBuffer.position() > 0)  break; //有接收数据,让程序继续运行
}

那么如何解决上面阻塞的问题呢,是要像BIO一样用多线程的方式吗?不建议,因为NIO本身是非阻塞的API设计,在设计上和BIO有很大的不同,应该想办法改进这段问题代码 。

public class NIOServer1 {
    /**
     * 已经建立连接的集合
     */
    private static ArrayList<SocketChannel> channels = new ArrayList<>();

    //直接基于非阻塞的写法
    public static void main(String[] args) throws Exception {
        
        //创建网络服务端
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        //绑定端口
        serverSocketChannel.bind(new InetSocketAddress(8080));

        System.out.println("启动成功");
        while (true) {
            //获取新tcp连接通道
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (socketChannel != null) {
                //tcp 请求 读取/响应
                System.out.println("收到新连接 : "+socketChannel.getRemoteAddress());
                //设置为非阻塞模式
                socketChannel.configureBlocking(false);
                channels.add(socketChannel);
            }else {
                    // 没有新连接的情况下,就去处理现有连接的数据,处理完的就删除掉
                    Iterator<SocketChannel> iterator = channels.iterator();
                    while (iterator.hasNext()) {
                        try{
                            SocketChannel ch =  iterator.next();
                            ByteBuffer  requestByteBuffer = ByteBuffer.allocate(1024);
                            while (ch.isOpen()&& ch.read(requestByteBuffer)!=-1 ){
                                // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
                                if (requestByteBuffer.position() > 0)  break;
                            }
                            if (requestByteBuffer.position() == 0)  continue;

                            //开启读模式
                            requestByteBuffer.flip();
                            byte[] content = new byte[requestByteBuffer.limit()];
                            requestByteBuffer.get(content);
                            System.out.println("收到数据 : "+new String(content));

                            //响应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);
                            }

                        }catch (IOException e) {
                         e.printStackTrace();
                         iterator.remove();
                        }
                    }
            }
        }
    }
}

_这种低效,浪费CPU的循环检查,会是NIO服务端的正确开发方式吗 ?_在NIO中提供了selector选择器方式避免循环检查。

Selector 选择器

Selector是一个Java NIO 组件 ,可以检查一个或多个NIO通道,并确定哪些通道已准备好进行读取或写入。实现单个线程可以管理多个通道,从而管理多个网络连接

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

四个事件分别对应SelectionKey四个常量。

  1. Connect连接(SelectionKey.OP_CONNECT)
  2. Accept准备就绪(OP_ACCEPT)
  3. Read读取(OP_READ)
  4. Write写入(OP_WRITE)

Selector 选择器

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

非阻塞的网络通道下,开发者通过Selector 注册对于通道感兴趣的事件类型,线程通过监听事件来触发相应的代码执行。(拓展:更底层是操作系统的多路复用机制)

        //核心代码
        Selector selector = Selector.open();
        //创建网络服务端
        ServerSocketChannel channel = ServerSocketChannel.open();
        channel.configureBlocking(false);
        //注册感兴趣的事件
        channel.register(selector, SelectionKey.OP_ACCEPT);

        while (true){
            //由accept轮询,变成了事件通知的方式
            int readyChannels = selector.select();//select 收到新的事件,方法才会返回
            if( readyChannels == 0) continue;
            Set<SelectionKey> selectionkey = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator= selectionkey.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key =  keyIterator.next();
                //判断不同的事件类型,执行对应的逻辑处理
                //key.isAcceptable();/key.isConnectable();/key.isReadable();/key.isWritable();
                keyIterator.remove();

                if( key.isAcceptable()){
                    ServerSocketChannel server= (ServerSocketChannel)key.attachment();
                    SocketChannel  clientSocketChannel = server.accept();
                    clientSocketChannel.configureBlocking(false);
                    // 通道注册
                    clientSocketChannel.register(selector,SelectionKey.OP_READ,clientSocketChannel);
                    System.out.println("收到新连接 : " + clientSocketChannel.getRemoteAddress());
                }
                if( key.isReadable()){
                    SocketChannel channel = (SocketChannel)key.attachment();
                }
            }
        }


// 客户端和上边一样没有改动的地方


/**
 * 结合Selector实现的非阻塞服务端(放弃对channel的轮询,借助消息通知机制)
 */
public class NIOServerV2 {

    public static void main(String[] args) throws Exception {
        // 1. 创建网络服务端ServerSocketChannel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式

        // 2. 构建一个Selector选择器,并且将channel注册上去
        Selector selector = Selector.open();
        SelectionKey selectionKey = serverSocketChannel.register(selector, 0, serverSocketChannel);// 将serverSocketChannel注册到selector
        selectionKey.interestOps(SelectionKey.OP_ACCEPT); // 对serverSocketChannel上面的accept事件感兴趣(serverSocketChannel只能支持accept操作)

        // 3. 绑定端口
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));

        System.out.println("启动成功");

        while (true) {
            // 不再轮询通道,改用下面轮询事件的方式.select方法有阻塞效果,直到有事件通知才会有返回
            selector.select();
            // 获取事件
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            // 遍历查询结果e
            Iterator<SelectionKey> iter = selectionKeys.iterator();
            while (iter.hasNext()) {
                // 被封装的查询结果
                SelectionKey key = iter.next();
                iter.remove();
                // 关注 Read 和 Accept两个事件
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.attachment();
                    // 将拿到的客户端连接通道,注册到selector上面
                    SocketChannel clientSocketChannel = server.accept(); // mainReactor 轮询accept
                    clientSocketChannel.configureBlocking(false);
                    clientSocketChannel.register(selector, SelectionKey.OP_READ, clientSocketChannel);
                    System.out.println("收到新连接 : " + clientSocketChannel.getRemoteAddress());
                }

                if (key.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) key.attachment();
                    try {
                        ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
                        while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
                            // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
                            if (requestBuffer.position() > 0) break;
                        }
                        if(requestBuffer.position() == 0) continue; // 如果没数据了, 则不继续后面的处理
                        requestBuffer.flip();
                        byte[] content = new byte[requestBuffer.limit()];
                        requestBuffer.get(content);
                        System.out.println(new String(content));
                        System.out.println("收到数据,来自:" + socketChannel.getRemoteAddress());
                        // TODO 业务操作 数据库 接口调用等等

                        // 响应结果 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()) {
                            socketChannel.write(buffer);
                        }
                    } catch (IOException e) {
                        // e.printStackTrace();
                        key.cancel(); // 取消事件订阅
                    }
                }
            }
            selector.selectNow();
        }
        // 问题: 此处一个selector监听所有事件,一个线程处理所有请求事件. 会成为瓶颈! 要有多线程的运用
    }
}

NIO对比BIO 

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

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

NIO与多线程结合的改进方案

推荐阅读Doug Lea的著名文章《Scalable IO in Java》gee.cs.oswego.edu/dl/cpjslide…

code 基于reactor的思想对代码进行改进。待定。

小结

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

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