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

来源:https://www.cnblogs.com/wmyskxz/archive/2018/08/16/9485251.html
-Advertisement-
Play Games

前言:在之前的面試中,每每問到關於Java I/O 方面的東西都感覺自己吃了大虧..所以這裡搶救一下..來深入的瞭解一下在Java之中的 I/O 到底是怎麼回事..文章可能說明類的文字有點兒多,希望能耐心讀完.. 什麼是 I/O? 學習過電腦相關課程的童鞋應該都知道,I/O 即輸入Input/ 輸 ...


前言:在之前的面試中,每每問到關於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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • onNodeCreated 回調,捕獲 DOM 創建完畢的回調,然後利用 zTree 的規則找到 treeNode.tId + "_a" 這樣的 標簽,自行添加 class 就是了 ...
  • 時隔一年左右,學習了新的知識,從嘗試Linux部署項目,網路安全,至後端開發,然後用起了Jquery, 而且是必須要做。也讓自己見識可能會更廣泛一些。對於一個剛畢業的大學生而言。方正我是沒有用過jquery, 儘管我有培訓學習過,也只是兩天時間,略有瞭解而已,工作中畫畫頁面肯定後期通過專業人士調,可 ...
  • 標簽內容 <div class="box"> 請編寫javascript代碼,完成如下功能要求:<br /> 1.取消覆選款後,要求促銷價格、促銷開始結束日期3個控制項不可用。<br /> 2.選中覆選框後,要求促銷價格、促銷開始結束日期3個控制項可用。 </div> <div class="box"> ...
  • jquery內容 <script> $(function () { $("dl dt").click(function () { $(this).siblings().toggle().parent().siblings().children("dd").hide(); }); }); </scri ...
  • 客戶端代碼: <html><head> <script> var socket; if ("WebSocket" in window) { var ws = new WebSocket("ws://127.0.0.1:8181"); socket = ws; ws.onopen = function ...
  • layui是一款優秀的前端模塊化css框架,作者是賢心 —— 國內的一位前端大佬。 我用layui做過兩個完整的項目,對她的感覺就是,這貨非常適合做後臺管理界面,且基於jquery,很容易上手。當然,她最大的優點我覺得還是她的模塊化方式,相比requirejs,seajs之類繁瑣的配置,她跟簡單... ...
  • 微信小程式bug記錄 textarea 1. textarea在模擬器上沒有padding,可是在真機上會自帶padding,而且在外部改不了,並且在安卓和IOS上padding還不一樣 第一張圖是在開發工具上的,第二張圖是在IOS真機上的。從上圖可以看出來,在開發工具上顯示很正常,而且沒有padd ...
  • KeepAlive--高可用解決方案 究竟啥才是互聯網架構“高可用” ...
一周排行
    -Advertisement-
    Play Games
  • 前言 本文介紹一款使用 C# 與 WPF 開發的音頻播放器,其界面簡潔大方,操作體驗流暢。該播放器支持多種音頻格式(如 MP4、WMA、OGG、FLAC 等),並具備標記、實時歌詞顯示等功能。 另外,還支持換膚及多語言(中英文)切換。核心音頻處理採用 FFmpeg 組件,獲得了廣泛認可,目前 Git ...
  • OAuth2.0授權驗證-gitee授權碼模式 本文主要介紹如何筆者自己是如何使用gitee提供的OAuth2.0協議完成授權驗證並登錄到自己的系統,完整模式如圖 1、創建應用 打開gitee個人中心->第三方應用->創建應用 創建應用後在我的應用界面,查看已創建應用的Client ID和Clien ...
  • 解決了這個問題:《winForm下,fastReport.net 從.net framework 升級到.net5遇到的錯誤“Operation is not supported on this platform.”》 本文內容轉載自:https://www.fcnsoft.com/Home/Sho ...
  • 國內文章 WPF 從裸 Win 32 的 WM_Pointer 消息獲取觸摸點繪製筆跡 https://www.cnblogs.com/lindexi/p/18390983 本文將告訴大家如何在 WPF 裡面,接收裸 Win 32 的 WM_Pointer 消息,從消息裡面獲取觸摸點信息,使用觸摸點 ...
  • 前言 給大家推薦一個專為新零售快消行業打造了一套高效的進銷存管理系統。 系統不僅具備強大的庫存管理功能,還集成了高性能的輕量級 POS 解決方案,確保頁面載入速度極快,提供良好的用戶體驗。 項目介紹 Dorisoy.POS 是一款基於 .NET 7 和 Angular 4 開發的新零售快消進銷存管理 ...
  • ABP CLI常用的代碼分享 一、確保環境配置正確 安裝.NET CLI: ABP CLI是基於.NET Core或.NET 5/6/7等更高版本構建的,因此首先需要在你的開發環境中安裝.NET CLI。這可以通過訪問Microsoft官網下載並安裝相應版本的.NET SDK來實現。 安裝ABP ...
  • 問題 問題是這樣的:第三方的webapi,需要先調用登陸介面獲取Cookie,訪問其它介面時攜帶Cookie信息。 但使用HttpClient類調用登陸介面,返回的Headers中沒有找到Cookie信息。 分析 首先,使用Postman測試該登陸介面,正常返回Cookie信息,說明是HttpCli ...
  • 國內文章 關於.NET在中國為什麼工資低的分析 https://www.cnblogs.com/thinkingmore/p/18406244 .NET在中國開發者的薪資偏低,主要因市場需求、技術棧選擇和企業文化等因素所致。歷史上,.NET曾因微軟的閉源策略發展受限,儘管後來推出了跨平臺的.NET ...
  • 在WPF開發應用中,動畫不僅可以引起用戶的註意與興趣,而且還使軟體更加便於使用。前面幾篇文章講解了畫筆(Brush),形狀(Shape),幾何圖形(Geometry),變換(Transform)等相關內容,今天繼續講解動畫相關內容和知識點,僅供學習分享使用,如有不足之處,還請指正。 ...
  • 什麼是委托? 委托可以說是把一個方法代入另一個方法執行,相當於指向函數的指針;事件就相當於保存委托的數組; 1.實例化委托的方式: 方式1:通過new創建實例: public delegate void ShowDelegate(); 或者 public delegate string ShowDe ...