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 多路複用器

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