1. 程式人生 > 實用技巧 >BIO,NIO,AIO 總結

BIO,NIO,AIO 總結

BIO,NIO,AIO 總結

Java 中的 BIO、NIO和 AIO 理解為是 Java 語言對作業系統的各種 IO 模型的封裝。程式設計師在使用這些 API 的時候,不需要關心作業系統層面的知識,也不需要根據不同作業系統編寫不同的程式碼。只需要使用Java的API就可以了。

在講 BIO,NIO,AIO 之前先來回顧一下這樣幾個概念:同步與非同步,阻塞與非阻塞。

關於同步和非同步的概念解讀困擾著很多程式設計師,大部分的解讀都會帶有自己的一點偏見。參考了Stackoverflow相關問題後對原有答案進行了進一步完善:

When you execute something synchronously, you wait for it to finish before moving on to another task. When you execute something asynchronously, you can move on to another task before it finishes.

當你同步執行某項任務時,你需要等待其完成才能繼續執行其他任務。當你非同步執行某些操作時,你可以在完成另一個任務之前繼續進行。

  • 同步:兩個同步任務相互依賴,並且一個任務必須以依賴於另一任務的某種方式執行。 比如在A->B事件模型中,你需要先完成 A 才能執行B。 再換句話說,同步呼叫中被呼叫者未處理完請求之前,呼叫不返回,呼叫者會一直等待結果的返回。
  • 非同步: 兩個非同步的任務完全獨立的,一方的執行不需要等待另外一方的執行。再換句話說,非同步呼叫種一呼叫就返回結果不需要等待結果返回,當結果返回的時候通過回撥函式或者其他方式拿著結果再做相關事情,

阻塞和非阻塞

  • 阻塞:阻塞就是發起一個請求,呼叫者一直等待請求結果返回,也就是當前執行緒會被掛起,無法從事其他任務,只有當條件就緒才能繼續。
  • 非阻塞:非阻塞就是發起一個請求,呼叫者不用一直等著結果返回,可以先去幹其他事情。

如何區分 “同步/非同步 ”和 “阻塞/非阻塞” 呢?

同步/非同步是從行為角度描述事物的,而阻塞和非阻塞描述的當前事物的狀態(等待呼叫結果時的狀態)。

1. BIO (Blocking I/O)

同步阻塞I/O模式,資料的讀取寫入必須阻塞在一個執行緒內等待其完成。

1.1 傳統 BIO

BIO通訊(一請求一應答)模型圖如下(圖源網路,原出處不明):

採用BIO 通訊模型的服務端,通常由一個獨立的 Acceptor 執行緒負責監聽客戶端的連線。我們一般通過在while(true)迴圈中服務端會呼叫accept()方法等待接收客戶端的連線的方式監聽請求,請求一旦接收到一個連線請求,就可以建立通訊套接字在這個通訊套接字上進行讀寫操作,此時不能再接收其他客戶端連線請求,只能等待同當前連線的客戶端的操作執行完成, 不過可以通過多執行緒來支援多個客戶端的連線,如上圖所示。

如果要讓BIO 通訊模型能夠同時處理多個客戶端請求,就必須使用多執行緒(主要原因是socket.accept()socket.read()socket.write()涉及的三個主要函式都是同步阻塞的),也就是說它在接收到客戶端連線請求之後為每個客戶端建立一個新的執行緒進行鏈路處理,處理完成之後,通過輸出流返回應答給客戶端,執行緒銷燬。這就是典型的一請求一應答通訊模型。我們可以設想一下如果這個連線不做任何事情的話就會造成不必要的執行緒開銷,不過可以通過執行緒池機制改善,執行緒池還可以讓執行緒的建立和回收成本相對較低。使用FixedThreadPool可以有效的控制了執行緒的最大數量,保證了系統有限的資源的控制,實現了N(客戶端請求數量):M(處理客戶端請求的執行緒數量)的偽非同步I/O模型(N 可以遠遠大於 M),下面一節"偽非同步 BIO"中會詳細介紹到。

我們再設想一下當客戶端併發訪問量增加後這種模型會出現什麼問題?

在 Java 虛擬機器中,執行緒是寶貴的資源,執行緒的建立和銷燬成本很高,除此之外,執行緒的切換成本也是很高的。尤其在 Linux 這樣的作業系統中,執行緒本質上就是一個程序,建立和銷燬執行緒都是重量級的系統函式。如果併發訪問量增加會導致執行緒數急劇膨脹可能會導致執行緒堆疊溢位、建立新執行緒失敗等問題,最終導致程序宕機或者僵死,不能對外提供服務。

1.2 偽非同步 IO

為了解決同步阻塞I/O面臨的一個鏈路需要一個執行緒處理的問題,後來有人對它的執行緒模型進行了優化一一一後端通過一個執行緒池來處理多個客戶端的請求接入,形成客戶端個數M:執行緒池最大執行緒數N的比例關係,其中M可以遠遠大於N.通過執行緒池可以靈活地調配執行緒資源,設定執行緒的最大值,防止由於海量併發接入導致執行緒耗盡。

偽非同步IO模型圖(圖源網路,原出處不明):

採用執行緒池和任務佇列可以實現一種叫做偽非同步的 I/O 通訊框架,它的模型圖如上圖所示。當有新的客戶端接入時,將客戶端的 Socket 封裝成一個Task(該任務實現java.lang.Runnable介面)投遞到後端的執行緒池中進行處理,JDK 的執行緒池維護一個訊息佇列和 N 個活躍執行緒,對訊息佇列中的任務進行處理。由於執行緒池可以設定訊息佇列的大小和最大執行緒數,因此,它的資源佔用是可控的,無論多少個客戶端併發訪問,都不會導致資源的耗盡和宕機。

偽非同步I/O通訊框架採用了執行緒池實現,因此避免了為每個請求都建立一個獨立執行緒造成的執行緒資源耗盡問題。不過因為它的底層仍然是同步阻塞的BIO模型,因此無法從根本上解決問題。

1.3 程式碼示例

下面程式碼中演示了BIO通訊(一請求一應答)模型。我們會在客戶端建立多個執行緒依次連線服務端並向其傳送"當前時間+:hello world",服務端會為每個客戶端執行緒建立一個執行緒來處理。程式碼示例出自閃電俠的部落格,原地址如下:

https://www.jianshu.com/p/a4e03835921a

客戶端

/**
 * 
 * @author 閃電俠
 * @date 2018年10月14日
 * @Description:客戶端
 */
public class IOClient {

  public static void main(String[] args) {
    // TODO 建立多個執行緒,模擬多個客戶端連線服務端
    new Thread(() -> {
      try {
        Socket socket = new Socket("127.0.0.1", 3333);
        while (true) {
          try {
            socket.getOutputStream().write((new Date() + ": hello world").getBytes());
            Thread.sleep(2000);
          } catch (Exception e) {
          }
        }
      } catch (IOException e) {
      }
    }).start();

  }

}

服務端

/**
 * @author 閃電俠
 * @date 2018年10月14日
 * @Description: 服務端
 */
public class IOServer {

  public static void main(String[] args) throws IOException {
    // TODO 服務端處理客戶端連線請求
    ServerSocket serverSocket = new ServerSocket(3333);

    // 接收到客戶端連線請求之後為每個客戶端建立一個新的執行緒進行鏈路處理
    new Thread(() -> {
      while (true) {
        try {
          // 阻塞方法獲取新的連線
          Socket socket = serverSocket.accept();

          // 每一個新的連線都建立一個執行緒,負責讀取資料
          new Thread(() -> {
            try {
              int len;
              byte[] data = new byte[1024];
              InputStream inputStream = socket.getInputStream();
              // 按位元組流方式讀取資料
              while ((len = inputStream.read(data)) != -1) {
                System.out.println(new String(data, 0, len));
              }
            } catch (IOException e) {
            }
          }).start();

        } catch (IOException e) {
        }

      }
    }).start();

  }

}

1.4 總結

在活動連線數不是特別高(小於單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連線專注於自己的 I/O 並且程式設計模型簡單,也不用過多考慮系統的過載、限流等問題。執行緒池本身就是一個天然的漏斗,可以緩衝一些系統處理不了的連線或請求。但是,當面對十萬甚至百萬級連線的時候,傳統的 BIO 模型是無能為力的。因此,我們需要一種更高效的 I/O 處理模型來應對更高的併發量。

2. NIO (New I/O)

2.1 NIO 簡介

NIO是一種同步非阻塞的I/O模型,在Java 1.4 中引入了 NIO 框架,對應 java.nio 包,提供了 Channel , Selector,Buffer等抽象。

NIO中的N可以理解為Non-blocking,不單純是New。它支援面向緩衝的,基於通道的I/O操作方法。 NIO提供了與傳統BIO模型中的SocketServerSocket相對應的SocketChannelServerSocketChannel兩種不同的套接字通道實現,兩種通道都支援阻塞和非阻塞兩種模式。阻塞模式使用就像傳統中的支援一樣,比較簡單,但是效能和可靠性都不好;非阻塞模式正好與之相反。對於低負載、低併發的應用程式,可以使用同步阻塞I/O來提升開發速率和更好的維護性;對於高負載、高併發的(網路)應用,應使用 NIO 的非阻塞模式來開發。

2.2 NIO的特性/NIO與IO區別

如果是在面試中回答這個問題,我覺得首先肯定要從 NIO 流是非阻塞 IO 而 IO 流是阻塞 IO 說起。然後,可以從 NIO 的3個核心元件/特性為 NIO 帶來的一些改進來分析。如果,你把這些都回答上了我覺得你對於 NIO 就有了更為深入一點的認識,面試官問到你這個問題,你也能很輕鬆的回答上來了。

1)Non-blocking IO(非阻塞IO)

IO流是阻塞的,NIO流是不阻塞的。

Java NIO使我們可以進行非阻塞IO操作。比如說,單執行緒中從通道讀取資料到buffer,同時可以繼續做別的事情,當資料讀取到buffer中後,執行緒再繼續處理資料。寫資料也是一樣的。另外,非阻塞寫也是如此。一個執行緒請求寫入一些資料到某通道,但不需要等待它完全寫入,這個執行緒同時可以去做別的事情。

Java IO的各種流是阻塞的。這意味著,當一個執行緒呼叫read()write()時,該執行緒被阻塞,直到有一些資料被讀取,或資料完全寫入。該執行緒在此期間不能再幹任何事情了

2)Buffer(緩衝區)

IO 面向流(Stream oriented),而 NIO 面向緩衝區(Buffer oriented)。

Buffer是一個物件,它包含一些要寫入或者要讀出的資料。在NIO類庫中加入Buffer物件,體現了新庫與原I/O的一個重要區別。在面向流的I/O中·可以將資料直接寫入或者將資料直接讀到 Stream 物件中。雖然 Stream 中也有 Buffer 開頭的擴充套件類,但只是流的包裝類,還是從流讀到緩衝區,而 NIO 卻是直接讀到 Buffer 中進行操作。

在NIO厙中,所有資料都是用緩衝區處理的。在讀取資料時,它是直接讀到緩衝區中的; 在寫入資料時,寫入到緩衝區中。任何時候訪問NIO中的資料,都是通過緩衝區進行操作。

最常用的緩衝區是 ByteBuffer,一個 ByteBuffer 提供了一組功能用於操作 byte 陣列。除了ByteBuffer,還有其他的一些緩衝區,事實上,每一種Java基本型別(除了Boolean型別)都對應有一種緩衝區。

3)Channel (通道)

NIO 通過Channel(通道) 進行讀寫。

通道是雙向的,可讀也可寫,而流的讀寫是單向的。無論讀寫,通道只能和Buffer互動。因為 Buffer,通道可以非同步地讀寫。

4)Selector (選擇器)

NIO有選擇器,而IO沒有。

選擇器用於使用單個執行緒處理多個通道。因此,它需要較少的執行緒來處理這些通道。執行緒之間的切換對於作業系統來說是昂貴的。 因此,為了提高系統效率選擇器是有用的。

2.3 NIO 讀資料和寫資料方式

通常來說NIO中的所有IO都是從 Channel(通道) 開始的。

  • 從通道進行資料讀取 :建立一個緩衝區,然後請求通道讀取資料。
  • 從通道進行資料寫入 :建立一個緩衝區,填充資料,並要求通道寫入資料。

資料讀取和寫入操作圖示:

2.4 NIO核心元件簡單介紹

NIO 包含下面幾個核心的元件:

  • Channel(通道)
  • Buffer(緩衝區)
  • Selector(選擇器)

整個NIO體系包含的類遠遠不止這三個,只能說這三個是NIO體系的“核心API”。我們上面已經對這三個概念進行了基本的闡述,這裡就不多做解釋了。

2.5 程式碼示例

程式碼示例出自閃電俠的部落格,原地址如下:

https://www.jianshu.com/p/a4e03835921a

客戶端 IOClient.java 的程式碼不變,我們對服務端使用 NIO 進行改造。以下程式碼較多而且邏輯比較複雜,大家看看就好。

/**
 * 
 * @author 閃電俠
 * @date 2019年2月21日
 * @Description: NIO 改造後的服務端
 */
public class NIOServer {
  public static void main(String[] args) throws IOException {
    // 1. serverSelector負責輪詢是否有新的連線,服務端監測到新的連線之後,不再建立一個新的執行緒,
    // 而是直接將新連線繫結到clientSelector上,這樣就不用 IO 模型中 1w 個 while 迴圈在死等
    Selector serverSelector = Selector.open();
    // 2. clientSelector負責輪詢連線是否有資料可讀
    Selector clientSelector = Selector.open();

    new Thread(() -> {
      try {
        // 對應IO程式設計中服務端啟動
        ServerSocketChannel listenerChannel = ServerSocketChannel.open();
        listenerChannel.socket().bind(new InetSocketAddress(3333));
        listenerChannel.configureBlocking(false);
        listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);

        while (true) {
          // 監測是否有新的連線,這裡的1指的是阻塞的時間為 1ms
          if (serverSelector.select(1) > 0) {
            Set<SelectionKey> set = serverSelector.selectedKeys();
            Iterator<SelectionKey> keyIterator = set.iterator();

            while (keyIterator.hasNext()) {
              SelectionKey key = keyIterator.next();

              if (key.isAcceptable()) {
                try {
                  // (1) 每來一個新連線,不需要建立一個執行緒,而是直接註冊到clientSelector
                  SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
                  clientChannel.configureBlocking(false);
                  clientChannel.register(clientSelector, SelectionKey.OP_READ);
                } finally {
                  keyIterator.remove();
                }
              }

            }
          }
        }
      } catch (IOException ignored) {
      }
    }).start();
    new Thread(() -> {
      try {
        while (true) {
          // (2) 批量輪詢是否有哪些連線有資料可讀,這裡的1指的是阻塞的時間為 1ms
          if (clientSelector.select(1) > 0) {
            Set<SelectionKey> set = clientSelector.selectedKeys();
            Iterator<SelectionKey> keyIterator = set.iterator();

            while (keyIterator.hasNext()) {
              SelectionKey key = keyIterator.next();

              if (key.isReadable()) {
                try {
                  SocketChannel clientChannel = (SocketChannel) key.channel();
                  ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                  // (3) 面向 Buffer
                  clientChannel.read(byteBuffer);
                  byteBuffer.flip();
                  System.out.println(
                      Charset.defaultCharset().newDecoder().decode(byteBuffer).toString());
                } finally {
                  keyIterator.remove();
                  key.interestOps(SelectionKey.OP_READ);
                }
              }

            }
          }
        }
      } catch (IOException ignored) {
      }
    }).start();

  }
}

為什麼大家都不願意用 JDK 原生 NIO 進行開發呢?從上面的程式碼中大家都可以看出來,是真的難用!除了程式設計複雜、程式設計模型難之外,它還有以下讓人詬病的問題:

  • JDK 的 NIO 底層由 epoll 實現,該實現飽受詬病的空輪詢 bug 會導致 cpu 飆升 100%
  • 專案龐大之後,自行實現的 NIO 很容易出現各類 bug,維護成本較高,上面這一坨程式碼我都不能保證沒有 bug

Netty 的出現很大程度上改善了 JDK 原生 NIO 所存在的一些讓人難以忍受的問題。

3. AIO (Asynchronous I/O)

AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改進版 NIO 2,它是非同步非阻塞的IO模型。非同步 IO 是基於事件和回撥機制實現的,也就是應用操作之後會直接返回,不會堵塞在那裡,當後臺處理完成,作業系統會通知相應的執行緒進行後續的操作。

AIO 是非同步IO的縮寫,雖然 NIO 在網路操作中,提供了非阻塞的方法,但是 NIO 的 IO 行為還是同步的。對於 NIO 來說,我們的業務執行緒是在 IO 操作準備好時,得到通知,接著就由這個執行緒自行進行 IO 操作,IO操作本身是同步的。(除了 AIO 其他的 IO 型別都是同步的,這一點可以從底層IO執行緒模型解釋,推薦一篇文章:《漫話:如何給女朋友解釋什麼是Linux的五種IO模型?》

查閱網上相關資料,我發現就目前來說 AIO 的應用還不是很廣泛,Netty 之前也嘗試使用過 AIO,不過又放棄了。

參考

作者:Snailclimb
連結:BIO,NIO,AIO 總結
來源:github