I/O 問題能夠說是當今互聯網 Web 應用中所面臨的主要問題之一,由於當前在這個海量數據時代,數據在網絡中隨處流動。這個流動的過程當中都涉及到 I/O 問題,能夠說大部分 Web 應用系統的瓶頸都是 I/O 瓶頸。本文的目的正是分析 I/O 的內在工做機制,你將瞭解到:Java 的 I/O 類庫的基本架構;磁盤 I/O 工做機制;網絡 I/O 的工做機制;其中以網絡 I/O 爲重點介紹 Java Socket 的工做方式;你還將瞭解到 NIO 的工做方式,還有同步和異步以及阻塞與非阻塞的區別,最後咱們將介紹一些經常使用的關於 I/O 的優化技巧。css
I/O 問題是任何編程語言都沒法迴避的問題,能夠說 I/O 問題是整我的機交互的核心問題,由於 I/O 是機器獲取和交換信息的主要渠道。在當今這個數據大爆炸時代,I/O 問題尤爲突出,很容易成爲一個性能瓶頸。正因如此,因此 Java 在 I/O 上也一直在作持續的優化,如從 1.4 開始引入了 NIO,提高了 I/O 的性能。關於 NIO 咱們將在後面詳細介紹。前端
Java 的 I/O 操做類在包 java.io 下,大概有將近 80 個類,可是這些類大概能夠分紅四組,分別是:java
前兩組主要是根據傳輸數據的數據格式,後兩組主要是根據傳輸數據的方式,雖然 Socket 類並不在 java.io 包下,可是我仍然把它們劃分在一塊兒,由於我我的認爲 I/O 的核心問題要麼是數據格式影響 I/O 操做,要麼是傳輸方式影響 I/O 操做,也就是將什麼樣的數據寫到什麼地方的問題,I/O 只是人與機器或者機器與機器交互的手段,除了在它們可以完成這個交互功能外,咱們關注的就是如何提升它的運行效率了,而數據格式和傳輸方式是影響效率最關鍵的因素了。咱們後面的分析也是基於這兩個因素來展開的。linux
基於字節的 I/O 操做接口輸入和輸出分別是:InputStream 和 OutputStream,InputStream 輸入流的類繼承層次以下圖所示:ios
輸入流根據數據類型和操做方式又被劃分紅若干個子類,每一個子類分別處理不一樣操做類型,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 編碼問題請參考另外一篇文章 《深刻分析Java中的中文編碼問題》。數組
下圖是寫字符的 I/O 操做接口涉及到的類,Writer 類提供了一個抽象方法 write(char cbuf[], int off, int len) 由子類去實現。
讀字符的操做接口也有相似的類結構,以下圖所示:
讀字符的操做接口中也是 int read(char cbuf[], int off, int len),返回讀到的 n 個字節數,無論是 Writer 仍是 Reader 類它們都只定義了讀取或寫入的數據字符的方式,也就是怎麼寫或讀,可是並無規定數據要寫到哪去,寫到哪去就是咱們後面要討論的基於磁盤和網絡的工做機制。
另外數據持久化或網絡傳輸都是以字節進行的,因此必需要有字符到字節或字節到字符的轉化。字符到字節須要轉化,其中讀的轉化過程以下圖所示:
InputStreamReader 類是字節到字符的轉化橋樑,InputStream 到 Reader 的過程要指定編碼字符集,不然將採用操做系統默認字符集,極可能會出現亂碼問題。StreamDecoder 正是完成字節到字符的解碼的實現類。也就是當你用以下方式讀取一個文件時:
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,只不過這裏的解碼字符集是默認字符集。
寫入也是相似的過程以下圖所示:
經過 OutputStreamWriter 類完成,字符到字節的編碼過程,由 StreamEncoder 完成編碼過程。
前面介紹了基本的 Java I/O 的操做接口,這些接口主要定義瞭如何操做數據,以及介紹了操做兩種數據結構:字節和字符的方式。還有一個關鍵問題就是數據寫到何處,其中一個主要方式就是將數據持久化到物理磁盤,下面將介紹如何將數據持久化到物理磁盤的過程。
咱們知道數據在磁盤的惟一最小描述就是文件,也就是說上層應用程序只能經過文件來操做磁盤上的數據,文件也是操做系統和磁盤驅動器交互的一個最小單元。值得注意的是 Java 中一般的 File 並不表明一個真實存在的文件對象,當你經過指定一個路徑描述符時,它就會返回一個表明這個路徑相關聯的一個虛擬對象,這個多是一個真實存在的文件或者是一個包含多個文件的目錄。爲什麼要這樣設計?由於大部分狀況下,咱們並不關心這個文件是否真的存在,而是關心這個文件到底如何操做。例如咱們手機裏一般存了幾百個朋友的電話號碼,可是咱們一般關心的是我有沒有這個朋友的電話號碼,或者這個電話號碼是什麼,可是這個電話號碼到底能不能打通,咱們並非時時刻刻都去檢查,而只有在真正要給他打電話時纔會看這個電話能不能用。也就是使用這個電話記錄要比打這個電話的次數多不少。
什麼時候真正會要檢查一個文件存不存?就是在真正要讀取這個文件時,例如 FileInputStream 類都是操做一個文件的接口,注意到在建立一個 FileInputStream 對象時,會建立一個 FileDescriptor 對象,其實這個對象就是真正表明一個存在的文件對象的描述,當咱們在操做一個文件對象時能夠經過 getFD() 方法獲取真正操做的與底層操做系統關聯的文件描述。例如能夠調用 FileDescriptor.sync() 方法將操做系統緩存中的數據強制刷新到物理磁盤中。
下面以清單 1 的程序爲例,介紹下如何從磁盤讀取一段文本字符。以下圖所示:
當傳入一個文件路徑,將會根據這個路徑建立一個 File 對象來標識這個文件,而後將會根據這個 File 對象建立真正讀取文件的操做對象,這時將會真正建立一個關聯真實存在的磁盤文件的文件描述符 FileDescriptor,經過這個對象能夠直接控制這個磁盤文件。因爲咱們須要讀取的是字符格式,因此須要 StreamDecoder 類將 byte 解碼爲 char 格式,至於如何從磁盤驅動器上讀取一段數據,由操做系統幫咱們完成。至於操做系統是如何將數據持久化到磁盤以及如何創建數據結構須要根據當前操做系統使用何種文件系統來回答,至於文件系統的相關細節能夠參考另外的文章。
Socket 這個概念沒有對應到一個具體的實體,它是描述計算機之間完成相互通訊一種抽象功能。打個比方,能夠把 Socket 比做爲兩個城市之間的交通工具,有了它,就能夠在城市之間來回穿梭了。交通工具備多種,每種交通工具也有相應的交通規則。Socket 也同樣,也有多種。大部分狀況下咱們使用的都是基於 TCP/IP 的流套接字,它是一種穩定的通訊協議。
下圖是典型的基於 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 部分將介紹避免這種狀況。
BIO 即阻塞 I/O,無論是磁盤 I/O 仍是網絡 I/O,數據在寫入 OutputStream 或者從 InputStream 讀取時都有可能會阻塞。一旦有線程阻塞將會失去 CPU 的使用權,這在當前的大規模訪問量和有性能要求狀況下是不能接受的。雖然當前的網絡 I/O 有一些解決辦法,如一個客戶端一個處理線程,出現阻塞時只是一個線程阻塞而不會影響其它線程工做,還有爲了減小系統線程的開銷,採用線程池的辦法來減小線程建立和回收的成本,可是有一些使用場景仍然是沒法解決的。如當前一些須要大量 HTTP 長鏈接的狀況,像淘寶如今使用的 Web 旺旺項目,服務端須要同時保持幾百萬的 HTTP 鏈接,可是並非每時每刻這些鏈接都在傳輸數據,這種狀況下不可能同時建立這麼多線程來保持鏈接。即便線程的數量不是問題,仍然有一些問題仍是沒法避免的。如這種狀況,咱們想給某些客戶端更高的服務優先級,很難經過設計線程的優先級來完成,另一種狀況是,咱們須要讓每一個客戶端的請求在服務端可能須要訪問一些競爭資源,因爲這些客戶端是在不一樣線程中,所以須要同步,而每每要實現這些同步操做要遠遠比用單線程複雜不少。以上這些狀況都說明,咱們須要另一種新的 I/O 操做方式。
咱們先看一下 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 代碼:
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 請求的處理過程:
上圖中的 Selector 能夠同時監聽一組通訊信道(Channel)上的 I/O 狀態,前提是這個 Selector 要已經註冊到這些通訊信道中。選擇器 Selector 能夠調用 select() 方法檢查已經註冊的通訊信道上的是否有 I/O 已經準備好,若是沒有至少一個信道 I/O 狀態有變化,那麼 select 方法會阻塞等待或在超時時間後會返回 0。上圖中若是有多個信道有數據,那麼將會將這些數據分配到對應的數據 Buffer 中。因此關鍵的地方是有一個線程來處理全部鏈接的數據交互,每一個鏈接的數據交互都不是阻塞方式,因此能夠同時處理大量的鏈接請求。
上面介紹了 Selector 將檢測到有通訊信道 I/O 有數據傳輸時,經過 selelct() 取得 SocketChannel,將數據讀取或寫入 Buffer 緩衝區。下面討論一下 Buffer 如何接受和寫出數據?
Buffer 能夠簡單的理解爲一組基本數據類型的元素列表,它經過幾個變量來保存這個數據的當前位置狀態,也就是有四個索引。以下表所示:
索引 | 說明 |
---|---|
capacity | 緩衝區數組的總長度 |
position | 下一個要操做的數據元素的位置 |
limit | 緩衝區數組中不可操做的下一個元素的位置,limit<=capacity |
mark | 用於記錄當前 position 的前一個位置或者默認是 0 |
在實際操做數據時它們有以下關係圖:
咱們經過 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 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 性能一般的方法有:
磁盤陣列 | 說明 |
---|---|
RAID 0 | 數據被平均寫到多個磁盤陣列中,寫數據和讀數據都是並行的,因此磁盤的 IOPS 能夠提升一倍。 |
RAID 1 | RAID 1 的主要做用是可以提升數據的安全性,它將一份數據分別複製到多個磁盤陣列中。並不能提高 IOPS 可是相同的數據有多個備份。一般用於對數據安全性較高的場合中。 |
RAID 5 | 這中設計方式是前兩種的折中方式,它將數據平均寫到全部磁盤陣列總數減一的磁盤中,往另一個磁盤中寫入這份數據的奇偶校驗信息。若是其中一個磁盤損壞,能夠經過其它磁盤的數據和這個數據的奇偶校驗信息來恢復這份數據。 |
RAID 0+1 | 如名字同樣,就是根據數據的備份狀況進行分組,一份數據同時寫到多個備份磁盤分組中,同時多個分組也會並行讀寫。 |
網絡 I/O 優化一般有一些基本處理原則:
同步與異步
所謂同步就是一個任務的完成須要依賴另一個任務時,只有等待被依賴的任務完成後,依賴的任務才能算完成,這是一種可靠的任務序列。要麼成功都成功,失敗都失敗,兩個任務的狀態能夠保持一致。而異步是不須要等待被依賴的任務完成,只是通知被依賴的任務要完成什麼工做,依賴的任務也當即執行,只要本身完成了整個任務就算完成了。至於被依賴的任務最終是否真正完成,依賴它的任務沒法肯定,因此它是不可靠的任務序列。咱們能夠用打電話和發短信來很好的比喻同步與異步操做。
在設計到 IO 處理時一般都會遇到一個是同步仍是異步的處理方式的選擇問題。由於同步與異步的 I/O 處理方式對調用者的影響很大,在數據庫產品中都會遇到這個問題。由於 I/O 操做一般是一個很是耗時的操做,在一個任務序列中 I/O 一般都是性能瓶頸。可是同步與異步的處理方式對程序的可靠性影響很是大,同步可以保證程序的可靠性,而異步能夠提高程序的性能,必須在可靠性和性能之間作個平衡,沒有完美的解決辦法。
阻塞與非阻塞
阻塞與非阻塞主要是從 CPU 的消耗上來講的,阻塞就是 CPU 停下來等待一個慢的操做完成 CPU 才接着完成其它的事。非阻塞就是在這個慢的操做在執行時 CPU 去幹其它別的事,等這個慢的操做完成時,CPU 再接着完成後續的操做。雖然表面上看非阻塞的方式能夠明顯的提升 CPU 的利用率,可是也帶了另一種後果就是系統的線程切換增長。增長的 CPU 使用時間能不能補償系統的切換成本須要好好評估。
兩種的方式的組合
組合的方式能夠由四種,分別是:同步阻塞、同步非阻塞、異步阻塞、異步非阻塞,這四種方式都對 I/O 性能有影響。下面給出分析,並有一些經常使用的設計用例參考。
組合方式 | 性能分析 |
---|---|
同步阻塞 | 最經常使用的一種用法,使用也是最簡單的,可是 I/O 性能通常不好,CPU 大部分在空閒狀態。 |
同步非阻塞 | 提高 I/O 性能的經常使用手段,就是將 I/O 的阻塞改爲非阻塞方式,尤爲在網絡 I/O 是長鏈接,同時傳輸數據也不是不少的狀況下,提高性能很是有效。 這種方式一般能提高 I/O 性能,可是會增長 CPU 消耗,要考慮增長的 I/O 性能能不能補償 CPU 的消耗,也就是系統的瓶頸是在 I/O 仍是在 CPU 上。 |
異步阻塞 | 這種方式在分佈式數據庫中常常用到,例如在網一個分佈式數據庫中寫一條記錄,一般會有一份是同步阻塞的記錄,而還有兩至三份是備份記錄會寫到其它機器上,這些備份記錄一般都是採用異步阻塞的方式寫 I/O。 異步阻塞對網絡 I/O 可以提高效率,尤爲像上面這種同時寫多份相同數據的狀況。 |
異步非阻塞 | 這種組合方式用起來比較複雜,只有在一些很是複雜的分佈式狀況下使用,像集羣之間的消息同步機制通常用這種 I/O 組合方式。如 Cassandra 的 Gossip 通訊機制就是採用異步非阻塞的方式。 它適合同時要傳多份相同的數據到集羣中不一樣的機器,同時數據的傳輸量雖然不大,可是卻很是頻繁。這種網絡 I/O 用這個方式性能能達到最高。 |
雖然異步和非阻塞可以提高 I/O 的性能,可是也會帶來一些額外的性能成本,例如會增長線程數量從而增長 CPU 的消耗,同時也會致使程序設計的複雜度上升。若是設計的不合理的話反而會致使性能降低。在實際設計時要根據應用場景綜合評估一下。
下面舉一些異步和阻塞的操做實例:
在 Cassandra 中要查詢數據一般會往多個數據節點發送查詢命令,可是要檢查每一個節點返回數據的完整性,因此須要一個異步查詢同步結果的應用場景,部分代碼以下:
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 調優的一些方法。