Java面試常考的 BIO,NIO,AIO 總結

  • 2019 年 10 月 7 日
  • 筆記

作者:JavaGuide

文章來源:JavaGuide

熟練掌握 BIO,NIO,AIO 的基本概念以及一些常見問題是你準備面試的過程中不可或缺的一部分,另外這些知識點也是你學習 Netty 的基礎。

目錄:

  • 1. BIO (Blocking I/O)
    • 1.1 傳統 BIO
    • 1.2 偽異步 IO
    • 1.3 代碼示例
    • 1.4 總結
  • 2. NIO (New I/O)
    • 2.1 NIO 簡介
    • 2.2 NIO的特性/NIO與IO區別
      • 1)Non-blocking IO(非阻塞IO)
      • 2)Buffer(緩衝區)
      • 3)Channel (通道)
      • 4)Selectors(選擇器)
    • 2.3 NIO 讀數據和寫數據方式
    • 2.4 NIO核心組件簡單介紹
    • 2.5 代碼示例
  • 3. AIO (Asynchronous I/O)
  • 參考

BIO,NIO,AIO 總結

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

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

同步與異步

  • 同步: 同步就是發起一個調用後,被調用者未處理完請求之前,調用不返回。
  • 異步: 異步就是發起一個調用後,立刻得到被調用者的回應表示已接收到請求,但是被調用者並沒有返回結果,此時我們可以處理其他的請求,被調用者通常依靠事件,回調等機制來通知調用者其返回結果。

同步和異步的區別最大在於異步的話調用者不需要等待處理結果,被調用者會通過回調等機制來通知調用者其返回結果。

阻塞和非阻塞

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

那麼同步阻塞、同步非阻塞和異步非阻塞又代表什麼意思呢?

舉個生活中簡單的例子,你媽媽讓你燒水,小時候你比較笨啊,在哪裡傻等着水開(同步阻塞)。等你稍微再長大一點,你知道每次燒水的空隙可以去干點其他事,然後只需要時不時來看看水開了沒有(同步非阻塞)。後來,你們家用上了水開了會發出聲音的壺,這樣你就只需要聽到響聲後就知道水開了,在這期間你可以隨便干自己的事情,你需要去倒水了(異步非阻塞)。

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)Selectors(選擇器)

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,不過又放棄了。