1. 程式人生 > >Java 的 I/O 類庫的基本架構

Java 的 I/O 類庫的基本架構

gzip 一次 選擇器 監控 很難 exce sco 大小 raid

Java 的 I/O 類庫的基本架構

I/O 問題是任何編程語言都無法回避的問題,可以說 I/O 問題是整個人機交互的核心問題,因為 I/O 是機器獲取和交換信息的主要渠道。在當今這個數據大爆炸時代,I/O 問題尤其突出,很容易成為一個性能瓶頸。正因如此,所以 Java 在 I/O 上也一直在做持續的優化,如從 1.4 開始引入了 NIO,提升了 I/O 的性能。關於 NIO 我們將在後面詳細介紹。

Java 的 I/O 操作類在包 java.io 下,大概有將近 80 個類,但是這些類大概可以分成四組,分別是:

  1. 基於字節操作的 I/O 接口:InputStream 和 OutputStream
  2. 基於字符操作的 I/O 接口:Writer 和 Reader
  3. 基於磁盤操作的 I/O 接口:File
  4. 基於網絡操作的 I/O 接口:Socket

前兩組主要是根據傳輸數據的數據格式,後兩組主要是根據傳輸數據的方式,雖然 Socket 類並不在 java.io 包下,但是我仍然把它們劃分在一起,因為我個人認為 I/O 的核心問題要麽是數據格式影響 I/O 操作,要麽是傳輸方式影響 I/O 操作,也就是將什麽樣的數據寫到什麽地方的問題,I/O 只是人與機器或者機器與機器交互的手段,除了在它們能夠完成這個交互功能外,我們關註的就是如何提高它的運行效率了,而數據格式和傳輸方式是影響效率最關鍵的因素了。我們後面的分析也是基於這兩個因素來展開的。

基於字節的 I/O 操作接口

基於字節的 I/O 操作接口輸入和輸出分別是:InputStream 和 OutputStream,InputStream 輸入流的類繼承層次如下圖所示:

圖 1. InputStream 相關類層次結構

技術分享圖片

輸入流根據數據類型和操作方式又被劃分成若幹個子類,每個子類分別處理不同操作類型,OutputStream 輸出流的類層次結構也是類似,如下圖所示:

圖 2. OutputStream 相關類層次結構

技術分享圖片

這裏就不詳細解釋每個子類如何使用了,如果不清楚的話可以參考一下 JDK 的 API 說明文檔,這裏只想說明兩點,一個是操作數據的方式是可以組合使用的,如這樣組合使用

OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream("fileName"))

還有一點是流最終寫到什麽地方必須要指定,要麽是寫到磁盤要麽是寫到網絡中,其實從上面的類圖中我們發現,寫網絡實際上也是寫文件,只不過寫網絡還有一步需要處理就是底層操作系統再將數據傳送到其它地方而不是本地磁盤。關於網絡 I/O 和磁盤 I/O 我們將在後面詳細介紹。

基於字符的 I/O 操作接口

不管是磁盤還是網絡傳輸,最小的存儲單元都是字節,而不是字符,所以 I/O 操作的都是字節而不是字符,但是為啥有操作字符的 I/O 接口呢?這是因為我們的程序中通常操作的數據都是以字符形式,為了操作方便當然要提供一個直接寫字符的 I/O 接口,如此而已。我們知道字符到字節必須要經過編碼轉換,而這個編碼又非常耗時,而且還會經常出現亂碼問題,所以 I/O 的編碼問題經常是讓人頭疼的問題。關於 I/O 編碼問題請參考另一篇文章 《深入分析Java中的中文編碼問題》。

下圖是寫字符的 I/O 操作接口涉及到的類,Writer 類提供了一個抽象方法 write(char cbuf[], int off, int len) 由子類去實現。

圖 3. Writer 相關類層次結構

技術分享圖片

圖 4.Reader 類層次結構

技術分享圖片

讀字符的操作接口中也是 int read(char cbuf[], int off, int len),返回讀到的 n 個字節數,不管是 Writer 還是 Reader 類它們都只定義了讀取或寫入的數據字符的方式,也就是怎麽寫或讀,但是並沒有規定數據要寫到哪去,寫到哪去就是我們後面要討論的基於磁盤和網絡的工作機制。

字節與字符的轉化接口

另外數據持久化或網絡傳輸都是以字節進行的,所以必須要有字符到字節或字節到字符的轉化。字符到字節需要轉化,其中讀的轉化過程如下圖所示:

圖 5. 字符解碼相關類結構

技術分享圖片

InputStreamReader 類是字節到字符的轉化橋梁,InputStream 到 Reader 的過程要指定編碼字符集,否則將采用操作系統默認字符集,很可能會出現亂碼問題。StreamDecoder 正是完成字節到字符的解碼的實現類。也就是當你用如下方式讀取一個文件時:

清單 1.讀取文件
try { 
   StringBuffer str = new StringBuffer(); 
   char[] buf = new char[1024]; 
   FileReader f = new FileReader("file"); 
   while(f.read(buf)>0){ 
       str.append(buf); 
   } 
   str.toString(); 
} catch (IOException e) {}

FileReader 類就是按照上面的工作方式讀取文件的,FileReader 是繼承了 InputStreamReader 類,實際上是讀取文件流,然後通過 StreamDecoder 解碼成 char,只不過這裏的解碼字符集是默認字符集。

寫入也是類似的過程如下圖所示:

圖 6. 字符編碼相關類結構

技術分享圖片

通過 OutputStreamWriter 類完成,字符到字節的編碼過程,由 StreamEncoder 完成編碼過程。

磁盤 I/O 工作機制

前面介紹了基本的 Java I/O 的操作接口,這些接口主要定義了如何操作數據,以及介紹了操作兩種數據結構:字節和字符的方式。還有一個關鍵問題就是數據寫到何處,其中一個主要方式就是將數據持久化到物理磁盤,下面將介紹如何將數據持久化到物理磁盤的過程。

我們知道數據在磁盤的唯一最小描述就是文件,也就是說上層應用程序只能通過文件來操作磁盤上的數據,文件也是操作系統和磁盤驅動器交互的一個最小單元。值得註意的是 Java 中通常的 File 並不代表一個真實存在的文件對象,當你通過指定一個路徑描述符時,它就會返回一個代表這個路徑相關聯的一個虛擬對象,這個可能是一個真實存在的文件或者是一個包含多個文件的目錄。為何要這樣設計?因為大部分情況下,我們並不關心這個文件是否真的存在,而是關心這個文件到底如何操作。例如我們手機裏通常存了幾百個朋友的電話號碼,但是我們通常關心的是我有沒有這個朋友的電話號碼,或者這個電話號碼是什麽,但是這個電話號碼到底能不能打通,我們並不是時時刻刻都去檢查,而只有在真正要給他打電話時才會看這個電話能不能用。也就是使用這個電話記錄要比打這個電話的次數多很多。

何時真正會要檢查一個文件存不存?就是在真正要讀取這個文件時,例如 FileInputStream 類都是操作一個文件的接口,註意到在創建一個 FileInputStream 對象時,會創建一個 FileDescriptor 對象,其實這個對象就是真正代表一個存在的文件對象的描述,當我們在操作一個文件對象時可以通過 getFD() 方法獲取真正操作的與底層操作系統關聯的文件描述。例如可以調用 FileDescriptor.sync() 方法將操作系統緩存中的數據強制刷新到物理磁盤中。

下面以清單 1 的程序為例,介紹下如何從磁盤讀取一段文本字符。如下圖所示:

圖 7. 從磁盤讀取文件

技術分享圖片

當傳入一個文件路徑,將會根據這個路徑創建一個 File 對象來標識這個文件,然後將會根據這個 File 對象創建真正讀取文件的操作對象,這時將會真正創建一個關聯真實存在的磁盤文件的文件描述符 FileDescriptor,通過這個對象可以直接控制這個磁盤文件。由於我們需要讀取的是字符格式,所以需要 StreamDecoder 類將 byte 解碼為 char 格式,至於如何從磁盤驅動器上讀取一段數據,由操作系統幫我們完成。至於操作系統是如何將數據持久化到磁盤以及如何建立數據結構需要根據當前操作系統使用何種文件系統來回答,至於文件系統的相關細節可以參考另外的文章。

Java Socket 的工作機制

Socket 這個概念沒有對應到一個具體的實體,它是描述計算機之間完成相互通信一種抽象功能。打個比方,可以把 Socket 比作為兩個城市之間的交通工具,有了它,就可以在城市之間來回穿梭了。交通工具有多種,每種交通工具也有相應的交通規則。Socket 也一樣,也有多種。大部分情況下我們使用的都是基於 TCP/IP 的流套接字,它是一種穩定的通信協議。

下圖是典型的基於 Socket 的通信的場景:

圖 8.Socket 通信示例

技術分享圖片

主機 A 的應用程序要能和主機 B 的應用程序通信,必須通過 Socket 建立連接,而建立 Socket 連接必須需要底層 TCP/IP 協議來建立 TCP 連接。建立 TCP 連接需要底層 IP 協議來尋址網絡中的主機。我們知道網絡層使用的 IP 協議可以幫助我們根據 IP 地址來找到目標主機,但是一臺主機上可能運行著多個應用程序,如何才能與指定的應用程序通信就要通過 TCP 或 UPD 的地址也就是端口號來指定。這樣就可以通過一個 Socket 實例唯一代表一個主機上的一個應用程序的通信鏈路了。

建立通信鏈路

當客戶端要與服務端通信,客戶端首先要創建一個 Socket 實例,操作系統將為這個 Socket 實例分配一個沒有被使用的本地端口號,並創建一個包含本地和遠程地址和端口號的套接字數據結構,這個數據結構將一直保存在系統中直到這個連接關閉。在創建 Socket 實例的構造函數正確返回之前,將要進行 TCP 的三次握手協議,TCP 握手協議完成後,Socket 實例對象將創建完成,否則將拋出 IOException 錯誤。

與之對應的服務端將創建一個 ServerSocket 實例,ServerSocket 創建比較簡單只要指定的端口號沒有被占用,一般實例創建都會成功,同時操作系統也會為 ServerSocket 實例創建一個底層數據結構,這個數據結構中包含指定監聽的端口號和包含監聽地址的通配符,通常情況下都是“*”即監聽所有地址。之後當調用 accept() 方法時,將進入阻塞狀態,等待客戶端的請求。當一個新的請求到來時,將為這個連接創建一個新的套接字數據結構,該套接字數據的信息包含的地址和端口信息正是請求源地址和端口。這個新創建的數據結構將會關聯到 ServerSocket 實例的一個未完成的連接數據結構列表中,註意這時服務端與之對應的 Socket 實例並沒有完成創建,而要等到與客戶端的三次握手完成後,這個服務端的 Socket 實例才會返回,並將這個 Socket 實例對應的數據結構從未完成列表中移到已完成列表中。所以 ServerSocket 所關聯的列表中每個數據結構,都代表與一個客戶端的建立的 TCP 連接。

數據傳輸

傳輸數據是我們建立連接的主要目的,如何通過 Socket 傳輸數據,下面將詳細介紹。

當連接已經建立成功,服務端和客戶端都會擁有一個 Socket 實例,每個 Socket 實例都有一個 InputStream 和 OutputStream,正是通過這兩個對象來交換數據。同時我們也知道網絡 I/O 都是以字節流傳輸的。當 Socket 對象創建時,操作系統將會為 InputStream 和 OutputStream 分別分配一定大小的緩沖區,數據的寫入和讀取都是通過這個緩存區完成的。寫入端將數據寫到 OutputStream 對應的 SendQ 隊列中,當隊列填滿時,數據將被發送到另一端 InputStream 的 RecvQ 隊列中,如果這時 RecvQ 已經滿了,那麽 OutputStream 的 write 方法將會阻塞直到 RecvQ 隊列有足夠的空間容納 SendQ 發送的數據。值得特別註意的是,這個緩存區的大小以及寫入端的速度和讀取端的速度非常影響這個連接的數據傳輸效率,由於可能會發生阻塞,所以網絡 I/O 與磁盤 I/O 在數據的寫入和讀取還要有一個協調的過程,如果兩邊同時傳送數據時可能會產生死鎖,在後面 NIO 部分將介紹避免這種情況。

NIO 的工作方式

BIO 帶來的挑戰

BIO 即阻塞 I/O,不管是磁盤 I/O 還是網絡 I/O,數據在寫入 OutputStream 或者從 InputStream 讀取時都有可能會阻塞。一旦有線程阻塞將會失去 CPU 的使用權,這在當前的大規模訪問量和有性能要求情況下是不能接受的。雖然當前的網絡 I/O 有一些解決辦法,如一個客戶端一個處理線程,出現阻塞時只是一個線程阻塞而不會影響其它線程工作,還有為了減少系統線程的開銷,采用線程池的辦法來減少線程創建和回收的成本,但是有一些使用場景仍然是無法解決的。如當前一些需要大量 HTTP 長連接的情況,像淘寶現在使用的 Web 旺旺項目,服務端需要同時保持幾百萬的 HTTP 連接,但是並不是每時每刻這些連接都在傳輸數據,這種情況下不可能同時創建這麽多線程來保持連接。即使線程的數量不是問題,仍然有一些問題還是無法避免的。如這種情況,我們想給某些客戶端更高的服務優先級,很難通過設計線程的優先級來完成,另外一種情況是,我們需要讓每個客戶端的請求在服務端可能需要訪問一些競爭資源,由於這些客戶端是在不同線程中,因此需要同步,而往往要實現這些同步操作要遠遠比用單線程復雜很多。以上這些情況都說明,我們需要另外一種新的 I/O 操作方式。

NIO 的工作機制

我們先看一下 NIO 涉及到的關聯類圖,如下:

圖 9.NIO 相關類圖

技術分享圖片

上圖中有兩個關鍵類:Channel 和 Selector,它們是 NIO 中兩個核心概念。我們還用前面的城市交通工具來繼續比喻 NIO 的工作方式,這裏的 Channel 要比 Socket 更加具體,它可以比作為某種具體的交通工具,如汽車或是高鐵等,而 Selector 可以比作為一個車站的車輛運行調度系統,它將負責監控每輛車的當前運行狀態:是已經出戰還是在路上等等,也就是它可以輪詢每個 Channel 的狀態。這裏還有一個 Buffer 類,它也比 Stream 更加具體化,我們可以將它比作為車上的座位,Channel 是汽車的話就是汽車上的座位,高鐵上就是高鐵上的座位,它始終是一個具體的概念,與 Stream 不同。Stream 只能代表是一個座位,至於是什麽座位由你自己去想象,也就是你在去上車之前並不知道,這個車上是否還有沒有座位了,也不知道上的是什麽車,因為你並不能選擇,這些信息都已經被封裝在了運輸工具(Socket)裏面了,對你是透明的。NIO 引入了 Channel、Buffer 和 Selector 就是想把這些信息具體化,讓程序員有機會控制它們,如:當我們調用 write() 往 SendQ 寫數據時,當一次寫的數據超過 SendQ 長度是需要按照 SendQ 的長度進行分割,這個過程中需要有將用戶空間數據和內核地址空間進行切換,而這個切換不是你可以控制的。而在 Buffer 中我們可以控制 Buffer 的 capacity,並且是否擴容以及如何擴容都可以控制。

理解了這些概念後我們看一下,實際上它們是如何工作的,下面是典型的一段 NIO 代碼:

清單 2. NIO 工作代碼示例
public void selector() throws IOException {
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    Selector selector = Selector.open();
    ServerSocketChannel ssc = ServerSocketChannel.open();
    ssc.configureBlocking(false);//設置為非阻塞方式
    ssc.socket().bind(new InetSocketAddress(8080));
    ssc.register(selector, SelectionKey.OP_ACCEPT);//註冊監聽的事件
    while (true) {
        Set selectedKeys = selector.selectedKeys();//取得所有key集合
        Iterator it = selectedKeys.iterator();
        while (it.hasNext()) {
            SelectionKey key = (SelectionKey) it.next();
            if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
                ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();
             SocketChannel sc = ssChannel.accept();//接受到服務端的請求
                sc.configureBlocking(false);
                sc.register(selector, SelectionKey.OP_READ);
                it.remove();
            } else if 
            ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
                SocketChannel sc = (SocketChannel) key.channel();
                while (true) {
                    buffer.clear();
                    int n = sc.read(buffer);//讀取數據
                    if (n <= 0) {
                        break;
                    }
                    buffer.flip();
                }
                it.remove();
            }
        }
    }
}

調用 Selector 的靜態工廠創建一個選擇器,創建一個服務端的 Channel 綁定到一個 Socket 對象,並把這個通信信道註冊到選擇器上,把這個通信信道設置為非阻塞模式。然後就可以調用 Selector 的 selectedKeys 方法來檢查已經註冊在這個選擇器上的所有通信信道是否有需要的事件發生,如果有某個事件發生時,將會返回所有的 SelectionKey,通過這個對象 Channel 方法就可以取得這個通信信道對象從而可以讀取通信的數據,而這裏讀取的數據是 Buffer,這個 Buffer 是我們可以控制的緩沖器。

在上面的這段程序中,是將 Server 端的監聽連接請求的事件和處理請求的事件放在一個線程中,但是在實際應用中,我們通常會把它們放在兩個線程中,一個線程專門負責監聽客戶端的連接請求,而且是阻塞方式執行的;另外一個線程專門來處理請求,這個專門處理請求的線程才會真正采用 NIO 的方式,像 Web 服務器 Tomcat 和 Jetty 都是這個處理方式,關於 Tomcat 和 Jetty 的 NIO 處理方式可以參考文章《 Jetty 的工作原理和與 Tomcat 的比較》。

下圖是描述了基於 NIO 工作方式的 Socket 請求的處理過程:

圖 10. 基於 NIO 的 Socket 請求的處理過程

技術分享圖片

上圖中的 Selector 可以同時監聽一組通信信道(Channel)上的 I/O 狀態,前提是這個 Selector 要已經註冊到這些通信信道中。選擇器 Selector 可以調用 select() 方法檢查已經註冊的通信信道上的是否有 I/O 已經準備好,如果沒有至少一個信道 I/O 狀態有變化,那麽 select 方法會阻塞等待或在超時時間後會返回 0。上圖中如果有多個信道有數據,那麽將會將這些數據分配到對應的數據 Buffer 中。所以關鍵的地方是有一個線程來處理所有連接的數據交互,每個連接的數據交互都不是阻塞方式,所以可以同時處理大量的連接請求。

Buffer 的工作方式

上面介紹了 Selector 將檢測到有通信信道 I/O 有數據傳輸時,通過 selelct() 取得 SocketChannel,將數據讀取或寫入 Buffer 緩沖區。下面討論一下 Buffer 如何接受和寫出數據?

Buffer 可以簡單的理解為一組基本數據類型的元素列表,它通過幾個變量來保存這個數據的當前位置狀態,也就是有四個索引。如下表所示:

表 1.Buffer 中的參數項

技術分享圖片

在實際操作數據時它們有如下關系圖:

技術分享圖片

我們通過 ByteBuffer.allocate(11) 方法創建一個 11 個 byte 的數組緩沖區,初始狀態如上圖所示,position 的位置為 0,capacity 和 limit 默認都是數組長度。當我們寫入 5 個字節時位置變化如下圖所示:

技術分享圖片

這時我們需要將緩沖區的 5 個字節數據寫入 Channel 通信信道,所以我們需要調用 byteBuffer.flip() 方法,數組的狀態又發生如下變化:

技術分享圖片

這時底層操作系統就可以從緩沖區中正確讀取這 5 個字節數據發送出去了。在下一次寫數據之前我們在調一下 clear() 方法。緩沖區的索引狀態又回到初始位置。

這裏還要說明一下 mark,當我們調用 mark() 時,它將記錄當前 position 的前一個位置,當我們調用 reset 時,position 將恢復 mark 記錄下來的值。

還有一點需要說明,通過 Channel 獲取的 I/O 數據首先要經過操作系統的 Socket 緩沖區再將數據復制到 Buffer 中,這個的操作系統緩沖區就是底層的 TCP 協議關聯的 RecvQ 或者 SendQ 隊列,從操作系統緩沖區到用戶緩沖區復制數據比較耗性能,Buffer 提供了另外一種直接操作操作系統緩沖區的的方式即 ByteBuffer.allocateDirector(size),這個方法返回的 byteBuffer 就是與底層存儲空間關聯的緩沖區,它的操作方式與 linux2.4 內核的 sendfile 操作方式類似。

I/O 調優

下面就磁盤 I/O 和網絡 I/O 的一些常用的優化技巧進行總結如下:

磁盤 I/O 優化

性能檢測

我們的應用程序通常都需要訪問磁盤讀取數據,而磁盤 I/O 通常都很耗時,我們要判斷 I/O 是否是一個瓶頸,我們有一些參數指標可以參考:

如我們可以壓力測試應用程序看系統的 I/O wait 指標是否正常,例如測試機器有 4 個 CPU,那麽理想的 I/O wait 參數不應該超過 25%,如果超過 25% 的話,I/O 很可能成為應用程序的性能瓶頸。Linux 操作系統下可以通過 iostat 命令查看。

通常我們在判斷 I/O 性能時還會看另外一個參數就是 IOPS,我們應用程序需要最低的 IOPS 是多少,而我們的磁盤的 IOPS 能不能達到我們的要求。每個磁盤的 IOPS 通常是在一個範圍內,這和存儲在磁盤的數據塊的大小和訪問方式也有關。但是主要是由磁盤的轉速決定的,磁盤的轉速越高磁盤的 IOPS 也越高。

現在為了提高磁盤 I/O 的性能,通常采用一種叫 RAID 的技術,就是將不同的磁盤組合起來來提高 I/O 性能,目前有多種 RAID 技術,每種 RAID 技術對 I/O 性能提升會有不同,可以用一個 RAID 因子來代表,磁盤的讀寫吞吐量可以通過 iostat 命令來獲取,於是我們可以計算出一個理論的 IOPS 值,計算公式如下所以:

( 磁盤數 * 每塊磁盤的 IOPS)/( 磁盤讀的吞吐量 +RAID 因子 * 磁盤寫的吞吐量 )=IOPS

這個公式的詳細信息請查閱參考資料 Understanding Disk I/O。

提升 I/O 性能

提升磁盤 I/O 性能通常的方法有:

  1. 增加緩存,減少磁盤訪問次數
  2. 優化磁盤的管理系統,設計最優的磁盤訪問策略,以及磁盤的尋址策略,這裏是在底層操作系統層面考慮的。
  3. 設計合理的磁盤存儲數據塊,以及訪問這些數據塊的策略,這裏是在應用層面考慮的。如我們可以給存放的數據設計索引,通過尋址索引來加快和減少磁盤的訪問,還有可以采用異步和非阻塞的方式加快磁盤的訪問效率。
  4. 應用合理的 RAID 策略提升磁盤 IO,每種 RAID 的區別我們可以用下表所示:
表 2.RAID 策略

技術分享圖片

網絡 I/O 優化

網絡 I/O 優化通常有一些基本處理原則:

  1. 一個是減少網絡交互的次數:要減少網絡交互的次數通常我們在需要網絡交互的兩端會設置緩存,比如 Oracle 的 JDBC 驅動程序,就提供了對查詢的 SQL 結果的緩存,在客戶端和數據庫端都有,可以有效的減少對數據庫的訪問。關於 Oracle JDBC 的內存管理可以參考《 Oracle JDBC 內存管理》。除了設置緩存還有一個辦法是,合並訪問請求:如在查詢數據庫時,我們要查 10 個 id,我可以每次查一個 id,也可以一次查 10 個 id。再比如在訪問一個頁面時通過會有多個 js 或 css 的文件,我們可以將多個 js 文件合並在一個 HTTP 鏈接中,每個文件用逗號隔開,然後發送到後端 Web 服務器根據這個 URL 鏈接,再拆分出各個文件,然後打包再一並發回給前端瀏覽器。這些都是常用的減少網絡 I/O 的辦法。
  2. 減少網絡傳輸數據量的大小:減少網絡數據量的辦法通常是將數據壓縮後再傳輸,如 HTTP 請求中,通常 Web 服務器將請求的 Web 頁面 gzip 壓縮後在傳輸給瀏覽器。還有就是通過設計簡單的協議,盡量通過讀取協議頭來獲取有用的價值信息。比如在代理程序設計時,有 4 層代理和 7 層代理都是來盡量避免要讀取整個通信數據來取得需要的信息。
  3. 盡量減少編碼:通常在網絡 I/O 中數據傳輸都是以字節形式的,也就是通常要序列化。但是我們發送要傳輸的數據都是字符形式的,從字符到字節必須編碼。但是這個編碼過程是比較耗時的,所以在要經過網絡 I/O 傳輸時,盡量直接以字節形式發送。也就是盡量提前將字符轉化為字節,或者減少字符到字節的轉化過程。
  4. 根據應用場景設計合適的交互方式:所謂的交互場景主要包括同步與異步阻塞與非阻塞方式,下面將詳細介紹。

同步與異步

所謂同步就是一個任務的完成需要依賴另外一個任務時,只有等待被依賴的任務完成後,依賴的任務才能算完成,這是一種可靠的任務序列。要麽成功都成功,失敗都失敗,兩個任務的狀態可以保持一致。而異步是不需要等待被依賴的任務完成,只是通知被依賴的任務要完成什麽工作,依賴的任務也立即執行,只要自己完成了整個任務就算完成了。至於被依賴的任務最終是否真正完成,依賴它的任務無法確定,所以它是不可靠的任務序列。我們可以用打電話和發短信來很好的比喻同步與異步操作。

在設計到 IO 處理時通常都會遇到一個是同步還是異步的處理方式的選擇問題。因為同步與異步的 I/O 處理方式對調用者的影響很大,在數據庫產品中都會遇到這個問題。因為 I/O 操作通常是一個非常耗時的操作,在一個任務序列中 I/O 通常都是性能瓶頸。但是同步與異步的處理方式對程序的可靠性影響非常大,同步能夠保證程序的可靠性,而異步可以提升程序的性能,必須在可靠性和性能之間做個平衡,沒有完美的解決辦法。

阻塞與非阻塞

阻塞與非阻塞主要是從 CPU 的消耗上來說的,阻塞就是 CPU 停下來等待一個慢的操作完成 CPU 才接著完成其它的事。非阻塞就是在這個慢的操作在執行時 CPU 去幹其它別的事,等這個慢的操作完成時,CPU 再接著完成後續的操作。雖然表面上看非阻塞的方式可以明顯的提高 CPU 的利用率,但是也帶了另外一種後果就是系統的線程切換增加。增加的 CPU 使用時間能不能補償系統的切換成本需要好好評估。

兩種的方式的組合

組合的方式可以由四種,分別是:同步阻塞、同步非阻塞、異步阻塞、異步非阻塞,這四種方式都對 I/O 性能有影響。下面給出分析,並有一些常用的設計用例參考。

表 3. 四種組合方式

技術分享圖片

雖然異步和非阻塞能夠提升 I/O 的性能,但是也會帶來一些額外的性能成本,例如會增加線程數量從而增加 CPU 的消耗,同時也會導致程序設計的復雜度上升。如果設計的不合理的話反而會導致性能下降。在實際設計時要根據應用場景綜合評估一下。

下面舉一些異步和阻塞的操作實例:

在 Cassandra 中要查詢數據通常會往多個數據節點發送查詢命令,但是要檢查每個節點返回數據的完整性,所以需要一個異步查詢同步結果的應用場景,部分代碼如下:

清單 3.異步查詢同步結果
class AsyncResult implements IAsyncResult{ 
   private byte[] result_; 
   private AtomicBoolean done_ = new AtomicBoolean(false); 
   private Lock lock_ = new ReentrantLock(); 
   private Condition condition_; 
   private long startTime_; 
   public AsyncResult(){        
       condition_ = lock_.newCondition();// 創建一個鎖
       startTime_ = System.currentTimeMillis(); 
   }    
/*** 檢查需要的數據是否已經返回,如果沒有返回阻塞 */ 
public byte[] get(){ 
       lock_.lock(); 
       try{ 
           if (!done_.get()){condition_.await();} 
       }catch (InterruptedException ex){ 
           throw new AssertionError(ex); 
       }finally{lock_.unlock();} 
       return result_; 
} 
/*** 檢查需要的數據是否已經返回 */ 
   public boolean isDone(){return done_.get();} 
/*** 檢查在指定的時間內需要的數據是否已經返回,如果沒有返回拋出超時異常 */ 
   public byte[] get(long timeout, TimeUnit tu) throws TimeoutException{ 
       lock_.lock(); 
       try{            boolean bVal = true; 
           try{ 
               if ( !done_.get() ){ 
          long overall_timeout = timeout - (System.currentTimeMillis() - startTime_); 
                   if(overall_timeout > 0)// 設置等待超時的時間
                       bVal = condition_.await(overall_timeout, TimeUnit.MILLISECONDS); 
                   else bVal = false; 
               } 
           }catch (InterruptedException ex){ 
               throw new AssertionError(ex); 
           } 
           if ( !bVal && !done_.get() ){// 拋出超時異常
               throw new TimeoutException("Operation timed out."); 
           } 
       }finally{lock_.unlock();      } 
       return result_; 
} 
/*** 該函數拱另外一個線程設置要返回的數據,並喚醒在阻塞的線程 */ 
   public void result(Message response){        
       try{ 
           lock_.lock(); 
           if ( !done_.get() ){                
               result_ = response.getMessageBody();// 設置返回的數據
               done_.set(true); 
               condition_.signal();// 喚醒阻塞的線程
           } 
       }finally{lock_.unlock();}        
   }    
}

總結

本文闡述的內容較多,從 Java 基本 I/O 類庫結構開始說起,主要介紹了磁盤 I/O 和網絡 I/O 的基本工作方式,最後介紹了關於 I/O 調優的一些方法。

Java 的 I/O 類庫的基本架構