NIO核心原理深度解析

1 几个基础概念

在开始本文的主题之前,我们先来介绍一下几个基础概念。

1.1 同步

同步是指当前线程调用一个方法之后,当前线程必须等到该方法调用返回后,才能继续执行后续的代码。翻译成人话就是:某个人要做多件事时,必须做完第一件事情才能做第二件事情,然后才能做第三件,以此类推。而同步又可以再细分为同步阻塞和同步非阻塞。图示如下:
在这里插入图片描述

同步
主线程在调用了sum方法之后,必须等sum方法的结果返回之后才能继续往下执行,调用打印方法。

1.1.1 同步阻塞

同步阻塞是指在调用结果返回之前,当前线程会被挂起。当前线程只有在得到结果之后才会返回,然后才会继续往下执行。翻译成人话就是:当你要洗衣服时,你把衣服放进洗衣机里面洗,然后傻傻地在旁边等着,什么也不干,一直等到洗好之后再把衣服拿去晾。图示如下:
在这里插入图片描述

同步阻塞
张三在使用洗衣机洗衣服时,傻傻的站在洗衣机旁边等待衣服洗完,然后把衣服拿去晾。

1.1.2 同步非阻塞

同步非阻塞是指某个调用不能立刻得到结果时,该调用不会阻塞当前线程,此时当前线程可以不用等待结果就能继续往下执行其他的代码,等执行完别的代码再去检查一下之前的结果有没有返回。翻译成人话就是:当你想洗衣服时,你把衣服放进洗衣机里面洗,然后去一边看电视,每过一段时间就去看一下衣服洗好了没有,如果某一次看到衣服已经洗好了,就把洗好之后的衣服拿去晾。图示如下:
在这里插入图片描述

同步非阻塞
张三在洗衣服时,把衣服放进洗衣机之后就去看电视了,然后过一段时间再去看看衣服有没有洗好,如果洗好了,就再拿去晾。 ### 1.2 异步 **异步**是指,当前线程在发出一个调用之后,这个调用就马上返回了,但是并没有返回结果,此时当前线程可以继续去执行别的代码,在调用发出后,调用者会通过状态、通知来通知调用者其返回结果,或者是通过回调函数来处理该调用的结果。翻译成人话就是:当你想洗衣服时,你把衣服放进洗衣机里面,然后你就可以去干别的事情了,不用站在旁边干等着,也不用过一段时间就去看一下洗好了没有,而是在洗衣机设置一个通知,等洗衣机洗完衣服之后就发出“滴滴滴”的声音来通知你衣服已经洗好了,然后你再把衣服拿去晾就行了。

在这里插入图片描述

异步

2 BIO

BIO(Blocking I/O)即阻塞IO,也称为传统IO。在BIO中,对应的工作模式就是同步阻塞的I/O模式,即数据的读取和写入必须阻塞在一个线程内来等待其完成。原因就是在BIO中涉及到的 ServerSocket类的accept()方法、 InputStream类的read()方法和OutputStream类的write() 方法都是会对当前线程进行阻塞的。

2.1 BIO的缺点

在我们学习Java的网络编程的时候,教科书或者是老师教我们都是让我们先写一个服务端,然后再写一个客户端,然后将这两个小程序运行起来,这个时候就可以让这两个小程序进行通信了。但是我们并不知道的是,这种写法会有着一种很严重的缺陷。我们接下来通过代码来看一个看一下他的缺陷。
服务端代码如下:

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

public class BIOServerTest {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8089);
        System.out.println("第一步:创建端口为8090的端口成功。。。");
        while (true) {
            System.out.println("阻塞中,等待客户端来连接。。。");
            Socket client = serverSocket.accept();//阻塞1
            System.out.println("第二步:接收到端口号为:" + client.getPort() + "的客户端连接");
            InputStream inputStream = client.getInputStream();
            byte[] buffer = new byte[4096];
            System.out.println("阻塞中,等待客户端发送数据。。。");
            inputStream.read(buffer);//阻塞2
            String receivedContent = new String(buffer, "UTF-8");
            System.out.println("第三步:接收到的数据为:" + receivedContent);
        }
    }
}

客户端代码如下:

import java.io.*;
import java.net.Socket;
import java.util.Scanner;

public class Client {
    public static void main(String[] args) throws IOException {
        System.out.println("开始连接服务器。。。");
        Socket client = new Socket("127.0.0.1", 8089);
        System.out.println("所连接的服务器地址为:" + client.getRemoteSocketAddress());
        OutputStream outToServer = client.getOutputStream();
        DataOutputStream out = new DataOutputStream(outToServer);
        Scanner scanner = new Scanner(System.in);
        String str = scanner.nextLine();
        out.writeUTF(str);
        out.close();
        client.close();
    }
}

首先,我们先启动服务端,看一下控制台输出的结果。
在这里插入图片描述

刚启动服务端时,服务端控制台的输出

通过控制台的输出我们可以发现,当我们启动服务端之后,程序在执行Socket client = serverSocket.accept();//阻塞1时阻塞住了,没有继续往下执行,这时它要一直等到有客户端来连接它时,它才会继续往下执行。我们接下来启动一下客户端,去连接服务端,看看控制台的变化。
在这里插入图片描述

启动客户端时,客户端控制台的输出

在这里插入图片描述

启动客户端时,服务端控制台的输出

通过服务端的控制台输出可以发现,当我们使用客户端对服务端进行连接之后,服务端的控制台多了两行打印信息,然后在执行inputStream.read(buffer);//阻塞2时又再次阻塞住了。
我们接下来通过客户端往服务端发送一句话,然后再观察一下服务端控制台的变化。
在这里插入图片描述

客户端向服务端发送数据时,客户端控制台的输出

在这里插入图片描述

客户端向服务端发送数据时,服务端控制台的输出

可以看见,在客户端往服务端发送了一句你好,我是张三之后,服务端就在控制台输出了客户端发送给它的那句话。
我们接下来,再做一个小实验,为了方便,我们使用一个小工具SocketTest作为客户端来连接服务端。这个小工具的下载地址为:http://sockettest.sourceforge.net。接下来,我们先启动服务端,然后打开两个SocketTest的进程,接着使用第一个SocketTest进程来连接服务端,观察一下服务端的控制台输出。
在这里插入图片描述

使用第一个SocketTest进程连接服务端时,服务端控制台的输出

然后我们再用第二个SocketTest进程来连接服务端,看看服务端控制台的变化。
在这里插入图片描述

使用第二个SocketTest进程连接服务端时,服务端控制台的输出

通过观察服务端控制台的输出可以发现,这个时候,服务端控制台输出的信息是没有任何变化的。接下来我们再用第二个SocketTest进程来给服务端发送一句消息,看看服务端控制台的变化。
在这里插入图片描述

使用第二个SocketTest进程连接服务端并发送消息时,服务端控制台的输出

通过再次观察服务端控制台的输出可以发现,这个时候,服务端控制台输出的信息还是没有任何变化。然后我们再使用第一个SocketTest进程向服务端发送信息,观察一下控制台的输出。
在这里插入图片描述

使用第一个SocketTest进程连接服务端并发送消息时,服务端控制台的输出

通过观察服务端的输出可以发现,此时服务端打印的是第一个SocketTest进程所发送的信息,而第二个SocketTest进程所发送的信息并没有打印出来,服务端程序就结束了,说明服务端的连接是一直被第一个SocketTest进程所占用的。
通过上面的小实验,我们可以得出如下的结论:

  1. 如果服务端一直没有客户端来对其进行连接,服务端就会一直阻塞在serverSocket.accept()这句代码,不能继续往下执行其他的代码。
  2. 如果客户端在连接了服务端之后,如果一直不发送数据给服务端,那服务端就会一直阻塞在inputStream.read(buffer)这句代码,不能继续往下执行其他的代码。
  3. 如果某个客户端连接了服务端之后,如果一直不发送数据给服务端,那这个连接就会一直被这个客户端占用,其他的客户端无法连接此服务端,更无法向这个服务端发送数据。只有等占用连接的客户端关闭连接之后,其他的客户端才能连接上服务器。

2.2 解决BIO缺点的方案

通过上面的实验,我们知道了BIO的缺点,那我们有没有办法来解决这个缺点呢?答案是有的。那我们要如何做才能解决BIO这一个缺点呢?我们首先要明确的一点是,BIO的工作模型是同步阻塞,而在介绍同步阻塞时我们说过,此时发生阻塞的是当前线程,既然当前线程阻塞住了,那我们新开一个线程不就好了,但是如果有100个、1000个甚至更多的线程来连接服务端怎么办?新开一个线程也不够分呀。为了不让每个客户端在连接服务端时需要等待其他的客户端释放连接,我们干脆给每个Socket都分配一个线程好了,这样就可以解决BIO的阻塞问题了。具体的代码如下:

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

public class BIOServerWithThreadTest {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8089);
        System.out.println("第一步:创建端口为8090的端口成功。。。");
        System.out.println("阻塞中,等待客户端来连接。。。");
        while (true) {
            Socket client = serverSocket.accept();//阻塞1
            System.out.println("第二步:接收到端口号为:" +
             client.getPort() + "的客户端连接");
            new Thread(new MultiThreadServer(client)).start();
        }
        }

     static class MultiThreadServer implements Runnable{
        Socket csocket;
        MultiThreadServer(Socket csocket) {
            this.csocket = csocket;
        }
        @Override
        public void run() {
            try {
                InputStream inputStream = csocket.getInputStream();
                byte[] buffer = new byte[4096];
                System.out.println("阻塞中,等待客户端发送数据。。。");
                inputStream.read(buffer);//阻塞2
                String receivedContent = new String(buffer,"UTF-8");
                System.out.println("第三步:接收到的数据为:" + receivedContent);
                csocket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

然后我们再使用SocketTest来对服务端进行连接,观察一下服务端控制台的输出。
在这里插入图片描述

使用第一个客户端连接服务端时,服务端控制台的输出

在这里插入图片描述

使用第二个客户端连接服务端时,服务端控制台的输出

在这里插入图片描述

使用第三个客户端连接服务端时,服务端控制台的输出

通过观察控制台的输出,我们可以发现,每当有一个新的客户端连接到服务端时,服务端的控制台都会打印有客户端来连接的信息,并且进入等待客户端发送数据的阻塞状态。接下来,我们依次使用第三个SocketTest客户端、第一个SocketTest客户端、第二个SocketTest客户端来向服务端发送数据(这里顺序是我随意定的,实际上按照什么顺序来发都是可以的,按照这个顺序只是为了证明后面连接的客户端是可以想服务端发送数据的),然后观察服务端控制台的变化。
在这里插入图片描述

使用第三个客户端向服务端发送数据时,服务端控制台的输出

在这里插入图片描述

使用第一个客户端向服务端发送数据时,服务端控制台的输出

在这里插入图片描述

使用第二个客户端向服务端发送数据时,服务端控制台的输出

为了方便,本文仅使用三个SocketTest客户端(即三个进程)进行实验,读者们可以自行使用更多的SocketTest客户端来对服务端进行连接并进行相关的实验。

通过观察服务端的控制台信息我们可以发现,三个客户端都是可以连接上服务端的,并且三个客户端都是可以对服务端发送消息,并且没有顺序的限制。但是,我们可以思考一下,这样的做法,是不是就没有缺点了呢?
其实答案很明显,我们为每个Socket连接都分配一个线程的做法肯定是会耗费大量的计算机资源的,因为每个线程的创建和销毁都会占用一定资源和时间,并且线程之间切换的开销也很大,如果Socket连接过多的话,消耗完了所有的计算机资源之后,那肯定是会导致计算机崩溃。
那有没有别的方法来避免计算机的资源被耗尽问题并且可以解决BIO的缺点呢?答案是有的,我们可以使用线程池的方法,当我们需要创建一个新的进程时,直接从线程池获取就行了,线程池中的线程数量是可以由我们来控制的,这样就可以避免创建过多的线程而导致计算机崩溃,并且可以让线程池在程序启动时就把所有的线程创建好,避免了每当有一个新的Socket连接来连接服务端时要去创建新的线程的额外时间消耗。比如Tomcat7以前就是这么做的。当然,因为篇幅与侧重点原因,这里就不再贴出相应的代码,感兴趣的自行搜索相关资料进行学习。
虽然线程池看起来是一个比较完美的解决方案,但是,在线程池中还是有着大量的线程占用这计算机资源。既然有问题存在,那人们肯定就会寻求更加优雅的解决方案,而这个解决方案就是本文的主题——NIO

3 NIO

先回到我们之前的BIO服务端的流程,经过上面的讨论,我们知道,在BIO服务端中会有两个地方存在阻塞,我们假设一下,如果我们引入一个可以将其改成非阻塞状态,是不是可以解决BIO的三个缺点了呢?为了方便理解,我们画一张图来将其表示出来,在图的左边,是原来的BIO流程,浅绿色的方框表示非阻塞,灰色的方框就表示是会发生阻塞的方法,在图的右边,我们预想的解决方案,我们通过引入图中红色部分来将灰色的方框变成非阻塞状态,也就是浅绿色。图示如下:
在这里插入图片描述

设想的方案

然后我们可以根据我们设想的方案来写一下伪代码,看看如果accept方法和read方法不再阻塞之后,我们的代码应该怎么写,因为只是伪代码,下面给出的代码并不能运行,大家关注其思想即可。代码如下:

public class Solution {
    public static void main(String[] args) throws IOException {
        List<Socket> clientList = new ArrayList<>();
        ServerSocket serverSocket = new ServerSocket(8089);
        while (true) {
            //将serverSocket设置为非阻塞状态
            serverSocket.setNoBlocking();//伪代码,实际上并没有这个方法
            //将serverSocket设置为非阻塞状态
            Socket client = serverSocket.accept();
            //如果接收到客户端的请求了就将其添加到链表中
            if (client.isAccepted()) {//伪代码,实际上并没有这个方法
                clientList.add(client);
            }
            //遍历已经接收到的客户端请求,然后尝试读取客户端发来的数据
            for (Socket clientSocket : clientList) {
                client.setNoBlocking();//伪代码,实际上并没有这个方法
                byte[] buffer = new byte[4096];
                //尝试读取客户端发来的数据,并不一定可以读到,如果客户端发送有数据过来,count的值大于0
                int count = client.getInputStream().read(buffer);
                //如果读取到数据,则将其打印出来
                if (count > 0) {
                    String receivedContent = new String(buffer, "UTF-8");
                    System.out.println(receivedContent);
                }

            }

        }
    }
}

上面的代码的主要思想就是,如果没有阻塞方法的存在了,那么我们就可以在接受到客户端的请求之后,将其放入一共List或者数组中,然后遍历这个存放在已经连上来的客户端的List,看一下其中是否有客户端发送数据过来,如果某个客户端发送过来了,那么就将其数据打印出来。由于我们使用了一个死循环来一致检测是否有客户端来进行连接,同时在死循环中遍历检查连上来的客户端是否发送有数据,因为没有阻塞方法的存在,我们就不再需要额外再开新的线程来对每个客户端的请求进行处理了,极大的提高了代码的运行效率和计算机资源的使用率。
其实NIO就是通过上面的思想来实现的,接下来我们来看看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.List;

public class NIOServerTest {
    public static void main(String[] args) throws IOException, InterruptedException {
        List<SocketChannel> clientList = new ArrayList<>();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8089));
        //设置为非阻塞
        serverSocketChannel.configureBlocking(false);
        while (true) {
            Thread.sleep(2000);
            SocketChannel client = serverSocketChannel.accept();
            if (null == client) {
                System.out.println("没有客户端来连接。。。");
            } else {
               //设置为非阻塞
                client.configureBlocking(false);
                System.out.println(String.format("端口为:%d的客户端已经连接成功。。。", client.socket().getPort()));
                clientList.add(client);
            }
            ByteBuffer byteBuffer = ByteBuffer.allocate(2048);
            for (SocketChannel clientItem : clientList) {

                int count = clientItem.read(byteBuffer);
                if (count > 0) {
                    byteBuffer.flip();
                    byte[] buffer = new byte[byteBuffer.limit()];
                    byteBuffer.get(buffer);
                    System.out.println(String.format("接收到端口号为:%d的客户端发来的数据,值为:%s", clientItem.socket().getPort(), new String(buffer)));
                    byteBuffer.clear();
                }
            }
        }
    }
}

通过代码,我们可以看到,代码中有两行非常重要的代码,即serverSocketChannel.configureBlocking(false);和client.configureBlocking(false);这两行代码就对应着我们伪代码中的setNoBlocking方法,作用就是将serverSocketChannel和client设为非阻塞状态。其思想就是跟我们刚刚在伪代码那里讨论的一样,至此就可以通过一个线程来实现与多个客户端通信了。
我们可以再思考一下,这个方法是否已经十分完美了呢?如果我们将客户端的数量放大,放大到十万个甚至是一百万个,然后只有两个客户端发送有数据过来,这个时候会发生什么呢?因为我们不知道哪个客户端有没有发数据过来,所以我们每次都要把所有的客户端都检查一遍,如果有过多的客户端没有发送数据过来,那这个时候还是会造成大量的资源浪费。既然问题有了,那么我们还得想办法解决它。

3.1 多路复用器

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章