天天看點

Java I/O不迷茫,一文為你導航!

前言:在之前的面試中,每每問到關于Java I/O 方面的東西都感覺自己吃了大虧..是以這裡搶救一下..來深入的了解一下在Java之中的 I/O 到底是怎麼回事..文章可能說明類的文字有點兒多,希望能耐心讀完..

什麼是 I/O?

學習過計算機相關課程的童鞋應該都知道,I/O 即輸入Input/ 輸出Output的縮寫,最容易讓人聯想到的就是螢幕這樣的輸出裝置以及鍵盤滑鼠這一類的輸入裝置,其廣義上的定義就是:資料在内部存儲器和外部存儲器或其他周邊裝置之間的輸入和輸出;

我們可以從定義上看到問題的核心就是:資料/ 輸入/ 輸出,在Java中,主要就是涉及到磁盤 I/O 和網絡 I/O 兩種了;

簡單了解Java 流(Stream)

通常我們說 I/O 都會涉及到諸如輸入流、輸出流這樣的概念,那麼什麼是流呢?流是一個抽象但形象的概念,你可以簡單了解成一個資料的序列,輸入流表示從一個源讀取資料,輸出流則表示向一個目标寫資料,在Java程式中,對于資料的輸入和輸出都是采用 “流” 這樣的方式進行的,其裝置可以是檔案、網絡、記憶體等;

流具有方向性,至于是輸入流還是輸出流則是一個相對的概念,一般以程式為參考,如果資料的流向是程式至裝置,我們成為輸出流,反之我們稱為輸入流。

可以将流想象成一個“水流管道”,水流就在這管道中形成了,自然就出現了方向的概念。

“流”,代表了任何有能力産出資料的資料源對象或有能力接受資料的接收端對象,它屏蔽了實際的 I/O 裝置中處理資料的細節——摘自《Think in Java》

參考資料:深入了解 Java中的 流 (Stream):https://www.cnblogs.com/shitouer/archive/2012/12/19/2823641.html

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

I/O 問題是任何程式設計語言都無法回避的問題,因為 I/O 操作是人機互動的核心,是機器擷取和交換資訊的主要管道,是以如何設計 I/O 系統變成了一大難題,特别是在當今大流量大資料的時代,I/O 問題尤其突出,很容易稱為一個性能的瓶頸,也正因為如此,在 I/O 庫上也一直在做持續的優化,例如JDK1.4引入的 NIO,JDK1.7引入的 NIO 2.0,都一定程度上的提升了 I/O 的性能;

Java的 I/O 操作類在包 java.io下,有将近80個類,這些類大概可以分成如下 4 組:

  • 基于位元組操作的 I/O 接口:InputStream 和 OutputStream;
  • 基于字元操作的 I/O 接口:Writer 和 Reader;
  • 基于磁盤操作的 I/O 接口:File;
  • 基于網絡操作的 I/O 接口:Socket;

前兩組主要是傳輸資料的資料格式,後兩組主要是傳輸資料的方式,雖然Socket類并不在java.io包下,但這裡仍然把它們劃分在了一起;I/O 隻是人機互動的一種手段,除了它們能夠完成這個互動功能外,我們更多的應該是關注如何提高它的運作效率;

00.基于位元組的 I/O 操作接口

基于位元組的 I/O 操作的接口輸入和輸出分别對應是 InputStream 和 OutputStream,InputStream 的類層次結構如下圖:

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

這裡就不詳細解釋每個子類如何使用了,如果感興趣可以自己去看一下JDK的源碼,而且的話從類名也能大緻看出一二該類是在處理怎樣的一些東西..這裡需要說明兩點:

1)操作資料的方式是可以組合使用的:

例如:

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

2)必須要指定流最終寫到什麼地方:

要麼是寫到磁盤,要麼是寫到網絡中,但重點是你必須說明這一點,而且你會發現其實SocketOutputStream是屬于FileOutputStream下的,也就是說寫網絡實際上也是寫檔案,隻不過寫網絡還有一步需要處理,就是讓底層的作業系統知道我這個資料是需要傳送到其他地方而不是本地磁盤上的;

01.基于字元的 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 類它們都隻定義了讀取或寫入的資料字元的方式,也就是怎麼寫或讀,但是并沒有規定資料要寫到哪去,寫到哪去就是我們後面要讨論的基于磁盤和網絡的工作機制。

01.位元組與字元的轉化接口

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

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 完成編碼過程。

磁盤 I/O 的工作機制

在介紹 Java 讀取和寫入磁盤檔案之前,先來看看應用程式通路檔案有哪幾種方式;

幾種通路檔案的方式

我們知道,讀取和寫入檔案 I/O 操作都調用的是作業系統提供給我們的接口,因為磁盤裝置是歸作業系統管的,而隻要是系統調用都可能存在核心空間位址和使用者空間位址切換的問題,這是為了保證使用者程序不能直接操作核心,保證核心的安全而設計的,現代的作業系統将虛拟空間劃分成了核心空間和使用者空間兩部分并實作了隔離,但是這樣雖然保證了核心程式運作的安全性,但是也必然存在資料可能需要從核心空間向使用者使用者空間複制的問題;

如果遇到非常耗時的操作,如磁盤 I/O,資料從磁盤複制到核心空間,然後又從核心空間複制到使用者空間,将會非常耗時,這時作業系統為了加速 I/O 通路,在核心空間使用緩存機制,也就是将從磁盤讀取的檔案按照一定的組織方式進行緩存,入股使用者程式通路的是同一段磁盤位址的空間資料,那麼作業系統将從核心緩存中直接取出傳回給使用者程式,這樣就可以減少 I/O 的響應時間;

00. 标準通路檔案的方式

讀取的方式是,當應用程式調用

read()

接口時:

  • ①作業系統首先檢查在核心的高速緩存中是否存在需要的資料,如果有,那麼直接從緩存中傳回;
  • ②如果沒有,則從磁盤中讀取,然後緩存在作業系統的緩存中;

寫入的方式是,當應用程式調用

write()

  • 從使用者位址空間複制到核心位址空間的緩存中,這時對使用者程式來說寫操作就已經完成了,至于什麼時候在寫到磁盤中由作業系統決定,除非顯示地調用了 sync 同步指令;

01.直接 I/O 方式

所謂的直接 I/O 的方式就是應用程式直接通路磁盤資料,而不經過作業系統核心資料緩沖區,這樣做的目的是減少一次從核心緩沖區到使用者程式緩存的資料複制;

這種通路檔案的方式通常是在對資料的緩存管理由應用程式實作的資料庫管理系統中,如在資料庫管理系統中,系統明确地知道應該緩存哪些資料,應該失效哪些資料,還可以對一些熱點資料做預加載,提前将熱點資料加載到記憶體,可以加速資料的通路效率,而這些情況如果是交給作業系統進行緩存,那麼作業系統将不知道哪些資料是熱點資料,哪些是隻會通路一次的資料,因為它隻是簡單的緩存最近一次從磁盤讀取的資料而已;

但是直接 I/O 也有負面影響,如果通路的資料不再應用程式緩存之中,那麼每次資料都會直接從磁盤進行加載,這種直接加載會非常緩慢,是以直接 I/O 通常與 異步 I/O 進行結合以達到更好的性能;

10.記憶體映射的方式

記憶體映射是指将硬碟上檔案的位置與程序邏輯位址空間中一塊大小相同的區域一一對應,當要通路記憶體中一段資料時,轉換為通路檔案的某一段資料。這種方式的目的同樣是減少資料在使用者空間和核心空間之間的拷貝操作。當大量資料需要傳輸的時候,采用記憶體映射方式去通路檔案會獲得比較好的效率。

同步和異步通路檔案的方式

另外還有兩種方式,一種是資料的讀取和寫入都是同步操作的同步方式,另一種是是當通路資料的線程送出請求之後,線程會接着去處理其他事情,而不是阻塞等待的異步通路方式,但從筆者就《深入分析 Java Web技術内幕》一書中的内容來看,這兩種方式更像是對标準通路方式的一個具體說明,是标準通路方式對應的兩種不同處理方法,知道就好了...

Java 通路磁盤檔案

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

何時真正會要檢查一個檔案存不存?就是在真正要讀取這個檔案時,例如 FileInputStream 類都是操作一個檔案的接口,注意到在建立一個 FileInputStream 對象時,會建立一個 FileDescriptor 對象,其實這個對象就是真正代表一個存在的檔案對象的描述,當我們在操作一個檔案對象時可以通過 getFD() 方法擷取真正操作的與底層作業系統關聯的檔案描述。例如可以調用 FileDescriptor.sync() 方法将作業系統緩存中的資料強制重新整理到實體磁盤中。

下面以上文讀取檔案的程式為例,介紹下如何從磁盤讀取一段文本字元。如下圖所示:

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

參考文章:深入分析 Java I/O 的工作機制

關于這一part,我們隻需要了解一下就可以,我也是直接複制就完事兒...

Java 序列化技術

Java序列化就是将一個對象轉化成一串二進制表示的位元組數組,通過儲存或轉移這些位元組資料來達到持久化的目的。需要持久化,對象必須繼承

java.io.Serializable

接口,或者将其轉為位元組數組,用于網絡傳輸;

一個實際的序列化例子

第一步:建立一個用于序列化的對象

為了具體說明序列化在Java中是如何運作的,我們來寫一個實際的例子,首先我們來寫一個用于序列化的對象,然後實作上述的接口:

/**
 * 用于示範Java中序列化的工作流程...
 *
 * @author: @我沒有三顆心髒
 * @create: 2018-08-15-下午 14:37
 */
public class People implements Serializable{

	public String name;
	public transient int age;

	public void sayHello() {
		System.out.println("Hello,My Name is " + name);
	}
}
           

注意:一個類的對象想要序列化成功,必須滿足兩個條件

  • ①實作上述的接口;
  • ②保證該類的所有屬性必須都是可序列化的,如果不希望某個屬性序列化(例如一些敏感資訊),可以加上

    transient

    關鍵字;

第二步:序列化對象

如下的代碼完成了執行個體化一個 People 對象并其序列化到D盤的根目錄下的一個操作,這裡呢按照 Java 的标準約定将檔案的字尾寫成 .ser 的樣子,你也可以寫成其他的...

People people = new People();
people.name = "我沒有三顆心髒";
people.age = 21;

try {
	FileOutputStream fileOutputStream = new FileOutputStream("D:/people.ser");
	ObjectOutputStream out = new ObjectOutputStream(fileOutputStream);
	out.writeObject(people);
	out.close();
	fileOutputStream.close();
	System.out.println("Serialized data is saved in D:/");
} catch (IOException e) {
	e.printStackTrace();
}
           

第三步:反序列化對象

下面的程式完成了對剛才我們序列化的檔案還原成一個People對象的過程,并擷取了其中的參數,但是注意,由于我們希望 age 屬性是短暫的加入了

transient

關鍵字, 是以我們無法擷取到序列化時 People 的 age 屬性:

People people = null;
try {
	FileInputStream fileIn = new FileInputStream("D:/people.ser");
	ObjectInputStream in = new ObjectInputStream(fileIn);
	people = (People) in.readObject();
	in.close();
	fileIn.close();
} catch (IOException i) {
	i.printStackTrace();
	return;
} catch (ClassNotFoundException c) {
	System.out.println("People class not found");
	c.printStackTrace();
	return;
}
System.out.println("Deserialized People...");
System.out.println("Name: " + people.name);
System.out.println("Age: " + people.age);
           

輸出結果如下:

Deserialized People...
Name: 我沒有三顆心髒
Age: 0
           

serialVersionUID的作用

上述的例子中我們完成了對一個 People 對象序列化和反序列化的過程,我們現在來做一點簡單的修改,例如把age字段的

transient

關鍵字去掉:

public class People implements Serializable {

	public String name;
	public int age;

	public void sayHello() {
		System.out.println("Hello,My Name is " + name);
	}
}
           

然後我們再運作我們剛才反序列化的代碼,會發現,這個時候程式竟然報錯了,說是serialVersionUID不一緻:

事實上,如果你經常看别人的代碼的話,或許會有留意到諸如這樣的代碼:

private static final long serialVersionUID = 876323262645176354L;
           

就這一長串的東西也不知道是在幹嘛的,但這其實是為了保證序列化版本的相容性,即在版本更新後序列化仍保持對象的唯一性;我們通過上述的修改也感受到了其中的一二,但是問題是:我們并沒有在需要序列化的對象中寫任何關于這個UID的代碼呀?

這是個有趣的問題,通常情況下,如果我們實作了序列化接口,但是沒有自己顯式的聲明這個UID的話,那麼JVM就會根據該類的類名、屬性名、方法名等自己計算出一個獨一無二的變量值,然後将這個變量值一同序列化到檔案之中,而在反序列化的時候同樣,會根據該類計算出一個獨一無二的變量然後進行比較,不一緻就會報錯,但是我懷着強烈的好奇心去反編譯了一下.class檔案,并沒有發現編譯器寫了UDI這一類的東西,我看《深入分析 Java Web 技術内幕》中說,實際上是寫到了二進制檔案裡面了;

  • 不顯式聲明的缺點:一旦寫好了某一個類,那麼想要修改就不行了,是以我們最好自己顯式的去聲明;
  • 顯式聲明的方式:①使用預設的1L作用UID;②根據類名、接口名等生成一個64位的哈希字段,現在的編譯器如IDEA、Eclipse都有這樣的功能,大家感興趣去了解下;

序列化用來幹什麼?

雖然我們上面的程式成功将一個對象序列化儲存到磁盤,然後從磁盤還原,但是這樣的功能到底可以應用在哪些場景?到底可以幹一些什麼樣的事情呢?下面舉一些在實際應用中的例子:

  • Web伺服器中儲存Session對象,如Tomcat會在伺服器關閉時把session序列化存儲到一個名為session.ser的檔案之中,這個過程稱為session的鈍化;
  • 網絡上傳輸對象,如分布式應用等;

關于序列化的一些細節

1.如果一個類沒有實作Serializable接口,但是它的基類實作了,那麼這個類也是可以序列化的;

2.相反,如果一個類實作了Serializable接口,但是它的父類沒有實作,那麼這個類還是可以序列化(Object是所有類的父類),但是序列化該子類對象,然後反序列化後輸出父類定義的某變量的數值,會發現該變量數值與序列化時的數值不同(一般為null或者其他預設值),而且這個父類裡面必須有無參的構造方法,不然子類反序列化的時候會報錯。

了解到這裡就可以了,更多的細節感興趣的童鞋可以自行去搜尋引擎搜尋..

網絡 I/O 工作機制

資料從一台主機發送到網絡中的另一台主機需要經過很多步驟,首先雙方需要有溝通的意向,然後要有能夠溝通的實體管道(實體鍊路),其次,還要保障雙方能夠正常的進行交流,例如語言一緻的問題、說話順序的問題等等等;

Java Socket 的工作機制

看到有地方說:網絡 I/O 的實質其實就是對 Socket 的讀取;那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 部分将介紹避免這種情況。

NIO 的工作方式

BIO 帶來的挑戰

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

NIO 的工作機制

很多人都把NIO翻譯成New IO,但我更覺得No-Block IO更接近它的本意,也就是非阻塞式IO,它雖然是非阻塞式的,但它是同步的,我們先看一下 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 中。是以關鍵的地方是有一個線程來處理所有連接配接的資料互動,每個連接配接的資料互動都不是阻塞方式,是以可以同時處理大量的連接配接請求。

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 個位元組資料發送出去了。在下一次寫資料之前我們在調一下 clear() 方法。緩沖區的索引狀态又回到初始位置。

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

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

Java NIO 執行個體

上面從 NIO 中引入了一些概念,下面我們對這些概念再來進行簡單的複述和補充:

  • 緩沖區Buffer:緩沖區是一個對象,裡面存的是資料,NIO進行通訊,傳遞的資料,都包裝到Buffer中,Buffer是一個抽象類。子類有ByteBuffer、CharBuffer等,常用的是位元組緩沖區,也就是ByteBuffer;
  • 通道Channel:channel是一個通道,通道就是通流某種物質的管道,在這裡就是通流資料,他和流的不同之處就在于,流是單向的,隻能向一個方向流動,而通道是一個管道,有兩端,是雙向的,可以進行讀操作,也可以寫操作,或者兩者同時進行;
  • 多路複用器Selector:多路複用器是一個大管家,他管理着通道,通道把自己注冊到Selector上面,Selector會輪詢注冊到自己的管道,通過判斷這個管道的不同的狀态,來進行相應的操作;

NIO 工作機制的核心思想就是:用戶端和伺服器端都是使用的通道,通道具有事件,可以将事件注冊到多路複選器上,事件有就緒和非就緒兩種狀态,就緒的狀态會放到多路複選器的就緒鍵的集合中,起一個線程不斷地去輪詢就緒的狀态,根據不同的狀态做不同的處理

參考資料:https://wangjingxin.top/2017/01/17/io/

NIO 和 IO 的主要差別

  1. 面向流與面向緩沖.

    Java NIO和IO之間第一個最大的差別是,IO是面向流的,NIO是面向緩沖區的。Java IO面向流意味着每次從流中讀一個或多個位元組,直至讀取所有位元組,它們沒有被緩存在任何地方。此外,它不能前後移動流中的資料。如果需要前後移動從流中讀取的資料,需要先将它緩存到一個緩沖區。 Java NIO的緩沖導向方法略有不同。資料讀取到一個它稍後處理的緩沖區,需要時可在緩沖區中前後移動。這就增加了處理過程中的靈活性。

  2. 阻塞與非阻塞IO

    Java IO的各種流是阻塞的。這意味着,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些資料被讀取,或資料完全寫入。該線程在此期間不能再幹任何事情了。 Java NIO的非阻塞模式,使一個線程從某通道發送請求讀取資料,但是它僅能得到目前可用的資料,如果目前沒有資料可用時,該線程可以繼續做其他的事情。 非阻塞寫也是如此。一個線程請求寫入一些資料到某通道,但不需要等待它完全寫入,這個線程同時可以去做别的事情。線程通常将非阻塞IO的空閑時間用于在其它通道上執行IO操作,是以一個單獨的線程現在可以管理多個輸入和輸出通道(channel)。

  3. 選擇器(Selectors)

    Java NIO的選擇器允許一個單獨的線程來監視多個輸入通道,你可以注冊多個通道使用一個選擇器,然後使用一個單獨的線程來“選擇”通道:這些通道裡已經有可以處理的輸入,或者選擇已準備寫入的通道。這種選擇機制,使得一個單獨的線程很容易來管理多個通道。

Java AIO 簡單了解

AIO就是異步非阻塞IO,A就是asynchronous的意思,因為NIO1.0雖然面向緩沖,利用多路複選器實作了同步非阻塞IO,可是在NIO1.0中需要使用一個線程不斷去輪詢就緒集合,開銷也是比較大的,是以在jdk1.7中擴充了NIO,稱之為NIO2.0,NIO2.0中引入了AIO,此外NIO2.0中還引入了異步檔案通道,那麼究竟是怎麼實作異步的呢?

AIO 有三個特點,它的特點也可以說明它是如何完成異步這樣的操作的:

  • ①讀完了再通知我;
  • ②不會加快 I/O,隻是在讀完後進行通知;
  • ③使用回調函數,進行業務處理;

AIO 的核心原理就是:對用戶端和伺服器端的各種操作進行回調函數的注冊(通過實作一個CompletionHandler接口,其中定義了一個completed的成功操作方法和一個fail的失敗方法)。在完成某個操作之後,就會自己去調用該注冊到該操作的回調函數,達到異步的效果。

BIO/ NIO/ AIO 的簡單了解

我們在這裡假設一個燒了一排開水的場景,BIO(同步阻塞IO)的做法就是,叫一個線程停留在一個水壺那,直到這個水壺燒開我再去處理下一個水壺;NIO(準備好再通知我,同步非阻塞IO)的做法就是叫一個線程不斷地去詢問每個水壺的狀态,看看是否有水壺的狀态發生了變化,變化則再去做相應的處理;AIO(讀完了再通知我,異步非阻塞IO)的做法是在每個水壺上都安裝一個裝置,當水壺燒開之後就會自動通知我水壺燒開了讓我做相應的處理;

如果還覺得了解起來有困難的童鞋建議閱讀以下這篇文章,相信會有收獲:

http://loveshisong.cn/程式設計技術/2016-06-25-十分鐘了解BIO-NIO-AIO.html

BIO、NIO、AIO适用場景分析

  • BIO方式适用于連接配接數目比較小且固定的架構,這種方式對伺服器資源要求比較高,并發局限于應用中,JDK1.4以前的唯一選擇,但程式直覺簡單易了解。
  • NIO方式适用于連接配接數目多且連接配接比較短(輕操作)的架構,比如聊天伺服器,并發局限于應用中,程式設計比較複雜,JDK1.4開始支援。
  • AIO方式使用于連接配接數目多且連接配接比較長(重操作)的架構,比如相冊伺服器,充分調用OS參與并發操作,程式設計比較複雜,JDK7開始支援。

簡單總結

這篇文章大量複制粘貼到《深入分析 Java Web 技術内幕》第二節“深入分析 Java I/O 的工作機制”的内容,沒辦法确實很多描述性的概念以及說明,自己的說明也沒有達到用簡單語言能描述複雜事物的程度..是以可能看起來這篇文章會有那麼點兒難以下咽..我自己的話也是為了寫着一篇文章查了很多資料,書也是翻了很多很多遍才對Java 中的 I/O 相關的知識有所熟悉,不過耗費的時間也是值得的,同時也希望觀看文章的你能夠有所收獲,也歡迎各位指正!

歡迎轉載,轉載請注明出處!

簡書ID:@我沒有三顆心髒

github:wmyskxz

歡迎關注公衆微信号:wmyskxz_javaweb

分享自己的Java Web學習之路以及各種Java學習資料

想要交流的朋友也可以加qq群:3382693

繼續閱讀