Java IO 和 NIO

weixin_34208283發表於2018-11-26

同步和非同步、阻塞和非阻塞

  • 同步 (synchronous) 是一種可靠的執行機制,當我們進行同步操作時,後續操作是等待當前呼叫返回,才會進行下一步操作。
  • 非同步 (asynchronous) 相反於同步操作,執行非同步操作時,其他操作不需要等待當前呼叫返回,通常依靠事件、回撥機制來實現任務間的次序關係。
  • 阻塞 (blocking) 操作被執行時,當前執行緒會處於阻塞狀態,無法執行其他任務,只有當條件就緒,阻塞操作完成,執行緒才會繼續往下執行。典型操作比如,ServerSocket 新連線建立完畢後、資料寫入後、讀取完成後,執行緒才會繼續往下執行。
  • 非阻塞 (non-blocking) 是不需要等待條件就緒,只要執行操作,就會立即返回結果。比如 IO 操作還未結束,就可以先讀取部分資料,直接返回,相應的 IO 操作繼續在後臺進行。

不能一概認為同步、阻塞就是低效的。不同的場景有不同的功能需求。

Java 提供了哪些 IO 方式

Java IO 方式很多,基於不同的 IO 抽象模型和實現方式,可以簡單區分。

  • 傳統 IO

指的是 java.io 包和 java.net 包下的部分網路 API。它們基於流模型實現,提供了我們最熟知的一些 IO 功能,比如 File 抽象類、RandomAccessFile 類、輸入輸出流、Socket、ServerSocket、HttpURLConnection 等類。這些類的互動特點是同步、阻塞的方式。也就是說,在讀取輸入輸出流時,在讀、寫操作完成前,執行緒會一直阻塞在那裡,它們之間的呼叫是可靠的線性關係。

  • NIO

Java 1.4 中引入了 NIO 框架 (java.nio) ,提供了 Selector、Channel、Buffer 等新的抽象,可以用來構建多路複用、同步、非阻塞的 IO 程式,同時也提供了更接近作業系統底層的高效能資料操作方式 DMA。

  • AIO

在 Java 1.7 中引入了非同步、非阻塞 IO 。也有人把它叫做 NIO2。非同步 IO 基於事件和回撥機制,可以簡單理解為,應用操作直接返回,而不會阻塞在那裡,當後臺處理完成,作業系統會通知相應執行緒進行後續工作。

基礎 API 功能與設計,InputStream/OutputStream 和 Reader/Writer 等模式的設計和實現原理

  • 首先,基礎 IO API 的設計,不僅僅是指對檔案的 IO,執行緒間的通訊 pipe,網路程式設計中 Socket 通訊,都是典型的 IO 操作目標。
  • 輸入輸出流 (InputStream/OutputStream) 是用於讀取或者寫入位元組的,例如操作圖片檔案。
  • 而 Reader/Writer 則是用於操作字元,在輸出輸出流的基礎上增加了字元編碼、解碼的功能,適用於類似從檔案中讀取或者寫入字串。本質上計算機操作的都是位元組,不管是網路通訊還是檔案讀取,Reader/Writer 相當於構建了應用邏輯和原始資料之間的橋樑。
  • BufferedOutputStream 等帶緩衝區的實現,可以避免頻繁的磁碟讀寫,進而提高 IO 的處理效率,這種設計利用了緩衝區,即將批量的操作一次處理,但是使用時要記得 flush。
  • 很多 IO 工具類實現了 Closeable 介面,因為需要進行資源的釋放。比如,使用 FileInputStream,它會獲取相應的檔案描述符 (FileDescripter) 。需要利用 try-with-resource、try-finally 等機制保證 FileInputStream 被明確關閉,進而釋放相應的檔案描述符,否則檔案資源將得不到釋放。在必要時,還應增加 Cleaner、Finalize 機制作為資源釋放的最後把關。
8279543-b2c889d961206353.png
Java IO

NIO 的基礎組成

  • Buffer,高效的資料容器,除了布林型別,所有原始資料型別都有相應的 Buffer 實現。

  • Channel,類似在 Linux 之類的作業系統上看到的檔案描述符,是 NIO 被用來支援批量式 IO 操作的一種抽象。
    File 或者 Socket,通常被認為式比較高層次的抽象,而 Channel 則是更加作業系統底層的一種抽象,這也使得 NIO 得以充分利用現代作業系統底層機制,獲得特定場景的效能優化。比如 DMA 等。不同層次的抽象是相互關聯的,我們可以利用 Channel 獲取 Socket,反之亦然。

  • Selector,是 NIO 用來構建多路複用的基礎,它提供了一種高效的機制,可以檢測到註冊在 Selector 上的多個 Channel 中,是否有 Channel 處於就緒狀態,並把它們歸類到就緒集合中,進而實現了單執行緒對多 Channel 的高效管理。
    Selector 同樣是基於作業系統底層機制,不同模式、不同版本都存在區別,例如,在最新的程式碼庫裡,相關實現如下:
    Linux 上的實現基於 epoll (http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java)

  • Charset,提供了 Unicode 字串定義,NIO 也提供了相應的編解碼器。

NIO 解決的問題

  1. 傳統 IO 在特定情境的困境

NIO 被用來構建多路複用程式,如果問為什麼需要 NIO,那應該就是為了解決傳統 IO 在遇到瓶頸、不能滿足我們需求時引入的多路複用方案的實現方式了。

在傳統 Socket IO 中,伺服器對每個客戶端連線都啟動一個單獨的執行緒來對應,而 Java 語言目前的執行緒實現是比較重量級的,啟動或者銷燬一個執行緒有明顯的開銷。例如在 32 位的 JVM 中,啟動一個執行緒的堆疊開銷是 320K,在 64 位 JVM 中這個數字是 1024K,如果是少量執行緒還能正常執行,若是遇到百萬執行緒級別的場景,1024K * 1M = 1TB,這個記憶體開銷就不能接受了,這還不算執行緒內部的執行程式的記憶體開銷。

同時,執行緒上下文切換在這種極多執行緒的情況下,也會極大拖累程式的執行,成為系統吸能的瓶頸。

如果引入執行緒池機制,通過一個固定大小的執行緒池,來負責管理工作執行緒,避免頻繁的建立、銷燬執行緒的開銷,這也是我們構建併發服務的典型方式。這種工作方式,可以參考下圖來理解:

8279543-09b9d3cbe1abc8f4.png

如果連線不多,只有最多幾百個連線的普通應用,這種工作方式在大部分情況下可以工作得很好。但是,如果併發數量急劇上升,執行緒上下文切換的開銷就很大。如果有些執行緒長時間佔用著執行緒池中的資源,其他執行緒就得不到操作。

  1. NIO 提供的多路複用思路
  • 首先建立一個 Selector 作為排程員。

  • 然後建立 ServerSocketChannel,配置為非阻塞模式(在阻塞模式下,註冊操作是不被允許的。配置阻塞操作後,Channel 的操作會變為非阻塞。另外也可從此推出,FileChannel 不可以配置非阻塞模式,不可以註冊)並向 Selector 註冊,通過指定 Selector.OP_ACCEPT,告訴排程員關注新的連線請求。

  • Selector 阻塞在 select 操作,當有關注的操作發生時,就會被喚醒。

  • 喚醒後,通過 SelectionKey 獲取物件進行相應的輸入輸出操作。

可以看到,在傳統 IO 操作中,程式都是同步阻塞模式,需要以多執行緒多工的方式處理。而 NIO 則是利用了一個執行緒對應一個 Selector 的輪詢機制來高效定位就緒的 Channel,來決定做什麼,僅僅 select 操作是阻塞的,可以有效避免大量客戶端連線時,頻繁執行緒切換帶來的問題。

NIO 程式設計

相比於傳統 IO,Java 提供的 NIO 在使用時要考慮的因素很多,編寫出來的程式也比較複雜。不是 NIO 就一定比 IO 好,在選擇技術方案時還是要根據需求具體決定。

  1. 使用 Selector

可以把對各個事件的處理操作封裝成 component 來對接 Selector

8279543-3b087c26d09579cb.png

如果所有監聽事件都註冊到同一個 Selector 中時,每個處理操作會遍歷到不需要監聽的物件,浪費效能和時間。可以開多幾個 Selector 配合 component 處理特定的連線集合。

8279543-d720da2dbbd067e5.png
  1. 讀取資料

NIO 中的讀取的資料是沒有分界的,我們不知道一次讀取了多少資料,所以需要構建一個 Reader 來解析這些資料塊。

8279543-0f85afb8e5e01bab.png

我們不知道每個資料塊中包含多少條資料,可以是不足一條,可以一條多,可以更多條。如果資料塊不足一條,那麼我們需要快取下來,等待後續的資料塊拼接出一個完整的資料。這就要求給每個連線分配一個 Reader,並分配緩衝區。

8279543-1e5f66382057f3d4.png

實現後的程式大概是這樣

8279543-6d526450d22f6f80.png
  1. 緩衝區設計

從第二點可以知道我們的 Reader 必須一個緩衝區,然而如何設計一個緩衝區又是需要考慮的問題。

假設簡單地按照所有可能到來的訊息的最大值,給每個 Reader 分配一個最大值大小的緩衝區,那在連線少量時,還能正常工作。但是,連線少量時,我們也可以選擇傳統 IO 這種更簡單的實現方式。假設這個最大值是 1M,那百萬連線就需要 1TB 的記憶體作為緩衝區,這還是沒考慮如果有些連線傳送資料塊的最大值是 16MB 、 128 MB 甚至更大的情況。

所以,我們應該設計一個動態緩衝區。最簡單的實現方式就像陣列的動態擴容,每次翻倍。這是最容易想到,也是不錯的思路,可以解決這個問題,但還可以在此基礎上進行優化。

考慮到連線的的資料大小是有規律的,不像 Java 通用集合一樣不知道實現要裝多少資料故只能一步一步做動態擴容。我們可以對連線的資料“一步擴容到合適的大小”,避免普通動態擴容的缺點。考慮下從 4KB 一步一步的翻倍擴容到幾百 MB 需要浪費多少效能。

針對特定資料規律擴容,假設大部分資料是 4KB 以內,少量資料是 10MB 以內,剩下的是大檔案。那就可以把擴容分成三個坎:

  • 初次分配 4KB 的緩衝區,滿足絕大部分的請求,無需擴容。
  • 4KB 不滿足則分配 10MB 的緩衝區,比如少量的圖片、小檔案傳輸。
  • 剩下極少量情況是傳輸大檔案,其大小不可估計,但是因為是極少量情況,所以可以直接分配資料塊允許的最大大小的快取。

其他實現緩衝區的方式有

  • 直接建立一個超大 buffer,各個 Reader 複用這個超大 buffer 的一部分作為緩衝區。
  • 使用連結串列法,緩衝區 buffer 太小就再分配一個 buffer,用連結串列連線新舊的 buffer 組成更大的 buffer。
  • 有些訊息有通用的格式,會在訊息頭寫下本次訊息的大小,程式根據這個大小分配相應的 buffer 即可。
  1. 寫入資料

在非阻塞模式下,呼叫 write 操作都是直接返回,不能保證每次都把資料寫完。這時就需要實現一個寫入資料緩衝區和相應的邏輯程式碼來完成寫入。

8279543-18acccfdea211e52.png

相關文章