爲什麼一個還沒畢業的大學生能夠把 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 是不是有一些思路了,不會覺得很混亂了,我們來看這四個類下的所有成員。

image-20200823091738251

[來自於 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

image-20200825080020253

root root 分別代表:當前文件的所有者當前文件所屬的用戶分組。Linux 下文件的操作權限分爲三種用戶:

  • 文件所有者:擁有的權限是紅框中的前三個字母-代表沒有某個權限
  • 文件所在組的所有用戶:擁有的權限是紅框中的中間三個字母
  • 其它組的所有用戶:擁有的權限是紅框中的最後三個字母

Java IO 流對象

回顧流的分類有2種:

  • 根據數據流向分爲輸入流和輸出流
  • 根據數據類型分爲字節流和字符流

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

image-20200823091738251

字節流對象

字節流對象大部分輸入流和輸出流都是成雙成對地出現,所以學習的時候可以將輸入流和輸出流一一對應的流對象關聯起來,輸入流和輸出流只是數據流向不同,而處理數據的方式可以是相同的。

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

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

image-20200825084204026

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() 方法時的位置

image-20200827082445395

(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 允許多次讀取輸入流的字節數據,只要將讀到的字節放回緩衝區即可。

2

需要注意的是如果回推字節時,如果緩衝區已滿,會拋出 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() 關閉此輸出流並釋放與此流關聯的所有系統資源

image-20200827090101687

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

  • OutputStream 是所有輸出字節流的抽象基類

  • ByteArrayOutputStream 和 FileOutputStream 是兩種基本的節點流,它們分別向字節數組本地文件寫出數據

  • DataOutputStream、BufferedOutputStream 是處理流,前者可以將字節數據轉換成基本數據類型寫出到文件中;後者是緩衝字節數組,只有在緩衝區滿時,纔會將所有的字節寫出到目的地,減少了 IO 次數

  • PipedOutputStream 用於多線程通信,可以和其它線程共用一個管道,向管道中寫入數據

  • ObjectOutputStream 用於對象的序列化,將對象轉換成字節數組後,將所有的字節都寫入到指定位置中

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

字符流對象

字符流對象也會有對應關係,大多數的類可以認爲是操作的數據從字節數組變爲字符,類的功能和字節流對象是相似的。

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

image-20200827094740444

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) 將字符讀入字符數組中的指定範圍中

還有其它一些額外的方法,與字節輸入流基類提供的方法是相同的,只是作用的對象不再是字節,而是字符。

image-20200827095911702

  • 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 執行完畢後都需要關閉流對象,釋放系統資源

image-20200827104837521

  • 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 的主要區別就可以用下面這個表格簡單概括。

BIO NIO
面向流(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 大小的字節數組,代碼就是下面這樣子。

ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

緩衝區讀寫數據的兩個核心方法:

  • put():將數據寫入到緩衝區中
  • get():從緩衝區中讀取數據

緩衝區的重要屬性:

  • capacity:緩衝區中最大存儲數據的容量,一旦聲明則無法改變

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

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

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

緩衝區爲高效讀寫數據而提供的其它輔助方法

  • flip():可以實現讀寫模式的切換,我們可以看看裏面的源碼
public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}

調用 flip() 會將可操作的大小 limit 設置爲當前寫的位置,操作數據的起始位置 position 設置爲 0,即從頭開始讀取數據

  • rewind():可以將 position 位置設置爲 0,再次讀取緩衝區中的數據
  • clear():清空整個緩衝區,它會將 position 設置爲 0,limit 設置爲 capacity,可以寫整個緩衝區

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

我們來看一個簡單的例子

public Class Main {
    public static void main(String[] args) {
         // 分配內存大小爲11的整型緩存區
        IntBuffer buffer = IntBuffer.allocate(11);
        // 往buffer裏寫入2個整型數據
        for (int i = 0; i < 2; ++i) {
            int randomNum = new SecureRandom().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:\小菠蘿分身\ 文件夾下。

public class Test {
	/** 緩衝區的大小 */
    public static final int SIZE = 1024;

    public static void main(String[] args) throws IOException {
        // 打開文件輸入流
        FileChannel inChannel = new FileInputStream("d:\小菠蘿\小菠蘿.jpg").getChannel();
        // 打開文件輸出流
        FileChannel outChannel = new FileOutputStream("d:\小菠蘿分身\小菠蘿-拷貝.jpg").getChannel();
        // 分配 1024 個字節大小的緩衝區
        ByteBuffer dsts = 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 中,零拷貝是通過用戶空間和內核空間的緩衝區共享一塊物理內存實現的,也就是說上面的圖可以演變成這個樣子。

image-20200904122132978
這時,無論是用戶空間還是內核空間操作自己的緩衝區,本質上都是操作這一塊共享內存中的緩衝區數據,省去了用戶空間和內核空間之間的數據拷貝操作

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

  • 用戶進程通過系統調用 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 並不是阻塞的,所以不會因爲多個線程之間切換導致上下文切換帶來的開銷

image-20200904185402331

在 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 狀態就緒時,該選擇器會輪詢發現,並作相應處理
public class NIOServer {
    
	Selector serverSelector = Selector.open();
    Selector clientSelector = Selector.open();
    
    public static void main(String[] args) throws IOException {
        NIOServer server = nwe NIOServer();
        new Thread(() -> {
            try {
                // 對應IO編程中服務端啓動
                ServerSocketChannel listenerChannel = ServerSocketChannel.open();
                listenerChannel.socket().bind(new InetSocketAddress(3333));
                listenerChannel.configureBlocking(false);
                listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
				server.acceptListener();
            } catch (IOException ignored) {
            }
        }).start();
        new Thread(() -> {
            try {
                server.clientListener();
            } catch (IOException ignored) {
            }
        }).start();
    }
}
// 監聽客戶端連接
public void acceptListener() {
    while (true) {
        if (serverSelector.select(1) > 0) {
            Set<SelectionKey> set = serverSelector.selectedKeys();
            Iterator<SelectionKey> keyIterator = set.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                if (key.isAcceptable()) {
                    try {
                        // (1) 每來一個新連接,註冊到clientSelector
                        SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
                        clientChannel.configureBlocking(false);
                        clientChannel.register(clientSelector, SelectionKey.OP_READ);
                    } finally {
                        // 從就緒的列表中移除這個key
                        keyIterator.remove();
                    }
                }
            }
        }
    }
}
// 監聽客戶端的 IO 狀態就緒
public void clientListener() {
    while (true) {
        // 批量輪詢是否有哪些連接有數據可讀
        if (clientSelector.select(1) > 0) {
            Set<SelectionKey> set = clientSelector.selectedKeys();
            Iterator<SelectionKey> keyIterator = set.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
				// 判斷該通道是否讀就緒狀態
                if (key.isReadable()) {
                    try {
                        // 獲取客戶端通道讀入數據
                        SocketChannel clientChannel = (SocketChannel) key.channel();
                        ByteBuffer byteBuffer = 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);
                    }
                }
            }
        }
    }
}

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

public class NIOClient {
    
    public static final int CAPACITY = 1024;

    public static void main(String[] args) throws Exception {
        ByteBuffer dsts = ByteBuffer.allocate(CAPACITY);
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 3333));
        socketChannel.configureBlocking(false);
        Scanner sc = new Scanner(System.in);
        while (true) {
            String msg = 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 有什麼優勢

你好,我是 cxuan,我自己手寫了四本 PDF,分別是 Java基礎總結、HTTP 核心總結、計算機基礎知識,操作系統核心總結,我已經整理成爲 PDF,可以關注公衆號 Java建設者 回覆 PDF 領取優質資料。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章