Netty(二)BIO与NIO

前言

上一篇博客中,直接简单粗暴的总结了Java NIO中的buffer,channel和Selector三个组件,但是并没有对BIO和NIO的区别做一个梳理,这一篇博客主要梳理BIO和NIO的区别,至于AIO,这个后面会在多线程的内容中进行梳理。

BIO

这个概念我们听过N多次,在我们初步写Socket的实例的时候,我们就会接触到一个基于IO流的Socket简单实例,那个实例就是BlockIO的,这里先贴出这个代码

Socket通信的简单服务端程序

public class SocketServer {
  public static void main(String[] args) throws Exception {
    // 监听指定的端口
    int port = 8888;
    ServerSocket server = new ServerSocket(port);
    
    // server将一直等待连接的到来
    System.out.println("server将一直等待连接的到来");
    Socket socket = server.accept();

    // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
    InputStream inputStream = socket.getInputStream();
    byte[] bytes = new byte[1024];
    int len;
    StringBuilder sb = new StringBuilder();
    //只有当客户端关闭它的输出流的时候,服务端才能取得结尾的-1
    while ((len = inputStream.read(bytes)) != -1) {
      // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
      sb.append(new String(bytes, 0, len, "UTF-8"));
    }
    System.out.println("get message from client: " + sb);

    //从建立的连接中获取Outputstream
    OutputStream outputStream = socket.getOutputStream();
    outputStream.write("Hello Client,I get the message.".getBytes("UTF-8"));

    inputStream.close();
    outputStream.close();
    socket.close();
    server.close();
  }
}

服务端需要获取输入输出流,然后从输入流中读取数据,往输出流中写出数据,会阻塞在accept上,一直等待客户端的连接。

public class SocketClient {
  public static void main(String args[]) throws Exception {
    // 要连接的服务端IP地址和端口
    String host = "127.0.0.1";
    int port = 8888;
    // 与服务端建立连接
    Socket socket = new Socket(host, port);
    // 建立连接后获得输出流
    OutputStream outputStream = socket.getOutputStream();
    String message = "你好  this is client";
    socket.getOutputStream().write(message.getBytes("UTF-8"));
    //通过shutdownOutput高速服务器已经发送完数据,后续只能接受数据
    socket.shutdownOutput();
    
    InputStream inputStream = socket.getInputStream();
    byte[] bytes = new byte[1024];
    int len;
    StringBuilder sb = new StringBuilder();
    while ((len = inputStream.read(bytes)) != -1) {
      //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
      sb.append(new String(bytes, 0, len,"UTF-8"));
    }
    System.out.println("get message from server: " + sb);
    
    inputStream.close();
    outputStream.close();
    socket.close();
  }
}

客户端与服务端似乎并没有太多的区别,都是通过socket获取输入输出流,然后写入或读取数据。这段代码的问题也很明显,每次服务端只能处理一个请求,后续的请求只能阻塞,并且最不能接受的是,这个只处理了一个请求,服务端就关闭了,在实际应用中,可是有N多个客户端的请求需要处理的。所以一般这个时候,我们就会想到对服务端进行优化。主要有两种方式,服务端采用死循环,让服务端不断监听客户端的连接,同时对数据处理采用多线程的方式。如下所示:

多线程的处理类:

package com.learn.bio;

import lombok.extern.slf4j.Slf4j;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

/**
 * autor:liman
 * createtime:2019/10/7
 * comment:
 */
@Slf4j
public class ServerHandler implements Runnable {

    private Socket socket;

    public ServerHandler(Socket socket) {
        this.socket = socket;
    }

    //在这里完成读写
    public void run() {
        BufferedReader in = null;
        PrintWriter out = null;
        try {
            in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            out = new PrintWriter(socket.getOutputStream(), true);
            String expression;
            int result;
            while (true) {
                if ((expression = in.readLine()) == null) break;
                log.info("服务端收到信息:" + expression);

                result = Calculator.cal(expression);
                out.println(result);
            }
        } catch (Exception e) {
            e.printStackTrace();
            log.error(e.getLocalizedMessage());
        } finally {
            //一些必要的清理工作
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                in = null;
            }
            if (out != null) {
                out.close();
                out = null;
            }
            if (socket != null) {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                socket = null;
            }
        }
    }
}

服务端的代码就变成了如下所示:

package com.learn.bio;


import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * autor:liman
 * createtime:2019/10/7
 * comment:
 */
@Slf4j
public class Server {

    private static int DEFAULT_PORT = 8888;

    private static ServerSocket serverSocket;

    public static void start() throws IOException {
        start(DEFAULT_PORT);
    }

    private synchronized static void start(int defaultPort) throws IOException {
        if (serverSocket != null) {
            return;
        }
        try {
            serverSocket = new ServerSocket(defaultPort);
            log.info("服务端顺利启动,端口为:{}", defaultPort);
            while (true) {
                Socket socket = serverSocket.accept();
                //利用多线程去处理数据
                new Thread(new ServerHandler(socket)).start();
            }
        } finally {
            //一些必要的清理工作
            if (serverSocket != null) {
                System.out.println("服务端已关闭。");
                serverSocket.close();
                serverSocket = null;
            }
        }
    }

}

 这样似乎让服务器的服务能力提升了一个量级,但是这样真的OK了么?并没有,针对一个请求,深入到accept源码中,我们会看到这样一段代码

    /**
     * Accepts connections.
     * @param s the connection
     */
    protected void accept(SocketImpl s) throws IOException {
        acquireFD();
        try {
            socketAccept(s);
        } finally {
            releaseFD();
        }
    }

也就是说实际的accept操作,先要获取一个文件句柄,然后再去建立连接(这里的文件句柄,我个人猜测应该是网卡的文件描述符) ,进入acquireFD()函数,我们可以看到如下代码

/*
 * "Acquires" and returns the FileDescriptor for this impl
 *
 * A corresponding releaseFD is required to "release" the
 * FileDescriptor.
 */
FileDescriptor acquireFD() {
    synchronized (fdLock) {
        fdUseCount++;
        return fd;
    }
}

惊奇的看到,这个是加了锁的,因此虽然我们引入了多线程的处理数据方式,但是建立TCP连接这一步依旧是阻塞的。

下图就是我们上面的通信模式示意图(借鉴了某位大牛的博客)

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

先说明一下这个图,在客户端发起连接的时候,服务端建立连接之后会经过read,decode,process,encode,send操作之后,客户端才能收到服务端的响应,而这整个过程,客户端都一直在等待。 read操作就是等待客户端的数据发送到服务端,decode和encode就是解码和编码数据(毕竟是网络,传输的是二进制),process是处理数据,send就是发送数据给客户端。

回到正题,我们优化了服务端之后,问题依旧很明显:首先,来一个请求就开一个线程,肯定不合理,当活跃连接数达到已经量级多线程对系统消耗很大,线程切换开销就很大。其次,accept是一个阻塞操作,在建立连接之后马上有新线程来处理socket,但是这个时候并不代表客户端的数据传送过来了,所以read方法的时候,服务端的线程也会阻塞,客户端就更不必说,一直要等到服务端send了数据之后才会进行下面的工作。

再来聊聊什么是BIO:

偶尔看到Unix的阻塞IO模型,如下所示:

在进程调用内核的过程中,内核分为两个阶段,第一个是准备数据,第二个是复制数据,两个过程都没有明确的回执给系统调用方,系统调用方一直是阻塞的,这个我们就称为同步阻塞IO。

NIO

聊到NIO,首先得看看上一篇博客中的三个组件:传送门——NIO中的三个组件。如果用NIO来完成上述网络通信实例,其示意图如下

利用NIO中的channel双向通信,加上单线程的Reactor模式完成,如果觉得Reactor不好理解,可以就理解为Selector。在NIO中服务端与客户端之间的通信不是ServerSocket和Socket了,而是ServerSocketChannel和SocketChannel,同时在服务端ServerSocketChannel和SocketChannel都可以注册到同一个Selector上,这个在代码中会有提现。

NIO模式下的服务端代码

package com.learn.netty;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

/**
 * autor:liman
 * createtime:2019/10/5
 * comment:
 */
public class NIOServer implements Runnable {
    private final int BUFFER_SIZE = 1024; // 缓冲区大小
    private final int PORT = 8888;        // 监听的端口
    private Selector selector;            // 多路复用器,NIO编程的基础,负责管理通道Channel
    // 缓冲区Buffer,和BIO的一个重要区别(NIO读写数据是在缓冲区中进行,而BIO是通过流的形式)
    private ByteBuffer readBuffer = ByteBuffer.allocate(BUFFER_SIZE);

    public NIOServer() {
        try {
            // 1.开启多路复用
            selector = Selector.open();
            // 2.打开服务器通道(网络读写通道)即开启channel
            ServerSocketChannel channel = ServerSocketChannel.open();
            // 3.设置服务器通道为非阻塞模式,true为阻塞模式,false为非阻塞模式。
            // 将channel设置成非阻塞模式
            channel.configureBlocking(false);
            // 4.绑定端口
            channel.socket().bind(new InetSocketAddress(PORT));

            //上一篇博客中总结过channel的几种注册事件,如果要注册多个事件只需要取或操作即可。
            channel.register(selector, SelectionKey.OP_ACCEPT);
            System.out.println("Server start >>>>>>>> port : " + PORT);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 需要一个线程负责Selector的轮询
    public void run() {
        while (true) {
            try {
                //1.多路复用监听器阻塞
                selector.select();
                //2.多路复用器已经选择结果集
                Iterator<SelectionKey> selectionKeys = selector.selectedKeys().iterator();
                //3.不停的轮询
                while (selectionKeys.hasNext()) {
                    //4.获取其中一个key
                    SelectionKey key = selectionKeys.next();
                    //5.获取后从容器中移出
                    selectionKeys.remove();
                    //6.只获取有效的key
                    if (!key.isValid()) {
                        continue;
                    }
                    //阻塞状态处理
                    if (key.isAcceptable()) {
                        accept(key);
                    }
                    //可读状态处理
                    if (key.isReadable()) {
                        read(key);
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 设置阻塞,等待client请求。
     * 在传统IO中,用的是ServerSocket和Socket。
     * 在NIO中用的是在NIO中采用的ServerSocketChannel和SocketChannel。
     *
     * @param selectionKey
     */
    private void accept(SelectionKey selectionKey) {

        try {
            //1.获取通道服务
            ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
            //2.执行阻塞方法
            SocketChannel socketChannel = serverSocketChannel.accept();
            //3.设置服务器通道为非阻塞模式,false为非阻塞模式
            socketChannel.configureBlocking(false);
            //4.把通道注册到多路复用器上,并设置读取标识
			//这里注册的是SocketChannel,不是ServerSocketChannel,这个SocketChannel才是双方通信的管道。
            socketChannel.register(selector, SelectionKey.OP_READ);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * @param selectionKey
     */
    private void read(SelectionKey selectionKey) {
        try {
            //1.清空缓冲区数据
            readBuffer.clear();
            //2.获取在多路复用器上注册的通道
			//这里注册的是SocketChannel,不是ServerSocketChannel,这个SocketChannel才是双方通信的管道。
            SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
            //3.读取数据,返回
            int count = socketChannel.read(readBuffer);
            //4.返回内容为-1,表示没有数据
            if(-1 == count){
                selectionKey.channel().close();
                selectionKey.cancel();
                return;
            }
            //5.有数据则在读取数据前进行复位操作
            readBuffer.flip();
            //6.根据缓冲区大小创建一个相应大小的bytes数组,用来获取值
            byte[] datas= new byte[readBuffer.remaining()];
            //7.接受缓冲区数据
            System.arraycopy(readBuffer.array(), readBuffer.position(), datas, 0, readBuffer.remaining());
            //8.打印获取到的数据
            System.out.println("NIO Server : "+new String(datas,"UTF-8"));
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {
        new Thread(new NIOServer()).start();
    }
}

NIO模式下的客户端代码

package com.learn.netty;

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

/**
 * autor:liman
 * createtime:2019/10/5
 * comment:
 */
public class NIOClient {
    private final static int PORT = 8888;
    private final static int BUFFER_SIZE = 1024;
    private final static String IP_ADDRESS = "127.0.0.1";

    // 从代码中可以看出,和传统的IO编程很像,很大的区别在于数据是写入缓冲区
    public static void main(String[] args) {
        clientReq();
    }

    private static void clientReq() {
        // 1.创建连接地址
        InetSocketAddress inetSocketAddress = new InetSocketAddress(IP_ADDRESS, PORT);
        // 2.声明一个连接通道
        SocketChannel socketChannel = null;
        // 3.创建一个缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocate(BUFFER_SIZE);
        try {
            // 4.打开通道
            socketChannel = SocketChannel.open();
            // 5.连接服务器
            socketChannel.connect(inetSocketAddress);
            //6.传输的数据
            String content = "hello this is nio client";
            // 8.把数据放到缓冲区中
            byteBuffer.put(content.getBytes("UTF-8"));
            // 9.对缓冲区进行复位
            byteBuffer.flip();
            // 10.写出数据
            socketChannel.write(readBuffer);
            // 11.清空缓冲区数据
            byteBuffer.clear();
            
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != socketChannel) {
                try {
                    socketChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

这个时候,服务端只需要建立连接之后就可以立即返回给客户端,如果指定的channel在selector注册的事件触发,会自动通知客户端去取数据,但是读取数据的过程还是阻塞的,所以NIO也称为同步非阻塞IO。

还是以Unix的网络IO模型来说明NIO

和之前一样,数据的读取分为两个步骤,只是第一个步骤在数据准备的时候,会理解返回给客户端,客户端在数据准备的阶段不用阻塞,但是在第二个阶段数据复制的过程还是会阻塞,第二个过程还是同步的,所以就称为同步非阻塞IO。 

总结

NIO与BIO对应Unix网络IO模型来说要好理解一点,看了网上很多资料,都是一上来就各种概念,直接弄得头晕。其实总结起来就一点

同步IO和异步IO的区别就在于:数据访问的时候进程是否阻塞!(这个说的是数据准备阶段)

阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回!(这个说的是数据复制阶段)

如果两个阶段都不用阻塞,那么就是我们常说的AIO(异步非阻塞IO)这个常见于FutureTask模式中,其对应的Unix网络模型如下,在数据一切准备好之后,直接通知调用方,这个可以联想我们常见的Ajax请求的回调。

参考资料:

Java 非阻塞 IO 和异步 IO

Java NIO:Buffer、Channel 和 Selector

Java Socket编程基础及深入讲解

JAVA 通过 Socket 实现 TCP 编程

发布了129 篇原创文章 · 获赞 37 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/liman65727/article/details/102317742
今日推荐