1. 程式人生 > 實用技巧 >一文帶你熟悉JAVA IO這個看似很高冷的菇涼

一文帶你熟悉JAVA IO這個看似很高冷的菇涼

Java IO 是一個龐大的知識體系,很多人學著學著就會學懵了,包括我在內也是如此,所以本文將會從 Java 的 BIO 開始,一步一步深入學習,引出 JDK1.4 之後出現的 NIO 技術,對比 NIO 與 BIO 的區別,然後對 NIO 中重要的三個組成部分進行講解(緩衝區、通道、選擇器),最後實現一個簡易的客戶端與伺服器通訊功能。

傳統的 BIO

Java IO流是一個龐大的生態環境,其內部提供了很多不同的輸入流和輸出流,細分下去還有位元組流和字元流,甚至還有緩衝流提高 IO 效能,轉換流將位元組流轉換為字元流······看到這些就已經對 IO 產生恐懼了,在日常開發中少不了對檔案的 IO 操作,雖然 apache 已經提供了Commons IO

這種封裝好的元件,但面對特殊場景時,我們仍需要自己去封裝一個高效能的檔案 IO 工具類,本文將會解析 Java IO 中涉及到的各個類,以及講解如何正確、高效地使用它們。

BIO NIO 和 AIO 的區別

我們會以一個經典的燒開水的例子通俗地講解它們之間的區別

型別燒開水
BIO 一直監測著某個水壺,該水壺燒開水後再監測下一個水壺
NIO 每隔一段時間就看看所有水壺的狀態,哪個水壺燒開水就去處理哪個水壺
AIO 不用監測水壺,每個水壺燒開水後都會主動通知執行緒說:“我的水燒開了,來處理我吧”

BIO (同步阻塞 I/O)

這裡假設一個燒開水的場景,有一排水壺在燒開水,BIO的工作模式就是, 小菠蘿一直看著著這個水壺,直到這個水壺燒開,才去處理下一個水壺。執行緒在等待水壺燒開的時間段什麼都沒有做。

NIO(同步非阻塞 I/O)

還拿燒開水來說,NIO的做法是小菠蘿一邊玩著手機,每隔一段時間就看一看每個水壺的狀態,看看是否有水壺的狀態發生了改變,如果某個水壺燒開了,可以先處理那個水壺,然後繼續玩手機,繼續隔一段時間又看看每個水壺的狀態。

AIO (非同步非阻塞 I/O)

小菠蘿覺得每隔一段時間就去看一看水壺太費勁了,於是購買了一批燒開水時可以嗶嗶響的水壺,於是開始燒水後,小菠蘿就直接去客廳玩手機了,水燒開時,就發出“嗶嗶”的響聲,通知小菠蘿來關掉水壺。

什麼是流

知識科普:我們知道任何一個檔案都是以二進位制形式存在於裝置中,計算機就只有01,你能看見的東西全部都是由這兩個數字組成,你看這篇文章時,這篇文章也是由01組成,只不過這些二進位制串經過各種轉換演變成一個個文字、一張張圖片躍然螢幕上。

而流就是將這些二進位制串在各種裝置之間進行傳輸,如果你覺得有些抽象,我舉個例子就會好理解一些:

下圖是一張圖片,它由01串組成,我們可以通過程式把一張圖片拷貝到一個資料夾中,

把圖片轉化成二進位制資料集,把資料一點一點地傳遞到資料夾中 , 類似於水的流動 , 這樣整體的資料就是一個數據流

IO 流讀寫資料的特點:

  • 順序讀寫。讀寫資料時,大部分情況下都是按照順序讀寫,讀取時從檔案開頭的第一個位元組到最後一個位元組,寫出時也是也如此(RandomAccessFile 可以實現隨機讀寫)

  • 位元組陣列。讀寫資料時本質上都是對位元組陣列做讀取和寫出操作,即使是字元流,也是在位元組流基礎上轉化為一個個字元,所以位元組陣列是 IO 流讀寫資料的本質。

流的分類

根據資料流向不同分類:輸入流 和 輸出流

  • 輸入流:從磁碟或者其它裝置中將資料輸入到程序中

  • 輸出流:將程序中的資料輸出到磁碟或其它裝置上儲存

1

圖示中的硬碟只是其中一種裝置,還有非常多的裝置都可以應用在IO流中,例如:印表機、硬碟、顯示器、手機······

根據處理資料的基本單位不同分類:位元組流 和 字元流

  • 位元組流:以位元組(8 bit)為單位做資料的傳輸

  • 字元流:以字元為單位(1字元 = 2位元組)做資料的傳輸

字元流的本質也是通過位元組流讀取,Java 中的字符采用 Unicode 標準,在讀取和輸出的過程中,通過以字元為單位,查詢對應的碼錶將位元組轉換為對應的字元。

面對位元組流和字元流,很多讀者都有疑惑:什麼時候需要用位元組流,什麼時候又要用字元流?

我這裡做一個簡單的概括,你可以按照這個標準去使用:

字元流只針對字元資料進行傳輸,所以如果是文字資料,優先採用字元流傳輸;除此之外,其它型別的資料(圖片、音訊等),最好還是以位元組流傳輸。

根據這兩種不同的分類,我們就可以做出下面這個表格,裡面包含了 IO 中最核心的 4 個頂層抽象類:

資料流向 / 資料型別位元組流字元流
輸入流 InputStream Reader
輸出流 OutputStream Writer

現在看 IO 是不是有一些思路了,不會覺得很混亂了,我們來看這四個類下的所有成員。


[來自於 cxuan 的 《Java基礎核心總結》]

看到這麼多的類是不是又開始覺得混亂了,不要慌,位元組流和字元流下的輸入流和輸出流大部分都是一一對應的,有了上面的表格支撐,我們不需要再擔心看見某個類會懵逼的情況了。

看到Stream就知道是位元組流,看到Reader / Writer就知道是字元流。

這裡還要額外補充一點:Java IO 提供了位元組流轉換為字元流的轉換類,稱為轉換流。

轉換流 / 資料型別位元組流與字元流之間的轉換
(輸入)位元組流 => 字元流 InputStreamReader
(輸出)字元流 => 位元組流 OutputStreamWriter

注意位元組流與字元流之間的轉換是有嚴格定義的:

  • 輸入流:可以將位元組流 => 字元流

  • 輸出流:可以將字元流 => 位元組流

為什麼在輸入流不能字元流 => 位元組流,輸出流不能位元組流 => 字元流?

在儲存裝置上,所有資料都是以位元組為單位儲存的,所以輸入到記憶體時必定是以位元組為單位輸入,輸出到儲存裝置時必須是以位元組為單位輸出,位元組流才是計算機最根本的儲存方式,而字元流是在位元組流的基礎上對資料進行轉換,輸出字元,但每個字元依舊是以位元組為單位儲存的。

節點流和處理流

在這裡需要額外插入一個小節講解節點流和處理流。

  • 節點流:節點流是真正傳輸資料的流物件,用於向特定的一個地方(節點)讀寫資料,稱為節點流。例如 FileInputStream

  • 處理流:處理流是對節點流的封裝,使用外層的處理流讀寫資料,本質上是利用節點流的功能,外層的處理流可以提供額外的功能。處理流的基類都是以Filter開頭。

1

上圖將ByteArrayInputStream封裝成DataInputStream,可以將輸入的位元組陣列轉換為對應資料型別的資料。例如希望讀入int型別資料,就會以2個位元組為單位轉換為一個數字。

Java IO 的核心類 File

Java 提供了 File類,它指向計算機作業系統中的檔案和目錄,通過該類只能訪問檔案和目錄,無法訪問內容。它內部主要提供了3種操作:

  • 訪問檔案的屬性:絕對路徑、相對路徑、檔名······

  • 檔案檢測:是否檔案、是否目錄、檔案是否存在、檔案的讀/寫/執行許可權······

  • 操作檔案:建立目錄、建立檔案、刪除檔案······

上面舉例的操作都是在開發中非常常用的,File 類遠不止這些操作,更多的操作可以直接去 API 文件中根據需求查詢。

訪問檔案的屬性:

API功能
String getAbsolutePath() 返回該檔案處於系統中的絕對路徑名
String getPath() 返回該檔案的相對路徑,通常與 new File() 傳入的路徑相同
String getName() 返回該檔案的檔名

檔案檢測:

API功能
boolean isFIle() 校驗該路徑指向是否一個檔案
boolean isDirectory() 校驗該路徑指向是否一個目錄
boolean isExist() 校驗該路徑指向的檔案/目錄是否存在
boolean canWrite() 校驗該檔案是否可寫
boolean canRead() 校驗該檔案是否可讀
boolean canExecute() 校驗該檔案/目錄是否可以被執行

操作檔案:

API功能
mkdirs() 遞迴建立多個資料夾,路徑中間有可能某些資料夾不存在
createNewFile() 建立新檔案,它是一個原子操作,有兩步:檢查檔案是否存在、建立新檔案
delete() 刪除檔案或目錄,刪除目錄時必須保證該目錄為空

多瞭解一些

檔案的讀/寫/執行許可權,在Windows中通常表現不出來,而在Linux中可以很好地體現這一點,原因是Linux有嚴格的使用者許可權分組,不同分組下的使用者對檔案有不同的操作許可權,所以這些方法在Linux下會比在Windows下更好理解。下圖是 redis 資料夾中的一些檔案的詳細資訊,被紅框標註的是不同使用者的執行許可權:

  • r(Read):代表該檔案可以被當前使用者讀,操作許可權的序號是4

  • w(Write):代表該檔案可以被當前使用者寫,操作許可權的序號是2

  • x(Execute):該檔案可以被當前使用者執行,操作許可權的序號是1


root root分別代表:當前檔案的所有者,當前檔案所屬的使用者分組。Linux 下檔案的操作許可權分為三種使用者:

  • 檔案所有者:擁有的許可權是紅框中的前三個字母,-代表沒有某個許可權

  • 檔案所在組的所有使用者:擁有的許可權是紅框中的中間三個字母

  • 其它組的所有使用者:擁有的許可權是紅框中的最後三個字母

Java IO 流物件

回顧流的分類有2種:

  • 根據資料流向分為輸入流和輸出流

  • 根據資料型別分為位元組流和字元流

所以,本小節將以位元組流和字元流作為主要分割點,在其內部再細分為輸入流和輸出流進行講解。


位元組流物件

位元組流物件大部分輸入流和輸出流都是成雙成對地出現,所以學習的時候可以將輸入流和輸出流一一對應的流物件關聯起來,輸入流和輸出流只是資料流向不同,而處理資料的方式可以是相同的。

注意不要認為用什麼流讀入資料,就需要用對應的流寫出資料,在 Java 中沒有這麼規定,下圖只是各個物件之間的一個對應關係,不是兩個類使用時必須強制關聯使用。

下面有非常多的類,我會介紹基類的方法,瞭解這些方法是非常有必要的,子類的功能基於父類去擴充套件,只有真正瞭解父類在做什麼,學習子類的成本就會下降。


InputStream

InputStream 是位元組輸入流的抽象基類,提供了通用的讀方法,讓子類使用或重寫它們。下面是 InputStream 常用的重要的方法。

重要方法功能
public abstract int read() 從輸入流中讀取下一個位元組,讀到尾部時返回 -1
public int read(byte b[]) 從輸入流中讀取長度為 b.length 個位元組放入位元組陣列 b 中
public int read(byte b[], int off, int len) 從輸入流中讀取指定範圍的位元組資料放入位元組陣列 b 中
public void close() 關閉此輸入流並釋放與該輸入流相關的所有資源

還有其它一些不太常用的方法,我也列出來了。

其它方法功能
public long skip(long n) 跳過接下來的 n 個位元組,返回實際上跳過的位元組數
public long available() 返回下一次可讀取(跳過)且不會被方法阻塞的位元組數的估計值
public synchronized void mark(int readlimit) 標記此輸入流的當前位置,對 reset() 方法的後續呼叫將會重新定位在 mark() 標記的位置,可以重新讀取相同的位元組
public boolean markSupported() 判斷該輸入流是否支援 mark() 和 reset() 方法,即能否重複讀取位元組
public synchronized void reset() 將流的位置重新定位在最後一次呼叫 mark() 方法時的位置


(1)ByteArrayInputStream

ByteArrayInputStream 內部包含一個buf位元組陣列緩衝區,該緩衝區可以從流中讀取的位元組數,使用pos指標指向讀取下一個位元組的下標位置,內部還維護了一個count屬性,代表能夠讀取count個位元組。

bytearrayinputstream

必須保證 pos 嚴格小於 count,而 count 嚴格小於 buf.length 時,才能夠從緩衝區中讀取資料

(2)FileInputStream

檔案輸入流,從檔案中讀入位元組,通常對檔案的拷貝、移動等操作,可以使用該輸入流把檔案的位元組讀入記憶體中,然後再利用輸出流輸出到指定的位置上。

(3)PipedInputStream

管道輸入流,它與 PipedOutputStream 成對出現,可以實現多執行緒中的管道通訊。PipedOutputStream 中指定與特定的 PipedInputStream 連線,PipedInputStream 也需要指定特定的 PipedOutputStream 連線,之後輸出流不斷地往輸入流的buffer緩衝區寫資料,而輸入流可以從緩衝區中讀取資料。

(4)ObjectInputStream

物件輸入流,用於物件的反序列化,將讀入的位元組資料反序列化為一個物件,實現物件的持久化儲存。

(5)PushBackInputStream

它是 FilterInputStream 的子類,是一個處理流,它內部維護了一個緩衝陣列buf

  • 在讀入位元組的過程中可以將讀取到的位元組資料回退給緩衝區中儲存,下次可以再次從緩衝區中讀出該位元組資料。所以PushBackInputStream 允許多次讀取輸入流的位元組資料,只要將讀到的位元組放回緩衝區即可。

需要注意的是如果回推位元組時,如果緩衝區已滿,會丟擲IOException異常。

它的應用場景:對資料進行分類規整。

假如一個檔案中儲存了數字和字母兩種型別的資料,我們需要將它們交給兩種執行緒各自去收集自己負責的資料,如果採用傳統的做法,把所有的資料全部讀入記憶體中,再將資料進行分離,面對大檔案的情況下,例如1G、2G,傳統的輸入流在讀入陣列後,由於沒有緩衝區,只能對資料進行拋棄,這樣每個執行緒都要讀一遍檔案。

使用 PushBackInputStream 可以讓一個專門的執行緒讀取檔案,喚醒不同的執行緒讀取字元:

  • 第一次讀取緩衝區的資料,判斷該資料由哪些執行緒讀取

  • 回退資料,喚醒對應的執行緒讀取資料

  • 重複前兩步

  • 關閉輸入流

到這裡,你是否會想到AQSCondition等待佇列,多個執行緒可以在不同的條件上等待被喚醒。

(6)BufferedInputStream

緩衝流,它是一種處理流,對節點流進行封裝並增強,其內部擁有一個buffer緩衝區,用於快取所有讀入的位元組,當緩衝區滿時,才會將所有位元組傳送給客戶端讀取,而不是每次都只發送一部分資料,提高了效率。

(7)DataInputStream

資料輸入流,它同樣是一種處理流,對節點流進行封裝後,能夠在內部對讀入的位元組轉換為對應的 Java 基本資料型別。

(8)SequenceInputStream

將兩個或多個輸入流看作是一個輸入流依次讀取,該類的存在與否並不影響整個 IO 生態,在程式中也能夠做到這種效果

(9)StringBufferInputStream

將字串中每個字元的低 8 位轉換為位元組讀入到位元組陣列中,目前已過期

InputStream 總結:

  • InputStream 是所有輸入位元組流的抽象基類

  • ByteArrayInputStream 和 FileInputStream 是兩種基本的節點流,他們分別從位元組陣列和本地檔案中讀取資料

  • DataInputStream、BufferedInputStream 和 PushBackInputStream 都是處理流,對基本的節點流進行封裝並增強

  • PipiedInputStream 用於多執行緒通訊,可以與其它執行緒公用一個管道,讀取管道中的資料。

  • ObjectInputStream 用於物件的反序列化,將物件的位元組資料讀入記憶體中,通過該流物件可以將位元組資料轉換成對應的物件

OutputStream

OutputStream 是位元組輸出流的抽象基類,提供了通用的寫方法,讓繼承的子類重寫和複用。

方法功能
public abstract void write(int b) 將指定的位元組寫出到輸出流,寫入的位元組是引數 b 的低 8 位
public void write(byte b[]) 將指定位元組陣列中的所有位元組寫入到輸出流當中
public void write(byte b[], int off, int len) 指定寫入的起始位置 offer,位元組數為 len 的位元組陣列寫入到輸出流當中
public void flush() 重新整理此輸出流,並強制寫出所有緩衝的輸出位元組到指定位置,每次寫完都要呼叫
public void close() 關閉此輸出流並釋放與此流關聯的所有系統資源


OutputStream 中大多數的類和 InputStream 是對應的,只不過資料的流向不同而已。從上面的圖可以看出:

  • OutputStream 是所有輸出位元組流的抽象基類

  • ByteArrayOutputStream 和 FileOutputStream 是兩種基本的節點流,它們分別向位元組陣列和本地檔案寫出資料

  • DataOutputStream、BufferedOutputStream 是處理流,前者可以將位元組資料轉換成基本資料型別寫出到檔案中;後者是緩衝位元組陣列,只有在緩衝區滿時,才會將所有的位元組寫出到目的地,減少了 IO 次數。

  • PipedOutputStream 用於多執行緒通訊,可以和其它執行緒共用一個管道,向管道中寫入資料

  • ObjectOutputStream 用於物件的序列化,將物件轉換成位元組陣列後,將所有的位元組都寫入到指定位置中

  • PrintStream 在 OutputStream 基礎之上提供了增強的功能,即可以方便地輸出各種型別的資料(而不僅限於byte型)的格式化表示形式,且 PrintStream 的方法從不丟擲 IOEception,其原理是寫出時將各個資料型別的資料統一轉換為 String 型別,我會在講解完

字元流物件

字元流物件也會有對應關係,大多數的類可以認為是操作的資料從位元組陣列變為字元,類的功能和位元組流物件是相似的。

字元輸入流和位元組輸入流的組成非常相似,字元輸入流是對位元組輸入流的一層轉換,所有檔案的儲存都是位元組的儲存,在磁碟上保留的不是檔案的字元,而是先把字元編碼成位元組,再儲存到檔案中。在讀取檔案時,讀入的也是一個一個位元組組成的位元組序列,而 Java 虛擬機器通過將位元組序列,按照2個位元組為單位轉換為 Unicode 字元,實現位元組到字元的對映。


Reader

Reader 是字元輸入流的抽象基類,它內部的重要方法如下所示。

重要方法方法功能
public int read(java.nio.CharBuffer target) 將讀入的字元存入指定的字元緩衝區中
public int read() 讀取一個字元
public int read(char cbuf[]) 讀入字元放入整個字元陣列中
abstract public int read(char cbuf[], int off, int len) 將字元讀入字元陣列中的指定範圍中

還有其它一些額外的方法,與位元組輸入流基類提供的方法是相同的,只是作用的物件不再是位元組,而是字元。

  • Reader 是所有字元輸入流的抽象基類

  • CharArrayReader 和 StringReader 是兩種基本的節點流,它們分別從讀取字元陣列和字串資料,StringReader 內部是一個String變數值,通過遍歷該變數的字元,實現讀取字串,本質上也是在讀取字元陣列

  • PipedReader 用於多執行緒中的通訊,從共用地管道中讀取字元資料

  • BufferedReader 是字元輸入緩衝流,將讀入的資料放入字元緩衝區中,實現高效地讀取字元

  • InputStreamReader 是一種轉換流,可以實現從位元組流轉換為字元流,將位元組資料轉換為字元

Writer

Reader 是字元輸出流的抽象基類,它內部的重要方法如下所示。

重要方法方法功能
public void write(char cbuf[]) 將 cbuf 字元陣列寫出到輸出流
abstract public void write(char cbuf[], int off, int len) 將指定範圍的 cbuf 字元陣列寫出到輸出流
public void write(String str) 將字串 str 寫出到輸出流,str 內部也是字元陣列
public void write(String str, int off, int len) 將字串 str 的某一部分寫出到輸出流
abstract public void flush() 重新整理,如果資料儲存在緩衝區,呼叫該方法才會真正寫出到指定位置
abstract public void close() 關閉流物件,每次 IO 執行完畢後都需要關閉流物件,釋放系統資源

  • Writer 是所有的輸出字元流的抽象基類

  • CharArrayWriter、StringWriter 是兩種基本的節點流,它們分別向Char 陣列、字串中寫入資料。StringWriter 內部儲存了 StringBuffer 物件,可以實現字串的動態增長

  • PipedWriter 可以向共用的管道中寫入字元資料,給其它執行緒讀取。

  • BufferedWriter是緩衝輸出流,可以將寫出的資料快取起來,緩衝區滿時再呼叫 flush() 寫出資料,減少 IO 次數。

  • PrintWriter 和 PrintStream 類似,功能和使用也非常相似,只是寫出的資料是字元而不是位元組。

  • OutputStreamWriter將字元流轉換為位元組流,將字元寫出到指定位置

位元組流與字元流的轉換

從任何地方把資料讀入到記憶體都是先以位元組流形式讀取,即使是使用字元流去讀取資料,依然成立,因為資料永遠是以位元組的形式存在於網際網路和硬體裝置中,字元流是通過字符集的對映,才能夠將位元組轉換為字元。

所以 Java 提供了兩種轉換流:

  • InputStreamReader:從位元組流轉換為字元流,將位元組資料轉換為字元資料讀入到記憶體

  • OutputStreamWriter:從字元流轉換為位元組流,將字元資料轉換為位元組資料寫出到指定位置

瞭解了 Java 傳統的 BIO 中字元流和位元組流的主要成員之後,至少要掌握以下兩個關鍵點:

(1)傳統的 BIO 是以為基本單位處理資料的,想象成水流,一點點地傳輸位元組資料,IO 流傳輸的過程永遠是以位元組形式傳輸。

(2)位元組流和字元流的區別在於操作的資料單位不相同,字元流是通過將位元組資料通過字符集對映成對應的字元,字元流本質上也是位元組流。

接下來我們再繼續學習 NIO 知識,NIO 是當下非常火熱的一種 IO 工作方式,它能夠解決傳統 BIO 的痛點:阻塞。

  • BIO 如果遇到 IO 阻塞時,執行緒將會被掛起,直到 IO 完成後才喚醒執行緒,執行緒切換帶來了額外的開銷。

  • BIO 中每個 IO 都需要有對應的一個執行緒去專門處理該次 IO 請求,會讓伺服器的壓力迅速提高。

我們希望做到的是當執行緒等待 IO 完成時能夠去完成其它事情,當 IO 完成時執行緒可以回來繼續處理 IO 相關操作,不必乾乾的坐等 IO 完成。在 IO 處理的過程中,能夠有一個專門的執行緒負責監聽這些 IO 操作,通知伺服器該如何操作。所以,我們聊到 IO,不得不去接觸 NIO 這一塊硬骨頭。

新潮的 NIO

我們來看看 BIO 和 NIO 的區別,BIO 是面向流的 IO,它建立的通道都是單向的,所以輸入和輸出流的通道不相同,必須建立2個通道,通道內的都是傳輸==0101001···==的位元組資料。

而在 NIO 中,不再是面向流的 IO 了,而是面向緩衝區,它會建立一個通道(Channel),該通道我們可以理解為鐵路,該鐵路上可以運輸各種貨物,而通道上會有一個緩衝區(Buffer)用於儲存真正的資料,緩衝區我們可以理解為一輛火車。

通道(鐵路)只是作為運輸資料的一個連線資源,而真正儲存資料的是緩衝區(火車)。即通道負責傳輸,緩衝區負責儲存。

理解了上面的圖之後,BIO 和 NIO 的主要區別就可以用下面這個表格簡單概括。

BIONIO
面向流(Stream) 面向緩衝區(Buffer)
單向通道 雙向通道
阻塞 IO 非阻塞 IO
選擇器(Selectors)

緩衝區(Buffer)

緩衝區是儲存資料的區域,在 Java 中,緩衝區就是陣列,為了可以操作不同資料型別的資料,Java 提供了許多不同型別的緩衝區,除了布林型別以外,其它基本資料型別都有對應的緩衝區陣列物件。

為什麼沒有布林型別的緩衝區呢?

在 Java 中,boolean 型別資料只佔用1 bit,而在 IO 傳輸過程中,都是以位元組為單位進行傳輸的,所以 boolean 的 1 bit 完全可以使用 byte 型別的某一位,或者 int 型別的某一位來表示,沒有必要為了這 1 bit 而專門提供多一個緩衝區。

緩衝區解釋
ByteBuffer 儲存位元組資料的緩衝區
CharBuffer 儲存字元資料的緩衝區
ShortBuffer 儲存短整型資料的緩衝區
IntBuffer 儲存整型資料的緩衝區
LongBuffer 儲存長整型資料的緩衝區
FloatBuffer 儲存單精度浮點型資料的緩衝區
DoubleBuffer 儲存雙精度浮點型資料的緩衝區

分配一個緩衝區的方式都高度一致:使用allocate(int capacity)方法。

例如需要分配一個 1024 大小的位元組陣列,程式碼就是下面這樣子。

ByteBufferbyteBuffer=ByteBuffer.allocate(1024);

緩衝區讀寫資料的兩個核心方法:

  • put():將資料寫入到緩衝區中

  • get():從緩衝區中讀取資料

緩衝區的重要屬性:

  • capacity:緩衝區中最大儲存資料的容量,一旦宣告則無法改變

  • limit:表示緩衝區中可以操作資料的大小,limit 之後的資料無法進行讀寫。必須滿足 limit <= capacity

  • position:當前緩衝區中正在操作資料的下標位置,必須滿足 position <= limit

  • mark:標記位置,呼叫 reset() 將 position 位置調整到 mark 屬性指向的下標位置,實現多次讀取資料

緩衝區為高效讀寫資料而提供的其它輔助方法:

  • flip():可以實現讀寫模式的切換,我們可以看看裡面的原始碼

publicfinalBufferflip(){
limit=position;
position=0;
mark=-1;
returnthis;
}

呼叫 flip() 會將可操作的大小 limit 設定為當前寫的位置,操作資料的起始位置 position 設定為 0,即從頭開始讀取資料。

  • rewind():可以將 position 位置設定為 0,再次讀取緩衝區中的資料

  • clear():清空整個緩衝區,它會將 position 設定為 0,limit 設定為 capacity,可以寫整個緩衝區

更多的方法可以去查閱 API 文件,本文礙於篇幅原因就不貼出其它方法了,主要是要理解緩衝區的作用

我們來看一個簡單的例子

publicClassMain{
publicstaticvoidmain(String[]args){
//分配記憶體大小為11的整型快取區
IntBufferbuffer=IntBuffer.allocate(11);
//往buffer裡寫入2個整型資料
for(inti=0;i<2;++i){
intrandomNum=newSecureRandom().nextInt();
buffer.put(randomNum);
}
//將Buffer從寫模式切換到讀模式
buffer.flip();
System.out.println("position>>"+buffer.position()
+"limit>>"+buffer.limit()
+"capacity>>"+buffer.capacity());
//讀取buffer裡的資料
while(buffer.hasRemaining()){
System.out.println(buffer.get());
}
System.out.println("position>>"+buffer.position()
+"limit>>"+buffer.limit()
+"capacity>>"+buffer.capacity());
}
}

執行結果如下圖所示,首先我們往緩衝區中寫入 2 個數據,position 在寫模式下指向下標 2,然後呼叫 flip() 方法切換為讀模式,limit 指向下標 2,position 從 0 開始讀資料,讀到下標為 2 時發現到達 limit 位置,不可繼續讀。

整個過程可以用下圖來理解,呼叫 flip() 方法以後,讀出資料的同時 position 指標不斷往後挪動,到達 limit 指標的位置時,該次讀取操作結束。

介紹完緩衝區後,我們知道它是儲存資料的空間,程序可以將緩衝區中的資料讀取出來,也可以寫入新的資料到緩衝區,那緩衝區的資料從哪裡來,又怎麼寫出去呢?接下來我們需要學習傳輸資料的介質:通道(Channel)

通道(Channel)

上面我們介紹過,通道是作為一種連線資源,作用是傳輸資料,而真正儲存資料的是緩衝區,所以介紹完緩衝區後,我們來學習通道這一塊。

通道是可以雙向讀寫的,傳統的 BIO 需要使用輸入/輸出流表示資料的流向,在 NIO 中可以減少通道資源的消耗。

通道類都儲存在java.nio.channels包下,我們日常用到的幾個重要的類有 4 個:

IO 通道型別具體類
檔案 IO FileChannel(用於檔案讀寫、操作檔案的通道)
TCP 網路 IO SocketChannel(用於讀寫資料的 TCP 通道)、ServerSocketChannel(監聽客戶端的連線)
UDP 網路 IO DatagramChannel(收發 UDP 資料報的通道)

可以通過getChannel()方法獲取一個通道,支援獲取通道的類如下:

  • 檔案 IO:FileInputStream、FileOutputStream、RandomAccessFile

  • TCP 網路 IO:Socket、ServerSocket

  • UDP 網路 IO:DatagramSocket

示例:檔案拷貝案例

我們來看一個利用通道拷貝檔案的例子,需要下面幾個步驟:

  • 開啟原檔案的輸入流通道,將位元組資料讀入到緩衝區中

  • 開啟目的檔案的輸出流通道,將緩衝區中的資料寫到目的地

  • 關閉所有流和通道(重要!)

這是一張小菠蘿的照片,它存在於d:\小菠蘿\資料夾下,我們將它拷貝到d:\小菠蘿分身\資料夾下。

publicclassTest{
/**緩衝區的大小*/
publicstaticfinalintSIZE=1024;

publicstaticvoidmain(String[]args)throwsIOException{
//開啟檔案輸入流
FileChannelinChannel=newFileInputStream("d:\小菠蘿\小菠蘿.jpg").getChannel();
//開啟檔案輸出流
FileChanneloutChannel=newFileOutputStream("d:\小菠蘿分身\小菠蘿-拷貝.jpg").getChannel();
//分配1024個位元組大小的緩衝區
ByteBufferdsts=ByteBuffer.allocate(SIZE);
//將資料從通道讀入緩衝區
while(inChannel.read(dsts)!=-1){
//切換緩衝區的讀寫模式
dsts.flip();
//將緩衝區的資料通過通道寫到目的地
outChannel.write(dsts);
//清空緩衝區,準備下一次讀
dsts.clear();
}
inChannel.close();
outChannel.close();
}

}

我畫了一張圖幫助你理解上面的這一個過程。

有人會問,NIO 的檔案拷貝和傳統 IO 流的檔案拷貝有何不同呢?我們在程式設計時感覺它們沒有什麼區別呀,貌似只是 API 不同罷了,我們接下來就去看看這兩者之間的區別吧。

BIO 和 NIO 拷貝檔案的區別

這個時候就要來了解了解作業系統底層是怎麼對 IO 和 NIO 進行區別的,我會用盡量通俗的文字帶你理解,可能並不是那麼嚴謹。

作業系統最重要的就是核心,它既可以訪問受保護的記憶體,也可以訪問底層硬體裝置,所以為了保護核心的安全,作業系統將底層的虛擬空間分為了使用者空間和核心空間,其中使用者空間就是給使用者程序使用的,核心空間就是專門給作業系統底層去使用的。

接下來,有一個 Java 程序希望把小菠蘿這張圖片從磁碟上拷貝,那麼核心空間和使用者空間都會有一個緩衝區

  • 這張照片就會從磁碟中讀出到核心緩衝區中儲存,然後作業系統將核心緩衝區中的這張圖片位元組資料拷貝到使用者程序的緩衝區中儲存下來,對應著下面這幅圖

  • 然後使用者程序會希望把緩衝區中的位元組資料寫到磁碟上的另外一個地方,會將資料拷貝到 Socket 緩衝區中,最終作業系統再將 Socket 緩衝區的資料寫到磁碟的指定位置上。

這一輪操作下來,我們數數經過了幾次資料的拷貝?4次。有 2 次是核心空間和使用者空間之間的資料拷貝,這兩次拷貝涉及到使用者態和核心態的切換,需要CPU參與進來,進行上下文切換。而另外 2 次是硬碟和核心空間之間的資料拷貝,這個過程利用到 DMA與系統記憶體交換資料,不需要 CPU 的參與。

導致 IO 效能瓶頸的原因:核心空間與使用者空間之間資料過多無意義的拷貝,以及多次上下文切換

操作狀態
使用者程序請求讀取資料 使用者態 -> 核心態
作業系統核心返回資料給使用者程序 核心態 -> 使用者態
使用者程序請求寫資料到硬碟 使用者態 -> 核心態
作業系統返回操作結果給使用者程序 核心態 -> 使用者態

在使用者空間與核心空間之間的操作,會涉及到上下文的切換,這裡需要 CPU 的干預,而資料在兩個空間之間來回拷貝,也需要 CPU 的干預,這無疑會增大 CPU 的壓力,NIO 是如何減輕 CPU 的壓力?運用作業系統的零拷貝技術。

作業系統的零拷貝

所以,作業系統出現了一個全新的概念,解決了 IO 瓶頸:零拷貝。零拷貝指的是核心空間與使用者空間之間的零次拷貝。

零拷貝可以說是 IO 的一大救星,作業系統底層有許多種零拷貝機制,我這裡僅針對 Java NIO 中使用到的其中一種零拷貝機制展開講解。

在 Java NIO 中,零拷貝是通過使用者空間和核心空間的緩衝區共享一塊實體記憶體實現的,也就是說上面的圖可以演變成這個樣子。

​這時,無論是使用者空間還是核心空間操作自己的緩衝區,本質上都是操作這一塊共享記憶體中的緩衝區資料,省去了使用者空間和核心空間之間的資料拷貝操作。

現在我們重新來拷貝檔案,就會變成下面這個步驟:

  • 使用者程序通過系統呼叫read()請求讀取檔案到使用者空間緩衝區(第一次上下文切換),使用者態 -> 核心態,資料從硬碟讀取到核心空間緩衝區中(第一次資料拷貝)

  • 系統呼叫返回到使用者程序(第二次上下文切換),此時使用者空間與核心空間共享這一塊記憶體(緩衝區),所以不需要從核心緩衝區拷貝到使用者緩衝區

  • 使用者程序發出write()系統呼叫請求寫資料到硬碟上(第三次上下文切換),此時需要將核心空間緩衝區中的資料拷貝到核心的 Socket 緩衝區中(第二次資料拷貝)

  • 由 DMA 將 Socket 緩衝區的內容寫到硬碟上(第三次資料拷貝),write()系統呼叫返回(第四次上下文切換)

整個過程就如下面這幅圖所示。

圖中,需要 CPU 參與工作的步驟只有第③個步驟,對比於傳統的 IO,CPU 需要在使用者空間與核心空間之間參與拷貝工作,需要無意義地佔用 2 次 CPU 資源,導致 CPU 資源的浪費。

下面總結一下作業系統中零拷貝的優點:

  • 降低 CPU 的壓力:避免 CPU 需要參與核心空間與使用者空間之間的資料拷貝工作

  • 減少不必要的拷貝:避免使用者空間與核心空間之間需要進行資料拷貝

上面的圖示可能並不嚴謹,對於你理解零拷貝會有一定的幫助,關於零拷貝的知識點可以去查閱更多資料哦,這是一門大學問。

介紹完通道後,我們知道它是用於傳輸資料的一種介質,而且是可以雙向讀寫的,那麼如果放在網路 IO 中,這些通道如果有資料就緒時,伺服器是如何發現並處理的呢?接下來我們去學習 NIO 中的最後一個重要知識點:選擇器(Selector)

選擇器(Selectors)

選擇器是提升 IO 效能的靈魂之一,它底層利用了多路複用 IO機制,讓選擇器可以監聽多個 IO 連線,根據 IO 的狀態響應到伺服器端進行處理。通俗地說:選擇器可以監聽多個 IO 連線,而傳統的 BIO 每個 IO 連線都需要有一個執行緒去監聽和處理。

圖中很明顯的顯示了在 BIO 中,每個 Socket 都需要有一個專門的執行緒去處理每個請求,而在 NIO 中,只需要一個 Selector 即可監聽各個 Socket 請求,而且 Selector 並不是阻塞的,所以不會因為多個執行緒之間切換導致上下文切換帶來的開銷。


在 Java NIO 中,選擇器是使用Selector類表示,Selector 可以接收各種 IO 連線,在 IO 狀態準備就緒時,會通知該通道註冊的 Selector,Selector 在下一次輪詢時會發現該 IO 連線就緒,進而處理該連線。

Selector 選擇器主要用於網路 IO當中,在這裡我會將傳統的 BIO Socket 程式設計和使用 NIO 後的 Socket 程式設計作對比,分析 NIO 為何更受歡迎。首先先來了解 Selector 的基本結構。

重要方法方法解析
open() 開啟一個 Selector 選擇器
int select() 阻塞地等待就緒的通道
int select(long timeout) 最多阻塞 timeout 毫秒,如果是 0 則一直阻塞等待,如果是 1 則代表最多阻塞 1 毫秒
int selectNow() 非阻塞地輪詢就緒的通道

在這裡,你會看到 select() 和它的過載方法是會阻塞的,如果使用者程序輪詢時發現沒有就緒的通道,作業系統有兩種做法:

  • 一直等待直到一個就緒的通道,再返回給使用者程序

  • 立即返回一個錯誤狀態碼給使用者程序,讓使用者程序繼續執行,不會阻塞

這兩種方法對應了同步阻塞 IO和同步非阻塞 IO,這裡讀者的一點小的觀點,請各位大神批判閱讀

Java 中的 NIO 不能真正意義上稱為 Non-Blocking IO,我們通過 API 的呼叫可以發現,select() 方法還是會存在阻塞的現象,根據傳入的引數不同,作業系統的行為也會有所不同,不同之處就是阻塞還是非阻塞,所以我更傾向於把 NIO 稱為 New IO,因為它不僅提供了 Non-Blocking IO,而且保留原有的 Blocking IO 的功能。

瞭解了選擇器之後,它的作用就是:監聽多個 IO 通道,當有通道就緒時選擇器會輪詢發現該通道,並做相應的處理。那麼 IO 狀態分為很多種,我們如何去識別就緒的通道是處於哪種狀態呢?在 Java 中提供了選擇鍵(SelectionKey)。

選擇鍵(SelectionKey)

在 Java 中提供了 4 種選擇鍵:

  • SelectionKey.OP_READ:套接字通道準備好進行讀操作

  • SelectionKey.OP_WRITE:套接字通道準備好進行寫操作

  • SelectionKey.OP_ACCEPT:伺服器套接字通道接受其它通道

  • SelectionKey.OP_CONNECT:套接字通道準備完成連線

在 SelectionKey 中包含了許多屬性

  • channel:該選擇鍵繫結的通道

  • selector:輪詢到該選擇鍵的選擇器

  • readyOps:當前就緒選擇鍵的值

  • interesOps:該選擇器對該通道感興趣的所有選擇鍵

選擇鍵的作用是:在選擇器輪詢到有就緒通道時,會返回這些通道的就緒選擇鍵(SelectionKey),通過選擇鍵可以獲取到通道進行操作。

簡單瞭解了選擇器後,我們可以結合緩衝區、通道和選擇器來完成一個簡易的聊天室應用。

示例:簡易的客戶端伺服器通訊

先說明,這裡的程式碼非常的臭和長,不推薦細看,直接看註釋附近的程式碼即可。

我們在伺服器端會開闢兩個執行緒

  • Thread1:專門監聽客戶端的連線,並把通道註冊到客戶端選擇器上

  • Thread2:專門監聽客戶端的其它 IO 狀態(讀狀態),當客戶端的 IO 狀態就緒時,該選擇器會輪詢發現,並作相應處理

publicclassNIOServer{

SelectorserverSelector=Selector.open();
    SelectorclientSelector=Selector.open();

publicstaticvoidmain(String[]args)throwsIOException{
NIOServerserver=nweNIOServer();
newThread(()->{
try{
//對應IO程式設計中服務端啟動
ServerSocketChannellistenerChannel=ServerSocketChannel.open();
listenerChannel.socket().bind(newInetSocketAddress(3333));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector,SelectionKey.OP_ACCEPT);
server.acceptListener();
}catch(IOExceptionignored){
}
}).start();
newThread(()->{
try{
server.clientListener();
}catch(IOExceptionignored){
}
}).start();
}
}
//監聽客戶端連線
publicvoidacceptListener(){
while(true){
if(serverSelector.select(1)>0){
Set<SelectionKey>set=serverSelector.selectedKeys();
Iterator<SelectionKey>keyIterator=set.iterator();
while(keyIterator.hasNext()){
SelectionKeykey=keyIterator.next();
if(key.isAcceptable()){
try{
//(1)每來一個新連線,註冊到clientSelector
SocketChannelclientChannel=((ServerSocketChannel)key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector,SelectionKey.OP_READ);
}finally{
//從就緒的列表中移除這個key
keyIterator.remove();
}
}
}
}
}
}
//監聽客戶端的IO狀態就緒
publicvoidclientListener(){
while(true){
//批量輪詢是否有哪些連線有資料可讀
if(clientSelector.select(1)>0){
Set<SelectionKey>set=clientSelector.selectedKeys();
Iterator<SelectionKey>keyIterator=set.iterator();
while(keyIterator.hasNext()){
SelectionKeykey=keyIterator.next();
//判斷該通道是否讀就緒狀態
if(key.isReadable()){
try{
//獲取客戶端通道讀入資料
SocketChannelclientChannel=(SocketChannel)key.channel();
ByteBufferbyteBuffer=ByteBuffer.allocate(1024);
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(
LocalDateTime.now().toString()+"Server端接收到來自Client端的訊息:"+
Charset.defaultCharset().decode(byteBuffer).toString());
}finally{
//從就緒的列表中移除這個key
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
}

在客戶端,我們可以簡單的輸入一些文字,傳送給伺服器

publicclassNIOClient{

publicstaticfinalintCAPACITY=1024;

publicstaticvoidmain(String[]args)throwsException{
ByteBufferdsts=ByteBuffer.allocate(CAPACITY);
SocketChannelsocketChannel=SocketChannel.open(newInetSocketAddress("127.0.0.1",3333));
socketChannel.configureBlocking(false);
Scannersc=newScanner(System.in);
while(true){
Stringmsg=sc.next();
dsts.put(msg.getBytes());
dsts.flip();
socketChannel.write(dsts);
dsts.clear();
}
}

}

下圖可以看見,在客戶端給伺服器端傳送資訊,伺服器接收到訊息後,可以將該條訊息分發給其它客戶端,就可以實現一個簡單的群聊系統,我們還可以給這些客戶端貼上標籤例如使用者姓名,聊天等級······,就可以標識每個客戶端啦。在這裡由於篇幅原因,我沒有寫出所有功能,因為使用原生的 NIO 實在是不太便捷。

我相信你們都是直接滑下來看這裡的,我在寫這段程式碼的時候也非常痛苦,甚至有點厭煩 Java 原生的 NIO 程式設計。實際上我們在日常開發中很少直接用 NIO 進行程式設計,通常都會用 Netty,Mina 這種伺服器框架,它們都是很好地 NIO 技術,對 Java 原生的 NIO 進行了上層的封裝、優化,簡化開發難度,但是在學習框架之前,我們需要了解它底層原生的技術,就像 Spring AOP 的動態代理,Spring IOC 容器的 Map 容器儲存物件,Netty 底層的 NIO 基礎······

總結

NIO 的三大板塊基本上都介紹完了,我沒有做過多詳細的 API 介紹,我希望能夠通過這篇文章讓你們對以下內容有所認知

  • Java IO 體系的組成部分:BIO 和 NIO

  • BIO 的基本組成部分:位元組流,字元流,轉換流和處理流

  • NIO 的三大重要模組:緩衝區(Buffer),通道(Channel),選擇器(Selector)以及它們的作用

  • NIO 與 BIO 兩者的對比:同步/非同步、阻塞/非阻塞,在檔案 IO 和 網路 IO 中,使用 NIO 相對於使用 BIO 有什麼優勢