1. 程式人生 > >從實踐角度重新理解BIO和NIO

從實踐角度重新理解BIO和NIO

前言

這段時間自己在看一些Java中BIO和NIO之類的東西,看了很多部落格,發現各種關於NIO的概念說的天花亂墜頭頭是道,可以說是非常的完整,但是整個看下來之後,自己對NIO還是一知半解的狀態,所以這篇文章不會提到很多的概念,而是站在一個實踐的角度,寫一些我自己關於NIO的見解,站在實踐過後的高度下再回去看概念,應該對概念會有一個更好的理解。


實現一個簡易單執行緒伺服器

要講明白BIO和NIO,首先我們應該自己實現一個簡易的伺服器,不用太複雜,單執行緒即可。

為什麼使用單執行緒作為演示

因為在單執行緒環境下可以很好地對比出BIO和NIO的一個區別,當然我也會演示在實際環境中BIO的所謂一個請求對應一個執行緒的狀況。

服務端

public class Server {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024];
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            System.out.println("伺服器已啟動並監聽8080埠");
            while (true) {
                System.out.println();
                System.out.println("伺服器正在等待連線...");
                Socket socket = serverSocket.accept();
                System.out.println("伺服器已接收到連線請求...");
                System.out.println();
                System.out.println("伺服器正在等待資料...");
                socket.getInputStream().read(buffer);
                System.out.println("伺服器已經接收到資料");
                System.out.println();
                String content = new String(buffer);
                System.out.println("接收到的資料:" + content);
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

客戶端

public class Consumer {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1",8080);
            socket.getOutputStream().write("向伺服器發資料".getBytes());
            socket.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

程式碼解析

我們首先建立了一個服務端類,在類中實現例項化了一個SocketServer並綁定了8080埠。之後呼叫accept方法來接收連線請求,並且呼叫read方法來接收客戶端傳送的資料。最後將接收到的資料列印。

完成了服務端的設計後,我們來實現一個客戶端,首先例項化Socket物件,並且繫結ip為127.0.0.1(本機),埠號為8080,呼叫write方法向伺服器傳送資料。

執行結果

當我們啟動伺服器,但客戶端還沒有向伺服器發起連線時,控制檯結果如下:

當客戶端啟動並向伺服器傳送資料後,控制檯結果如下:

結論

從上面的執行結果,首先我們至少可以看到,在伺服器啟動後,客戶端還沒有連線伺服器時,伺服器由於呼叫了accept方法,將一直阻塞,直到有客戶端請求連線伺服器。


對客戶端功能進行擴充套件

在上文中,我們實現的客戶端的邏輯主要是,建立Socket --> 連線伺服器 --> 傳送資料,我們的資料是在連線伺服器之後就立即傳送的,現在我們來對客戶端進行一次擴充套件,當我們連線伺服器後,不立即傳送資料,而是等待控制檯手動輸入資料後,再發送給服務端。(服務端程式碼保持不變)

程式碼

public class Consumer {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1",8080);
            String message = null;
            Scanner sc = new Scanner(System.in);
            message = sc.next();
            socket.getOutputStream().write(message.getBytes());
            socket.close();
            sc.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

測試

當服務端啟動,客戶端還沒有請求連線伺服器時,控制檯結果如下:

當服務端啟動,客戶端連線服務端,但沒有傳送資料時,控制檯結果如下:

當服務端啟動,客戶端連線服務端,並且傳送資料時,控制檯結果如下:

結論

從上文的執行結果中我們可以看到,伺服器端在啟動後,首先需要等待客戶端的連線請求(第一次阻塞),如果沒有客戶端連線,服務端將一直阻塞等待,然後當客戶端連線後,伺服器會等待客戶端傳送資料(第二次阻塞),如果客戶端沒有傳送資料,那麼服務端將會一直阻塞等待客戶端傳送資料。

服務端從啟動到收到客戶端資料的這個過程,將會有兩次阻塞的過程。這就是BIO的非常重要的一個特點,BIO會產生兩次阻塞,第一次在等待連線時阻塞,第二次在等待資料時阻塞。


BIO

在單執行緒條件下BIO的弱點

在上文中,我們實現了一個簡易的伺服器,這個簡易的伺服器是以單執行緒執行的,其實我們不難看出,當我們的伺服器接收到一個連線後,並且沒有接收到客戶端傳送的資料時,是會阻塞在read()方法中的,那麼此時如果再來一個客戶端的請求,服務端是無法進行響應的。換言之,在不考慮多執行緒的情況下,BIO是無法處理多個客戶端請求的。

BIO如何處理併發

在剛才的伺服器實現中,我們實現的是單執行緒版的BIO伺服器,不難看出,單執行緒版的BIO並不能處理多個客戶端的請求,那麼如何能使BIO處理多個客戶端請求呢。

其實不難想到,我們只需要在每一個連線請求到來時,建立一個執行緒去執行這個連線請求,就可以在BIO中處理多個客戶端請求了,這也就是為什麼BIO的其中一條概念是伺服器實現模式為一個連線一個執行緒,即客戶端有連線請求時伺服器端就需要啟動一個執行緒進行處理。

多執行緒BIO伺服器簡易實現

public class Server {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024];
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            System.out.println("伺服器已啟動並監聽8080埠");
            while (true) {
                System.out.println();
                System.out.println("伺服器正在等待連線...");
                Socket socket = serverSocket.accept();
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("伺服器已接收到連線請求...");
                        System.out.println();
                        System.out.println("伺服器正在等待資料...");
                        try {
                            socket.getInputStream().read(buffer);
                        } catch (IOException e) {
                            // TODO Auto-generated catch block
                            e.printStackTrace();
                        }
                        System.out.println("伺服器已經接收到資料");
                        System.out.println();
                        String content = new String(buffer);
                        System.out.println("接收到的資料:" + content);
                    }
                }).start();

            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

執行結果

 

很明顯,現在我們的伺服器的狀態就是一個執行緒對應一個請求,換言之,伺服器為每一個連線請求都建立了一個執行緒來處理。

多執行緒BIO伺服器的弊端

多執行緒BIO伺服器雖然解決了單執行緒BIO無法處理併發的弱點,但是也帶來一個問題:如果有大量的請求連線到我們的伺服器上,但是卻不傳送訊息,那麼我們的伺服器也會為這些不傳送訊息的請求建立一個單獨的執行緒,那麼如果連線數少還好,連線數一多就會對服務端造成極大的壓力。所以如果這種不活躍的執行緒比較多,我們應該採取單執行緒的一個解決方案,但是單執行緒又無法處理併發,這就陷入了一種很矛盾的狀態,於是就有了NIO。


NIO

NIO的引入

我們先來看看單執行緒模式下BIO伺服器的程式碼,其實NIO需要解決的最根本的問題就是存在於BIO中的兩個阻塞,分別是等待連線時的阻塞和等待資料時的阻塞。

public class Server {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024];
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            System.out.println("伺服器已啟動並監聽8080埠");
            while (true) {
                System.out.println();
                System.out.println("伺服器正在等待連線...");
                //阻塞1:等待連線時阻塞
                Socket socket = serverSocket.accept();
                System.out.println("伺服器已接收到連線請求...");
                System.out.println();
                System.out.println("伺服器正在等待資料...");
                //阻塞2:等待資料時阻塞
                socket.getInputStream().read(buffer);
                System.out.println("伺服器已經接收到資料");
                System.out.println();
                String content = new String(buffer);
                System.out.println("接收到的資料:" + content);
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

我們需要再老調重談的一點是,如果單執行緒伺服器在等待資料時阻塞,那麼第二個連線請求到來時,伺服器是無法響應的。如果是多執行緒伺服器,那麼又會有為大量空閒請求產生新執行緒從而造成執行緒佔用系統資源,執行緒浪費的情況。

那麼我們的問題就轉移到,如何讓單執行緒伺服器在等待客戶端資料到來時,依舊可以接收新的客戶端連線請求。

模擬NIO解決方案

如果要解決上文中提到的單執行緒伺服器接收資料時阻塞,而無法接收新請求的問題,那麼其實可以讓伺服器在等待資料時不進入阻塞狀態,問題不就迎刃而解了嗎?

第一種解決方案(等待連線時和等待資料時不阻塞)

public class Server {
    public static void main(String[] args) throws InterruptedException {
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        try {
            //Java為非阻塞設定的類
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.bind(new InetSocketAddress(8080));
            //設定為非阻塞
            serverSocketChannel.configureBlocking(false);
            while(true) {
                SocketChannel socketChannel = serverSocketChannel.accept();
                if(socketChannel==null) {
                    //表示沒人連線
                    System.out.println("正在等待客戶端請求連線...");
                    Thread.sleep(5000);
                }else {
                    System.out.println("當前接收到客戶端請求連線...");
                }
                if(socketChannel!=null) {
                    //設定為非阻塞
                    socketChannel.configureBlocking(false);
                    byteBuffer.flip();//切換模式  寫-->讀
                    int effective = socketChannel.read(byteBuffer);
                    if(effective!=0) {
                        String content = Charset.forName("utf-8").decode(byteBuffer).toString();
                        System.out.println(content);
                    }else {
                        System.out.println("當前未收到客戶端訊息");
                    }
                }
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

執行結果

 

不難看出,在這種解決方案下,雖然在接收客戶端訊息時不會阻塞,但是又開始重新接收伺服器請求,使用者根本來不及輸入訊息,伺服器就轉向接收別的客戶端請求了,換言之,伺服器弄丟了當前客戶端的請求。

解決方案二(快取Socket,輪詢資料是否準備好)

public class Server {
    public static void main(String[] args) throws InterruptedException {
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        List<SocketChannel> socketList = new ArrayList<SocketChannel>();
        try {
            //Java為非阻塞設定的類
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.bind(new InetSocketAddress(8080));
            //設定為非阻塞
            serverSocketChannel.configureBlocking(false);
            while(true) {
                SocketChannel socketChannel = serverSocketChannel.accept();
                if(socketChannel==null) {
                    //表示沒人連線
                    System.out.println("正在等待客戶端請求連線...");
                    Thread.sleep(5000);
                }else {
                    System.out.println("當前接收到客戶端請求連線...");
                    socketList.add(socketChannel);
                }
                for(SocketChannel socket:socketList) {
                    socket.configureBlocking(false);
                    int effective = socket.read(byteBuffer);
                    if(effective!=0) {
                        byteBuffer.flip();//切換模式  寫-->讀
                        String content = Charset.forName("UTF-8").decode(byteBuffer).toString();
                        System.out.println("接收到訊息:"+content);
                        byteBuffer.clear();
                    }else {
                        System.out.println("當前未收到客戶端訊息");
                    }
                }
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

 

執行結果

程式碼解析

在解決方案一中,我們採用了非阻塞方式,但是發現一旦非阻塞,等待客戶端傳送訊息時就不會再阻塞了,而是直接重新去獲取新客戶端的連線請求,這就會造成客戶端連線丟失,而在解決方案二中,我們將連線儲存在一個list集合中,每次等待客戶端訊息時都去輪詢,看看訊息是否準備好,如果準備好則直接列印訊息。

可以看到,從頭到尾我們一直沒有開啟第二個執行緒,而是一直採用單執行緒來處理多個客戶端的連線,這樣的一個模式可以很完美地解決BIO在單執行緒模式下無法處理多客戶端請求的問題,並且解決了非阻塞狀態下連線丟失的問題。

存在的問題(解決方案二)

從剛才的執行結果中其實可以看出,訊息沒有丟失,程式也沒有阻塞。但是,在接收訊息的方式上可能有些許不妥,我們採用了一個輪詢的方式來接收訊息,每次都輪詢所有的連線,看訊息是否準備好,測試用例中只是三個連線,所以看不出什麼問題來,但是我們假設有1000萬連線,甚至更多,採用這種輪詢的方式效率是極低的。

另外,1000萬連線中,我們可能只會有100萬會有訊息,剩下的900萬並不會傳送任何訊息,那麼這些連線程式依舊要每次都去輪詢,這顯然是不合適的。

真實NIO中如何解決

在真實NIO中,並不會在Java層上來進行一個輪詢,而是將輪詢的這個步驟交給我們的作業系統來進行,他將輪詢的那部分程式碼改為作業系統級別的系統呼叫(select函式,在linux環境中為epoll),在作業系統級別上呼叫select函式,主動地去感知有資料的socket。


關於使用select/epoll和直接在應用層做輪詢的區別

我們在之前實現了一個使用Java做多個客戶端連線輪詢的邏輯,但是在真正的NIO原始碼中其實並不是這麼實現的,NIO使用了作業系統底層的輪詢系統呼叫 select/epoll(windows:select,linux:epoll),那麼為什麼不直接實現而要去呼叫系統來做輪詢呢?

select底層邏輯

假設有A、B、C、D、E五個連線同時連線伺服器,那麼根據我們上文中的設計,程式將會遍歷這五個連線,輪詢每個連線,獲取各自資料準備情況,那麼和我們自己寫的程式有什麼區別呢?

首先,我們寫的Java程式其本質在輪詢每個Socket的時候也需要去呼叫系統函式,那麼輪詢一次呼叫一次,會造成不必要的上下文切換開銷。

而Select會將五個請求從使用者態空間全量複製一份到核心態空間,在核心態空間來判斷每個請求是否準備好資料,完全避免頻繁的上下文切換。所以效率是比我們直接在應用層寫輪詢要高的。

如果select沒有查詢到到有資料的請求,那麼將會一直阻塞(是的,select是一個阻塞函式)。如果有一個或者多個請求已經準備好資料了,那麼select將會先將有資料的檔案描述符置位,然後select返回。返回後通過遍歷檢視哪個請求有資料。

select的缺點:

  • 底層儲存依賴bitmap,處理的請求是有上限的,為1024。
  • 檔案描述符是會置位的,所以如果當被置位的檔案描述符需要重新使用時,是需要重新賦空值的。
  • fd(檔案描述符)從使用者態拷貝到核心態仍然有一筆開銷。
  • select返回後還要再次遍歷,來獲知是哪一個請求有資料。

poll函式底層邏輯

poll的工作原理和select很像,先來看一段poll內部使用的一個結構體。

struct pollfd{
    int fd;
    short events;
    short revents;
}

 

poll同樣會將所有的請求拷貝到核心態,和select一樣,poll同樣是一個阻塞函式,當一個或多個請求有資料的時候,也同樣會進行置位,但是它置位的是結構體pollfd中的events或者revents置位,而不是對fd本身進行置位,所以在下一次使用的時候不需要再進行重新賦空值的操作。poll內部儲存不依賴bitmap,而是使用pollfd陣列的這樣一個數據結構,陣列的大小肯定是大於1024的。解決了select 1、2兩點的缺點。

epoll

epoll是最新的一種多路IO複用的函式。這裡只說說它的特點。

epoll和上述兩個函式最大的不同是,它的fd是共享在使用者態和核心態之間的,所以可以不必進行從使用者態到核心態的一個拷貝,這樣可以節約系統資源;另外,在select和poll中,如果某個請求的資料已經準備好,它們會將所有的請求都返回,供程式去遍歷檢視哪個請求存在資料,但是epoll只會返回存在資料的請求,這是因為epoll在發現某個請求存在資料時,首先會進行一個重排操作,將所有有資料的fd放到最前面的位置,然後返回(返回值為存在資料請求的個數N),那麼我們的上層程式就可以不必將所有請求都輪詢,而是直接遍歷epoll返回的前N個請求,這些請求都是有資料的請求。


Java中BIO和NIO的概念

通常一些文章都是在開頭放上概念,但是我這次選擇將概念放在結尾,因為通過上面的實操,相信大家對Java中BIO和NIO都有了自己的一些理解,這時候再來看概念應該會更好理解一些了。

概念整理於:

https://blog.csdn.net/guanghuichenshao/article/details/79375967

 

先來個例子理解一下概念,以銀行取款為例:

  • 同步 : 自己親自出馬持銀行卡到銀行取錢(使用同步IO時,Java自己處理IO讀寫)。
  • 非同步 : 委託一小弟拿銀行卡到銀行取錢,然後給你(使用非同步IO時,Java將IO讀寫委託給OS處理,需要將資料緩衝區地址和大小傳給OS(銀行卡和密碼),OS需要支援非同步IO操作API)。
  • 阻塞 : ATM排隊取款,你只能等待(使用阻塞IO時,Java呼叫會一直阻塞到讀寫完成才返回)。
  • 非阻塞 : 櫃檯取款,取個號,然後坐在椅子上做其它事,等號廣播會通知你辦理,沒到號你就不能去,你可以不斷問大堂經理排到了沒有,大堂經理如果說還沒到你就不能去(使用非阻塞IO時,如果不能讀寫Java呼叫會馬上返回,當IO事件分發器會通知可讀寫時再繼續進行讀寫,不斷迴圈直到讀寫完成)。

 

Java對BIO、NIO的支援:

  • Java BIO (blocking I/O): 同步並阻塞,伺服器實現模式為一個連線一個執行緒,即客戶端有連線請求時伺服器端就需要啟動一個執行緒進行處理,如果這個連線不做任何事情會造成不必要的執行緒開銷,當然可以通過執行緒池機制改善。
  • Java NIO (non-blocking I/O): 同步非阻塞,伺服器實現模式為一個請求一個執行緒,即客戶端傳送的連線請求都會註冊到多路複用器上,多路複用器輪詢到連線有I/O請求時才啟動一個執行緒進行處理。

 

BIO、NIO適用場景分析:

  • BIO方式適用於連線數目比較小且固定的架構,這種方式對伺服器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,但程式直觀簡單易理解。
  • NIO方式適用於連線數目多且連線比較短(輕操作)的架構,比如聊天伺服器,併發侷限於應用中,程式設計比較複雜,JDK1.4開始支援。

結語

本文介紹了一些關於JavaBIO和NIO從自己實操的角度上的一些理解,我個人認為這樣去理解BIO和NIO會比光看概念會有更深的理解,也希望各位同學可以自己去敲一遍,通過程式的執行結果得出自己對JavaBIO和NIO的理解。

歡迎訪問個人部落格:http://blog.objectspace.cn/