說到 I/O,想必你們都不會陌生, I/O 英語全稱:Input/Output,即 輸入/輸出,一般 指數據在內部存儲器和外部存儲器或其餘周邊設備之間的輸入和輸出。
好比咱們經常使用的SD 卡、U 盤、移動硬盤等等存儲文件的硬件設備,當咱們將其插入電腦的 usb 硬件接口時,咱們就能夠從電腦中讀取設備中的信息或者寫入信息,這個過程就涉及到 I/O 的操做。java
固然,涉及 I/O 的操做,不只僅侷限於硬件設備的讀寫,還要網絡數據的傳輸,好比,咱們在電腦上用瀏覽器搜索互聯網上的信息,這個過程也涉及到 I/O 的操做。程序員
不管是從磁盤中讀寫文件,仍是在網絡中傳輸數據,能夠說 I/O 主要爲處理人機交互、機與機交互中獲取和交換信息提供的一套解決方案。編程
在 Java 的 IO 體系中,類將近有 80 個,位於java.io包下,感受很複雜,可是這些類大體能夠分紅四組:後端
前兩組主要從傳輸數據的數據格式不一樣,進行分組;後兩組主要從傳輸數據的方式不一樣,進行分組。瀏覽器
雖然 Socket 類並不在java.io包下,可是咱們仍然把它們劃分在一塊兒,由於 I/O 的核心問題,要麼是數據格式影響 I/O 操做,要麼是傳輸方式影響 I/O 操做,也就是將什麼樣的數據寫到什麼地方的問題,I/O 只是人與機器或者機器與機器交互的手段,除了在它們可以完成這個交互功能外,咱們關注的就是如何提升它的運行效率了,而數據格式和傳輸方式是影響效率最關鍵的因素。緩存
本文後面,也是基於這兩個點進行深刻展開分析。服務器
基於字節的輸入和輸出操做接口分別是:InputStream 和 OutputStream 。網絡
InputStream 輸入流的類繼承層次以下圖所示:數據結構
輸入流根據數據節點類型和處理方式,分別能夠劃分出了若干個子類,以下圖:多線程
OutputStream 輸出流的類層次結構也是相似。
OutputStream 輸出流的類繼承層次以下圖所示:
輸出流根據數據節點類型和處理方式,也分別能夠劃分出了若干個子類,以下圖:
在這裏就不詳細的介紹各個子類的使用方法,有興趣的朋友能夠查看 JDK 的 API 說明文檔,筆者也會在後期的文章會進行詳細的介紹,這裏只是重點想說一下,不管是輸入仍是輸出,操做數據的方式能夠組合使用,各個處理流的類並非只操做固定的節點流,好比以下輸出方式:
//將文件輸出流包裝到序列化輸出流中,再將序列化輸出流包裝到緩衝中OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream(new File("fileName")));
另外,輸出流最終寫到什麼地方必需要指定,要麼是寫到硬盤中,要麼是寫到網絡中,從圖中能夠發現,寫網絡實際上也是寫文件,只不過寫到網絡中,須要通過底層操做系統將數據發送到其餘的計算機中,而不是寫入到本地硬盤中。
無論是磁盤仍是網絡傳輸,最小的存儲單元都是字節,而不是字符,因此 I/O 操做的都是字節而不是字符,可是爲何要有操做字符的 I/O 接口呢?
這是由於咱們的程序中一般操做的數據都是以字符形式,爲了程序操做更方便而提供一個直接寫字符的 I/O 接口,僅此而已。
基於字符的輸入和輸出操做接口分別是:Reader 和 Writer ,下圖是字符的 I/O 操做接口涉及到的類結構圖。
Reader 輸入流的類繼承層次以下圖所示:
一樣的,輸入流根據數據節點類型和處理方式,分別能夠劃分出了若干個子類,以下圖:
Writer 輸出流的類繼承層次以下圖所示:
一樣的,輸出流根據數據節點類型和處理方式分類,分別能夠劃分出了若干個子類,以下圖:
無論是 Reader 仍是 Writer 類,它們都只定義了讀取或寫入數據字符的方式,也就是說要麼是讀要麼是寫,可是並無規定數據要寫到哪去,寫到哪去就是咱們後面要討論的基於磁盤或網絡的工做機制。
剛剛咱們說到,無論是磁盤仍是網絡傳輸,最小的存儲單元都是字節,而不是字符,設計字符的緣由是爲了程序操做更方便,那麼怎麼將字符轉化成字節或者將字節轉化成字符呢?
InputStreamReader 和 OutputStreamWriter 就是轉化橋樑。
輸入流字符解碼相關類結構的轉化過程以下圖所示:
從圖上能夠看到,InputStreamReader 類是字節到字符的轉化橋樑, 其中StreamDecoder指的是一個解碼操做類,Charset指的是字符集。
InputStream 到 Reader 的過程須要指定編碼字符集,不然將採用操做系統默認字符集,極可能會出現亂碼問題,StreamDecoder 則是完成字節到字符的解碼的實現類。
打開源碼部分,InputStream 到 Reader 轉化過程,以下圖:
輸出流轉化過程也是相似,以下圖所示:
經過 OutputStreamWriter 類完成字符到字節的編碼過程,由 StreamEncoder 完成編碼過程。
源碼部分,Writer 到 OutputStream 轉化過程,以下圖:
前面介紹了 Java I/O 的操做接口,這些接口主要定義瞭如何操做數據,以及介紹了操做數據格式的方式:字節流和字符流。
還有一個關鍵問題就是數據寫到何處,其中一個主要的處理方式就是將數據持久化到物理磁盤。
咱們知道數據在磁盤的惟一最小描述就是文件,也就是說上層應用程序只能經過文件來操做磁盤上的數據,文件也是操做系統和磁盤驅動器交互的一個最小單元。
在 Java I/O 體系中,File 類是惟一表明磁盤文件自己的對象。
File 類定義了一些與平臺無關的方法來操做文件,包括檢查一個文件是否存在、建立、刪除文件、重命名文件、判斷文件的讀寫權限是否存在、設置和查詢文件的最近修改時間等等操做。
值得注意的是 Java 中一般的 File 並不表明一個真實存在的文件對象,當你經過指定一個路徑描述符時,它就會返回一個表明這個路徑相關聯的一個虛擬對象,這個多是一個真實存在的文件或者是一個包含多個文件的目錄。
例如,讀取一個文件內容,程序以下:
以上面的程序爲例,從硬盤中讀取一段文本字符,操做流程以下圖:
咱們再來看看源碼執行流程。
當咱們傳入一個指定的文件名來建立 File 對象,經過 FileReader 來讀取文件內容時,會自動建立一個FileInputStream對象來讀取文件內容,也就是咱們上文中所說的字節流來讀取文件。
緊接着,會建立一個FileDescriptor的對象,其實這個對象就是真正表明一個存在的文件對象的描述。能夠經過FileInputStream對象調用getFD()方法獲取真正與底層操做系統關聯的文件描述。
因爲咱們須要讀取的是字符格式,因此須要 StreamDecoder 類將byte解碼爲char格式,至於如何從磁盤驅動器上讀取一段數據,由操做系統幫咱們完成。
繼續來講說數據寫到何處的另外一種處理方式:將數據寫入互聯網中以供其餘電腦能訪問。
在現實中,Socket 這個概念沒有一個具體的實體,它是描述計算機之間完成相互通訊一種抽象定義。
打個比方,能夠把 Socket 比做爲兩個城市之間的交通工具,有了它,就能夠在城市之間來回穿梭了。而且,交通工具備多種,每種交通工具也有相應的交通規則。Socket 也同樣,也有多種。大部分狀況下咱們使用的都是基於 TCP/IP 的流套接字,它是一種穩定的通訊協議。
典型的基於 Socket 通訊的應用程序場景,以下圖:
主機 A 的應用程序要想和主機 B 的應用程序通訊,必須經過 Socket 創建鏈接,而創建 Socket 鏈接必須須要底層 TCP/IP 協議來創建 TCP 鏈接。
咱們知道網絡層使用的 IP 協議能夠幫助咱們根據 IP 地址來找到目標主機,可是一臺主機上可能運行着多個應用程序,如何才能與指定的應用程序通訊就要經過 TCP 或 UPD 的地址也就是端口號來指定。這樣就能夠經過一個 Socket 實例表明惟一一個主機上的一個應用程序的通訊鏈路了。
爲了準確無誤地把數據送達目標處,TCP 協議採用了三次握手策略,以下圖:
其中,SYN 全稱爲 Synchronize Sequence Numbers,表示同步序列編號,是 TCP/IP 創建鏈接時使用的握手信號。
ACK 全稱爲 Acknowledge character,即確認字符,表示發來的數據已確認接收無誤。
在客戶機和服務器之間創建正常的 TCP 網絡鏈接時,客戶機首先發出一個SYN 消息,服務器使用SYN + ACK應答表示接收到了這個消息,最後客戶機再以ACK消息響應。
這樣在客戶機和服務器之間才能創建起可靠的 TCP 鏈接,數據才能夠在客戶機和服務器之間傳遞。
簡單流程以下:
完成三次握手以後,客戶端應用程序與服務器應用程序就能夠開始傳送數據了。
傳輸數據是咱們創建鏈接的主要目的,如何經過 Socket 傳輸數據呢?
當客戶端要與服務端通訊時,客戶端首先要建立一個 Socket 實例,默認操做系統將爲這個 Socket 實例分配一個沒有被使用的本地端口號,並建立一個包含本地、遠程地址和端口號的套接字數據結構,這個數據結構將一直保存在系統中直到這個鏈接關閉。
與之對應的服務端,也將建立一個 ServerSocket 實例,ServerSocket 建立比較簡單,只要指定的端口號沒有被佔用,通常實例建立都會成功,同時操做系統也會爲 ServerSocket 實例建立一個底層數據結構,這個數據結構中包含指定監聽的端口號和包含監聽地址的通配符,一般狀況下都是*即監聽全部地址。
以後當調用 accept() 方法時,將進入阻塞狀態,等待客戶端的請求。
咱們先啓動服務端程序,再運行客戶端,服務端收到客戶端發送的信息,服務端打印結果以下:
注意,客戶端只有與服務端創建三次握手成功以後,纔會發送數據,而 TCP/IP 握手過程,底層操做系統已經幫咱們實現了!
當鏈接已經創建成功,服務端和客戶端都會擁有一個 Socket 實例,每一個 Socket 實例都有一個InputStream和OutputStream,正如咱們前面所說的,網絡 I/O 都是以字節流傳輸的,Socket 正是經過這兩個對象來交換數據。
當 Socket 對象建立時,操做系統將會爲 InputStream 和 OutputStream 分別分配必定大小的緩衝區,數據的寫入和讀取都是經過這個緩存區完成的。
寫入端將數據寫到 OutputStream 對應的 SendQ 隊列中,當隊列填滿時,數據將被髮送到另外一端 InputStream 的 RecvQ 隊列中,若是這時 RecvQ 已經滿了,那麼 OutputStream 的 write 方法將會阻塞直到 RecvQ 隊列有足夠的空間容納 SendQ 發送的數據。
值得特別注意的是,緩存區的大小以及寫入端的速度和讀取端的速度很是影響這個鏈接的數據傳輸效率,因爲可能會發生阻塞,因此網絡 I/O 與磁盤 I/O 在數據的寫入和讀取還要有一個協調的過程,若是兩邊同時傳送數據時可能會產生死鎖的問題。
如何提升網絡 IO 傳輸效率、保證數據傳輸的可靠,已經成了工程師們急需解決的問題。
在計算機中,IO 傳輸數據有三種工做方式,分別是BIO、NIO、AIO。
在講解BIO、NIO、AIO以前,咱們先來回顧一下這幾個概念:同步與異步,阻塞與非阻塞。
同步與異步的區別
阻塞和非阻塞的區別
而咱們要講的BIO、NIO、AIO就是同步與異步、阻塞與非阻塞的組合。
BIO 俗稱同步阻塞 IO,一種很是傳統的 IO 模型,好比咱們上面所舉的那個程序例子,就是一個典型的**同步阻塞 IO **的工做方式。
採用 BIO 通訊模型的服務端,一般由一個獨立的Acceptor線程負責監聽客戶端的鏈接。
咱們通常在服務端經過while(true)循環中會調用accept()方法等待監聽客戶端的鏈接,一旦接收到一個鏈接請求,就能夠創建通訊套接字進行讀寫操做,此時不能再接收其餘客戶端鏈接請求,只能等待同當前鏈接的客戶端的操做執行完成, 不過能夠經過多線程來支持多個客戶端的鏈接。
客戶端多線程操做,程序以下:
服務端多線程操做,程序以下:
服務端運行結果,以下:
若是要讓 BIO 通訊模型可以同時處理多個客戶端請求,就必須使用多線程,也就是說它在接收到客戶端鏈接請求以後爲每一個客戶端建立一個新的線程進行鏈路處理,處理完成以後,經過輸出流返回應答給客戶端,線程銷燬。
這就是典型的一請求一應答通訊模型 。
若是出現 100、1000、甚至 10000 個用戶同時訪問服務器,這個時候,若是使用這種模型,那麼服務端也會建立與之相同的線程數量,線程數急劇膨脹可能會致使線程堆棧溢出、建立新線程失敗等問題,最終致使進程宕機或者僵死,不能對外提供服務。
固然,咱們能夠經過使用 Java 中 ThreadPoolExecutor 線程池機制來改善,讓線程的建立和回收成本相對較低,保證了系統有限的資源的控制,實現了 N (客戶端請求數量)大於 M (處理客戶端請求的線程數量)的僞異步 I/O 模型。
爲了解決同步阻塞 I/O 面臨的一個鏈路須要一個線程處理的問題,後來有人對它的線程模型進行了優化,後端經過一個線程池來處理多個客戶端的請求接入,造成客戶端個數 M:線程池最大線程數 N 的比例關係,其中 M 能夠遠遠大於 N,經過線程池能夠靈活地調配線程資源,設置線程的最大值,防止因爲海量併發接入致使資源耗盡。
僞異步 IO 模型圖,以下圖:
採用線程池和任務隊列能夠實現一種叫作僞異步的 I/O 通訊框架,當有新的客戶端接入時,將客戶端的 Socket 封裝成一個 Task 投遞到後端的線程池中進行處理。
Java 的線程池維護一個消息隊列和 N 個活躍線程,對消息隊列中的任務進行處理。
客戶端,程序以下:
服務端,程序以下:
先啓動服務端程序,再啓動客戶端程序,看看運行結果!
服務端,運行結果以下:
客戶端,運行結果以下:
本例中測試的客戶端數量是 30,服務端使用 java 線程池來處理任務,線程數量爲 5 個,服務端不用爲每一個客戶端都建立一個線程,因爲線程池能夠設置消息隊列的大小和最大線程數,所以,它的資源佔用是可控的,不管多少個客戶端併發訪問,都不會致使資源的耗盡和宕機。
在活動鏈接數不是特別高的狀況下,這種模型是還不錯,可讓每個鏈接專一於本身的 I/O 而且編程模型簡單,也不用過多考慮系統的過載、限流等問題。
可是,它的底層仍然是同步阻塞的 BIO 模型,當面對十萬甚至百萬級鏈接的時候,傳統的 BIO 模型真的是無能爲力的,咱們須要一種更高效的 I/O 處理模型來應對更高的併發量。
NIO 中的 N 能夠理解爲Non-blocking,一種同步非阻塞的 I/O 模型,在 Java 1.4 中引入,對應的在java.nio包下。
NIO 新增了Channel、Selector、Buffer等抽象概念,支持面向緩衝、基於通道的 I/O 操做方法。
NIO 提供了與傳統 BIO 模型中的 Socket 和 ServerSocket 相對應的 SocketChannel和 ServerSocketChannel 兩種不一樣的套接字通道實現。
NIO 這兩種通道都支持阻塞和非阻塞兩種模式。阻塞模式使用就像傳統中的支持同樣,比較簡單,可是性能和可靠性都很差;非阻塞模式正好與之相反。
對於低負載、低併發的應用程序,可使用同步阻塞 I/O 來提高開發效率和更好的維護性;對於高負載、高併發的(網絡)應用,應使用 NIO 的非阻塞模式來開發。
咱們先看一下 NIO 涉及到的核心關聯類圖,以下:
上圖中有三個關鍵類:Channel 、Selector 和 Buffer,它們是 NIO 中的核心概念。
咱們仍是用前面的城市交通工具來繼續形容 NIO 的工做方式,這裏的Channel要比Socket更加具體,它能夠比做爲某種具體的交通工具,如汽車或是高鐵、飛機等,而Selector能夠比做爲一個車站的車輛運行調度系統,它將負責監控每輛車的當前運行狀態:是已經出站仍是在路上等等,也就是說它能夠輪詢每一個Channel的狀態。
還有一個Buffer類,你能夠將它看做爲 IO 中Stream,可是它比 IO 中的Stream更加具體化,咱們能夠將它比做爲車上的座位,Channel若是是汽車的話,那麼Buffer就是汽車上的座位,Channel若是是高鐵上,那麼Buffer就是高鐵上的座位,它始終是一個具體的概念,這一點與Stream不一樣。
Socket 中的 Stream只能表明是一個座位,至因而什麼座位由你本身去想象,也就是說你在上車以前並不知道這個車上是否還有沒有座位,也不知道上的是什麼車,由於你並不能選擇,這些信息都已經被封裝在了運輸工具(Socket)裏面了。
NIO 引入了Channel、Buffer 和 Selector就是想把 IO 傳輸過程當中涉及到的信息具體化,讓程序員有機會去控制它們。
當咱們進行傳統的網絡 IO 操做時,好比調用 write() 往 Socket 中的 SendQ 隊列寫數據時,當一次寫的數據超過 SendQ 長度時,操做系統會按照 SendQ 的長度進行分割的,這個過程當中須要將用戶空間數據和內核地址空間進行切換,而這個切換不是程序員能夠控制的,由底層操做系統來幫咱們處理。
而在 Buffer 中,咱們能夠控制 Buffer 的 capacity(容量),而且是否擴容以及如何擴容均可以控制。
理解了這些概念後咱們看一下,實際上它們是如何工做的呢?
仍是以上面的操做爲例子,爲了方便觀看結果,本次的客戶端線程請求數改爲 15 個。
客戶端,程序以下:
服務端,程序以下:
先啓動服務端程序,再啓動客戶端程序,看看運行結果!
服務端,運行結果以下:
客戶端,運行結果以下:
固然,客戶端也不只僅只限制於 IO 的寫法,還可使用SocketChannel來操做客戶端,程序以下:
同樣的,先啓動服務端,再啓動客戶端,客戶端運行結果以下:
從操做上能夠看到,NIO 的操做比傳統的 IO 操做要複雜的多!
Selector被稱爲選擇器,固然你也能夠翻譯爲多路複用器。它是 Java NIO 核心組件中的一個,用於檢查一個或多個Channel(通道)的狀態是否處於鏈接就緒、接受就緒、可讀就緒、可寫就緒。
如此能夠實現單線程管理多個channels,也就是能夠管理多個網絡鏈接。
使用 Selector 的好處在於:相比傳統方式使用多個線程來管理 IO,Selector 使用了更少的線程就能夠處理通道了,而且實現網絡高效傳輸!
雖然 java 中的 nio 傳輸比較快,爲何你們都不肯意用 JDK 原生 NIO 進行開發呢?
從上面的代碼中你們均可以看出來,除了編程複雜、編程模型難以外,還有幾個讓人詬病的問題:
可是,Google 的 Netty 框架的出現,很大程度上改善了 JDK 原生 NIO 所存在的一些讓人難以忍受的問題,關於 Netty 框架,會在後期的文章裏進行介紹。
最後就是 AIO 了,全稱 Asynchronous I/O,能夠理解爲異步 IO,也被稱爲 NIO 2,在 Java 7 中引入了 NIO 的改進版 NIO 2,它是異步非阻塞的 IO 模型,也就是咱們如今所說的 AIO。
異步 IO 是基於事件和回調機制實現的,也就是應用操做以後會直接返回,不會堵塞在那裏,當後臺處理完成,操做系統會通知相應的線程進行後續的操做。
客戶端,程序示例:
服務端,程序示例:
一樣的,先啓動服務端程序,再啓動客戶端程序,看看運行結果!
服務端,運行結果以下:
客戶端端,運行結果以下:
這種組合方式用起來比較複雜,只有在一些很是複雜的分佈式狀況下使用,像集羣之間的消息同步機制通常用這種 I/O 組合方式。如 Cassandra 的 Gossip 通訊機制就是採用異步非阻塞的方式。
Netty 以前也嘗試使用過 AIO,不過又放棄了!
本文闡述的內容較多,從 Java 基本 I/O 類庫結構開始提及,主要介紹了 IO 的傳輸格式和傳輸方式,以及磁盤 I/O 和網絡 I/O 的基本工做方式。
本篇文章主要對 Java 的 IO 體系以及計算機部分網絡基礎知識作了些簡單的介紹,其實每個模塊涉及到的知識都很是很是多,在後期的文章中,會對各個模塊進行詳細的介紹,若是有理解不到的位置,歡迎指出!
以爲文章不錯就給小老弟點個關注吧,更多內容陸續奉上。