在開始之前
關於本教程
新的輸入/輸出 (NIO) 庫是在 JDK 1.4 中引入的。NIO 彌補了原來的 I/O 的不足,它在標準 Java 代碼中提供了高速的、面向塊的 I/O。通過定義包含數據的類,以及通過以塊的形式處理這些數據,NIO 不用使用本機代碼就可以利用低級優化,這是原來的 I/O 包所無法做到的。
在本教程中,我們將討論 NIO 庫的幾乎所有方面,從高級的概念性內容到底層的編程細節。除了學習諸如緩衝區和通道這樣的關鍵 I/O 元素外,您還有機會看到在更新後的庫中標準 I/O 是如何工作的。您還會了解只能通過 NIO 來完成的工作,如異步 I/O 和直接緩衝區。
在本教程中,我們將使用展示 NIO 庫的不同方面的代碼示例。幾乎每一個代碼示例都是一個大的 Java 程序的一部分,您可以在 參考資料 中找到這個 Java 程序。在做這些練習時,我們推薦您在自己的系統上下載、編譯和運行這些程序。在您學習了本教程以後,這些代碼將爲您的 NIO 編程努力提供一個起點。
本教程是爲希望學習更多關於 JDK 1.4 NIO 庫的知識的所有程序員而寫的。爲了最大程度地從這裏的討論中獲益,您應該理解基本的 Java 編程概念,如類、繼承和使用包。多少熟悉一些原來的 I/O 庫(來自 java.io.*
包)也會有所幫助。
雖然本教程要求掌握 Java 語言的工作詞彙和概念,但是不需要有很多實際編程經驗。除了徹底介紹與本教程有關的所有概念外,我還保持代碼示例儘可能短小和簡單。目的是讓即使沒有多少 Java 編程經驗的讀者也能容易地開始學習 NIO。
如何運行代碼
源代碼歸檔文件(在 參考資料 中提供)包含了本教程中使用的所有程序。每一個程序都由一個 Java 文件構成。每一個文件都根據名稱來識別,並且可以容易地與它所展示的編程概念相關聯。
教程中的一些程序需要命令行參數才能運行。要從命令行運行一個程序,只需使用最方便的命令行提示符。在 Windows 中,命令行提供符是 “Command” 或者 “command.com” 程序。在 UNIX 中,可以使用任何 shell。
需要安裝 JDK 1.4 並將它包括在路徑中,才能完成本教程中的練習。如果需要安裝和配置 JDK 1.4 的幫助,請參見 參考資料 。
輸入/輸出:概念性描述
I/O 簡介
I/O ? 或者輸入/輸出 ? 指的是計算機與外部世界或者一個程序與計算機的其餘部分的之間的接口。它對於任何計算機系統都非常關鍵,因而所有 I/O 的主體實際上是內置在操作系統中的。單獨的程序一般是讓系統爲它們完成大部分的工作。
在 Java 編程中,直到最近一直使用 流 的方式完成 I/O。所有 I/O 都被視爲單個的字節的移動,通過一個稱爲 Stream 的對象一次移動一個字節。流 I/O 用於與外部世界接觸。它也在內部使用,用於將對象轉換爲字節,然後再轉換回對象。
NIO 與原來的 I/O 有同樣的作用和目的,但是它使用不同的方式? 塊 I/O。正如您將在本教程中學到的,塊 I/O 的效率可以比流 I/O 高許多。
爲什麼要使用 NIO?
NIO 的創建目的是爲了讓 Java 程序員可以實現高速 I/O 而無需編寫自定義的本機代碼。NIO 將最耗時的 I/O 操作(即填充和提取緩衝區)轉移回操作系統,因而可以極大地提高速度。
流與塊的比較
原來的 I/O 庫(在 java.io.*
中) 與 NIO 最重要的區別是數據打包和傳輸的方式。正如前面提到的,原來的 I/O 以流的方式處理數據,而 NIO 以塊的方式處理數據。
面向流 的 I/O 系統一次一個字節地處理數據。一個輸入流產生一個字節的數據,一個輸出流消費一個字節的數據。爲流式數據創建過濾器非常容易。鏈接幾個過濾器,以便每個過濾器只負責單個複雜處理機制的一部分,這樣也是相對簡單的。不利的一面是,面向流的 I/O 通常相當慢。
一個 面向塊 的 I/O 系統以塊的形式處理數據。每一個操作都在一步中產生或者消費一個數據塊。按塊處理數據比按(流式的)字節處理數據要快得多。但是面向塊的 I/O 缺少一些面向流的 I/O 所具有的優雅性和簡單性。
集成的 I/O
在 JDK 1.4 中原來的 I/O 包和 NIO 已經很好地集成了。 java.io.*
已經以 NIO 爲基礎重新實現了,所以現在它可以利用 NIO 的一些特性。例如, java.io.*
包中的一些類包含以塊的形式讀寫數據的方法,這使得即使在更面向流的系統中,處理速度也會更快。
也可以用 NIO 庫實現標準 I/O 功能。例如,可以容易地使用塊 I/O 一次一個字節地移動數據。但是正如您會看到的,NIO 還提供了原 I/O 包中所沒有的許多好處。
通道和緩衝區
概述
通道
和 緩衝區
是 NIO 中的核心對象,幾乎在每一個 I/O 操作中都要使用它們。
通道是對原 I/O 包中的流的模擬。到任何目的地(或來自任何地方)的所有數據都必須通過一個 Channel 對象。一個 Buffer 實質上是一個容器對象。發送給一個通道的所有對象都必須首先放到緩衝區中;同樣地,從通道中讀取的任何數據都要讀到緩衝區中。
在本節中,您會瞭解到 NIO 中通道和緩衝區是如何工作的。
什麼是緩衝區?
Buffer
是一個對象, 它包含一些要寫入或者剛讀出的數據。 在 NIO 中加入 Buffer
對象,體現了新庫與原 I/O 的一個重要區別。在面向流的 I/O 中,您將數據直接寫入或者將數據直接讀到 Stream
對象中。
在 NIO 庫中,所有數據都是用緩衝區處理的。在讀取數據時,它是直接讀到緩衝區中的。在寫入數據時,它是寫入到緩衝區中的。任何時候訪問 NIO 中的數據,您都是將它放到緩衝區中。
緩衝區實質上是一個數組。通常它是一個字節數組,但是也可以使用其他種類的數組。但是一個緩衝區不 僅僅 是一個數組。緩衝區提供了對數據的結構化訪問,而且還可以跟蹤系統的讀/寫進程。
緩衝區類型
最常用的緩衝區類型是 ByteBuffer
。一個 ByteBuffer
可以在其底層字節數組上進行 get/set 操作(即字節的獲取和設置)。
ByteBuffer
不是 NIO 中唯一的緩衝區類型。事實上,對於每一種基本 Java 類型都有一種緩衝區類型:
ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
每一個 Buffer
類都是 Buffer
接口的一個實例。 除了 ByteBuffer
,每一個 Buffer 類都有完全一樣的操作,只是它們所處理的數據類型不一樣。因爲大多數標準 I/O 操作都使用 ByteBuffer
,所以它具有所有共享的緩衝區操作以及一些特有的操作。
現在您可以花一點時間運行 UseFloatBuffer.java,它包含了類型化的緩衝區的一個應用例子。
什麼是通道?
Channel
是一個對象,可以通過它讀取和寫入數據。拿 NIO 與原來的 I/O 做個比較,通道就像是流。
正如前面提到的,所有數據都通過 Buffer
對象來處理。您永遠不會將字節直接寫入通道中,相反,您是將數據寫入包含一個或者多個字節的緩衝區。同樣,您不會直接從通道中讀取字節,而是將數據從通道讀入緩衝區,再從緩衝區獲取這個字節。
通道類型
通道與流的不同之處在於通道是雙向的。而流只是在一個方向上移動(一個流必須是 InputStream
或者 OutputStream
的子類), 而 通道
可以用於讀、寫或者同時用於讀寫。
因爲它們是雙向的,所以通道可以比流更好地反映底層操作系統的真實情況。特別是在 UNIX 模型中,底層操作系統通道是雙向的。
從理論到實踐:NIO 中的讀和寫
概述
讀和寫是 I/O 的基本過程。從一個通道中讀取很簡單:只需創建一個緩衝區,然後讓通道將數據讀到這個緩衝區中。寫入也相當簡單:創建一個緩衝區,用數據填充它,然後讓通道用這些數據來執行寫入操作。
在本節中,我們將學習有關在 Java 程序中讀取和寫入數據的一些知識。我們將回顧 NIO 的主要組件(緩衝區、通道和一些相關的方法),看看它們是如何交互以進行讀寫的。在接下來的幾節中,我們將更詳細地分析這其中的每個組件以及其交互。
從文件中讀取
在我們第一個練習中,我們將從一個文件中讀取一些數據。如果使用原來的 I/O,那麼我們只需創建一個 FileInputStream
並從它那裏讀取。而在 NIO 中,情況稍有不同:我們首先從 FileInputStream
獲取一個 Channel
對象,然後使用這個通道來讀取數據。
在 NIO 系統中,任何時候執行一個讀操作,您都是從通道中讀取,但是您不是 直接 從通道讀取。因爲所有數據最終都駐留在緩衝區中,所以您是從通道讀到緩衝區中。
因此讀取文件涉及三個步驟:(1) 從 FileInputStream
獲取 Channel
,(2) 創建 Buffer
,(3) 將數據從 Channel
讀到 Buffer
中。
現在,讓我們看一下這個過程。
三個容易的步驟
第一步是獲取通道。我們從 FileInputStream
獲取通道:
1 2 |
|
下一步是創建緩衝區:
1 |
|
最後,需要將數據從通道讀到緩衝區中,如下所示:
1 |
|
您會注意到,我們不需要告訴通道要讀 多少數據 到緩衝區中。每一個緩衝區都有複雜的內部統計機制,它會跟蹤已經讀了多少數據以及還有多少空間可以容納更多的數據。我們將在 緩衝區內部細節 中介紹更多關於緩衝區統計機制的內容。
寫入文件
在 NIO 中寫入文件類似於從文件中讀取。首先從 FileOutputStream
獲取一個通道:
1 2 |
|
下一步是創建一個緩衝區並在其中放入一些數據 - 在這裏,數據將從一個名爲 message
的數組中取出,這個數組包含字符串 "Some bytes" 的 ASCII 字節(本教程後面將會解釋 buffer.flip()
和 buffer.put()
調用)。
1 2 3 4 5 6 |
|
最後一步是寫入緩衝區中:
1 |
|
注意在這裏同樣不需要告訴通道要寫入多數據。緩衝區的內部統計機制會跟蹤它包含多少數據以及還有多少數據要寫入。
讀寫結合
下面我們將看一下在結合讀和寫時會有什麼情況。我們以一個名爲 CopyFile.java 的簡單程序作爲這個練習的基礎,它將一個文件的所有內容拷貝到另一個文件中。CopyFile.java 執行三個基本操作:首先創建一個 Buffer
,然後從源文件中將數據讀到這個緩衝區中,然後將緩衝區寫入目標文件。這個程序不斷重複 ― 讀、寫、讀、寫 ― 直到源文件結束。
CopyFile 程序讓您看到我們如何檢查操作的狀態,以及如何使用 clear()
和 flip()
方法重設緩衝區,並準備緩衝區以便將新讀取的數據寫到另一個通道中。
運行 CopyFile 例子
因爲緩衝區會跟蹤它自己的數據,所以 CopyFile 程序的內部循環 (inner loop) 非常簡單,如下所示:
1 2 |
|
第一行將數據從輸入通道 fcin
中讀入緩衝區,第二行將這些數據寫到輸出通道 fcout
。
檢查狀態
下一步是檢查拷貝何時完成。當沒有更多的數據時,拷貝就算完成,並且可以在 read()
方法返回 -1 是判斷這一點,如下所示:
1 2 3 4 5 |
|
重設緩衝區
最後,在從輸入通道讀入緩衝區之前,我們調用 clear()
方法。同樣,在將緩衝區寫入輸出通道之前,我們調用 flip()
方法,如下所示:
1 2 3 4 5 6 7 8 9 |
|
clear()
方法重設緩衝區,使它可以接受讀入的數據。 flip()
方法讓緩衝區可以將新讀入的數據寫入另一個通道。
緩衝區內部細節
概述
本節將介紹 NIO 中兩個重要的緩衝區組件:狀態變量和訪問方法 (accessor)。
狀態變量是前一節中提到的"內部統計機制"的關鍵。每一個讀/寫操作都會改變緩衝區的狀態。通過記錄和跟蹤這些變化,緩衝區就可能夠內部地管理自己的資源。
在從通道讀取數據時,數據被放入到緩衝區。在有些情況下,可以將這個緩衝區直接寫入另一個通道,但是在一般情況下,您還需要查看數據。這是使用 訪問方法 get()
來完成的。同樣,如果要將原始數據放入緩衝區中,就要使用訪問方法 put()
。
在本節中,您將學習關於 NIO 中的狀態變量和訪問方法的內容。我們將描述每一個組件,並讓您有機會看到它的實際應用。雖然 NIO 的內部統計機制初看起來可能很複雜,但是您很快就會看到大部分的實際工作都已經替您完成了。您可能習慣於通過手工編碼進行簿記 ― 即使用字節數組和索引變量,現在它已在 NIO 中內部地處理了。
狀態變量
可以用三個值指定緩衝區在任意時刻的狀態:
position
limit
capacity
這三個變量一起可以跟蹤緩衝區的狀態和它所包含的數據。我們將在下面的小節中詳細分析每一個變量,還要介紹它們如何適應典型的讀/寫(輸入/輸出)進程。在這個例子中,我們假定要將數據從一個輸入通道拷貝到一個輸出通道。
Position
您可以回想一下,緩衝區實際上就是美化了的數組。在從通道讀取時,您將所讀取的數據放到底層的數組中。 position
變量跟蹤已經寫了多少數據。更準確地說,它指定了下一個字節將放到數組的哪一個元素中。因此,如果您從通道中讀三個字節到緩衝區中,那麼緩衝區的 position
將會設置爲3,指向數組中第四個元素。
同樣,在寫入通道時,您是從緩衝區中獲取數據。 position
值跟蹤從緩衝區中獲取了多少數據。更準確地說,它指定下一個字節來自數組的哪一個元素。因此如果從緩衝區寫了5個字節到通道中,那麼緩衝區的 position
將被設置爲5,指向數組的第六個元素。
Limit
limit
變量表明還有多少數據需要取出(在從緩衝區寫入通道時),或者還有多少空間可以放入數據(在從通道讀入緩衝區時)。
position
總是小於或者等於 limit
。
Capacity
緩衝區的 capacity
表明可以儲存在緩衝區中的最大數據容量。實際上,它指定了底層數組的大小 ― 或者至少是指定了准許我們使用的底層數組的容量。
limit
決不能大於 capacity
。
觀察變量
我們首先觀察一個新創建的緩衝區。出於本例子的需要,我們假設這個緩衝區的 總容量
爲8個字節。 Buffer
的狀態如下所示:
回想一下 ,limit
決不能大於 capacity
,此例中這兩個值都被設置爲 8。我們通過將它們指向數組的尾部之後(如果有第8個槽,則是第8個槽所在的位置)來說明這點。
position
設置爲0。如果我們讀一些數據到緩衝區中,那麼下一個讀取的數據就進入 slot 0 。如果我們從緩衝區寫一些數據,從緩衝區讀取的下一個字節就來自 slot 0 。 position
設置如下所示:
由於 capacity
不會改變,所以我們在下面的討論中可以忽略它。
第一次讀取
現在我們可以開始在新創建的緩衝區上進行讀/寫操作。首先從輸入通道中讀一些數據到緩衝區中。第一次讀取得到三個字節。它們被放到數組中從 position
開始的位置,這時 position 被設置爲 0。讀完之後,position 就增加到 3,如下所示:
limit
沒有改變。
第二次讀取
在第二次讀取時,我們從輸入通道讀取另外兩個字節到緩衝區中。這兩個字節儲存在由 position
所指定的位置上, position
因而增加 2:
limit
沒有改變。
flip
現在我們要將數據寫到輸出通道中。在這之前,我們必須調用 flip()
方法。這個方法做兩件非常重要的事:
- 它將
limit
設置爲當前position
。 - 它將
position
設置爲 0。
前一小節中的圖顯示了在 flip 之前緩衝區的情況。下面是在 flip 之後的緩衝區:
我們現在可以將數據從緩衝區寫入通道了。 position
被設置爲 0,這意味着我們得到的下一個字節是第一個字節。 limit
已被設置爲原來的 position
,這意味着它包括以前讀到的所有字節,並且一個字節也不多。
第一次寫入
在第一次寫入時,我們從緩衝區中取四個字節並將它們寫入輸出通道。這使得 position
增加到 4,而 limit
不變,如下所示:
第二次寫入
我們只剩下一個字節可寫了。 limit
在我們調用 flip()
時被設置爲 5,並且 position
不能超過 limit
。所以最後一次寫入操作從緩衝區取出一個字節並將它寫入輸出通道。這使得 position
增加到 5,並保持 limit
不變,如下所示:
clear
最後一步是調用緩衝區的 clear()
方法。這個方法重設緩衝區以便接收更多的字節。 Clear
做兩種非常重要的事情:
- 它將
limit
設置爲與capacity
相同。 - 它設置
position
爲 0。
下圖顯示了在調用 clear()
後緩衝區的狀態:
緩衝區現在可以接收新的數據了。
訪問方法
到目前爲止,我們只是使用緩衝區將數據從一個通道轉移到另一個通道。然而,程序經常需要直接處理數據。例如,您可能需要將用戶數據保存到磁盤。在這種情況下,您必須將這些數據直接放入緩衝區,然後用通道將緩衝區寫入磁盤。
或者,您可能想要從磁盤讀取用戶數據。在這種情況下,您要將數據從通道讀到緩衝區中,然後檢查緩衝區中的數據。
在本節的最後,我們將詳細分析如何使用 ByteBuffer
類的 get()
和 put()
方法直接訪問緩衝區中的數據。
get() 方法
ByteBuffer
類中有四個 get()
方法:
byte get();
ByteBuffer get( byte dst[] );
ByteBuffer get( byte dst[], int offset, int length );
byte get( int index );
第一個方法獲取單個字節。第二和第三個方法將一組字節讀到一個數組中。第四個方法從緩衝區中的特定位置獲取字節。那些返回 ByteBuffer
的方法只是返回調用它們的緩衝區的 this
值。
此外,我們認爲前三個 get()
方法是相對的,而最後一個方法是絕對的。 相對 意味着 get()
操作服從 limit
和 position
值 ― 更明確地說,字節是從當前 position
讀取的,而 position
在 get
之後會增加。另一方面,一個 絕對 方法會忽略 limit
和 position
值,也不會影響它們。事實上,它完全繞過了緩衝區的統計方法。
上面列出的方法對應於 ByteBuffer
類。其他類有等價的 get()
方法,這些方法除了不是處理字節外,其它方面是是完全一樣的,它們處理的是與該緩衝區類相適應的類型。
put()方法
ByteBuffer
類中有五個 put()
方法:
ByteBuffer put( byte b );
ByteBuffer put( byte src[] );
ByteBuffer put( byte src[], int offset, int length );
ByteBuffer put( ByteBuffer src );
ByteBuffer put( int index, byte b );
第一個方法 寫入(put)
單個字節。第二和第三個方法寫入來自一個數組的一組字節。第四個方法將數據從一個給定的源 ByteBuffer
寫入這個 ByteBuffer
。第五個方法將字節寫入緩衝區中特定的 位置
。那些返回 ByteBuffer
的方法只是返回調用它們的緩衝區的 this
值。
與 get()
方法一樣,我們將把 put()
方法劃分爲 相對 或者 絕對 的。前四個方法是相對的,而第五個方法是絕對的。
上面顯示的方法對應於 ByteBuffer
類。其他類有等價的 put()
方法,這些方法除了不是處理字節之外,其它方面是完全一樣的。它們處理的是與該緩衝區類相適應的類型。
類型化的 get() 和 put() 方法
除了前些小節中描述的 get()
和 put()
方法, ByteBuffer
還有用於讀寫不同類型的值的其他方法,如下所示:
getByte()
getChar()
getShort()
getInt()
getLong()
getFloat()
getDouble()
putByte()
putChar()
putShort()
putInt()
putLong()
putFloat()
putDouble()
事實上,這其中的每個方法都有兩種類型 ― 一種是相對的,另一種是絕對的。它們對於讀取格式化的二進制數據(如圖像文件的頭部)很有用。
您可以在例子程序 TypesInByteBuffer.java 中看到這些方法的實際應用。
緩衝區的使用:一個內部循環
下面的內部循環概括了使用緩衝區將數據從輸入通道拷貝到輸出通道的過程。
1 2 3 4 5 6 7 8 9 10 11 |
|
read()
和 write()
調用得到了極大的簡化,因爲許多工作細節都由緩衝區完成了。 clear()
和 flip()
方法用於讓緩衝區在讀和寫之間切換。
關於緩衝區的更多內容
概述
到目前爲止,您已經學習了使用緩衝區進行日常工作所需要掌握的大部分內容。我們的例子沒怎麼超出標準的讀/寫過程種類,在原來的 I/O 中可以像在 NIO 中一樣容易地實現這樣的標準讀寫過程。
本節將討論使用緩衝區的一些更復雜的方面,比如緩衝區分配、包裝和分片。我們還會討論 NIO 帶給 Java 平臺的一些新功能。您將學到如何創建不同類型的緩衝區以達到不同的目的,如可保護數據不被修改的 只讀 緩衝區,和直接映射到底層操作系統緩衝區的 直接 緩衝區。我們將在本節的最後介紹如何在 NIO 中創建內存映射文件。
緩衝區分配和包裝
在能夠讀和寫之前,必須有一個緩衝區。要創建緩衝區,您必須 分配 它。我們使用靜態方法 allocate()
來分配緩衝區:
1 |
|
allocate()
方法分配一個具有指定大小的底層數組,並將它包裝到一個緩衝區對象中 ― 在本例中是一個 ByteBuffer
。
您還可以將一個現有的數組轉換爲緩衝區,如下所示:
1 2 |
|
本例使用了 wrap()
方法將一個數組包裝爲緩衝區。必須非常小心地進行這類操作。一旦完成包裝,底層數據就可以通過緩衝區或者直接訪問。
緩衝區分片
slice()
方法根據現有的緩衝區創建一種 子緩衝區 。也就是說,它創建一個新的緩衝區,新緩衝區與原來的緩衝區的一部分共享數據。
使用例子可以最好地說明這點。讓我們首先創建一個長度爲 10 的 ByteBuffer
:
1 |
|
然後使用數據來填充這個緩衝區,在第 n 個槽中放入數字 n:
1 2 3 |
|
現在我們對這個緩衝區 分片 ,以創建一個包含槽 3 到槽 6 的子緩衝區。在某種意義上,子緩衝區就像原來的緩衝區中的一個 窗口 。
窗口的起始和結束位置通過設置 position
和 limit
值來指定,然後調用 Buffer
的 slice()
方法:
1 2 3 |
|
片
是緩衝區的 子緩衝區
。不過, 片段
和 緩衝區
共享同一個底層數據數組,我們在下一節將會看到這一點。
緩衝區份片和數據共享
我們已經創建了原緩衝區的子緩衝區,並且我們知道緩衝區和子緩衝區共享同一個底層數據數組。讓我們看看這意味着什麼。
我們遍歷子緩衝區,將每一個元素乘以 11 來改變它。例如,5 會變成 55。
1 2 3 4 5 |
|
最後,再看一下原緩衝區中的內容:
1 2 3 4 5 6 |
|
結果表明只有在子緩衝區窗口中的元素被改變了:
1 2 3 4 5 6 7 8 9 10 11 |
|
緩衝區片對於促進抽象非常有幫助。可以編寫自己的函數處理整個緩衝區,而且如果想要將這個過程應用於子緩衝區上,您只需取主緩衝區的一個片,並將它傳遞給您的函數。這比編寫自己的函數來取額外的參數以指定要對緩衝區的哪一部分進行操作更容易。
只讀緩衝區
只讀緩衝區非常簡單 ― 您可以讀取它們,但是不能向它們寫入。可以通過調用緩衝區的 asReadOnlyBuffer()
方法,將任何常規緩衝區轉換爲只讀緩衝區,這個方法返回一個與原緩衝區完全相同的緩衝區(並與其共享數據),只不過它是隻讀的。
只讀緩衝區對於保護數據很有用。在將緩衝區傳遞給某個對象的方法時,您無法知道這個方法是否會修改緩衝區中的數據。創建一個只讀的緩衝區可以 保證 該緩衝區不會被修改。
不能將只讀的緩衝區轉換爲可寫的緩衝區。
直接和間接緩衝區
另一種有用的 ByteBuffer
是直接緩衝區。 直接緩衝區 是爲加快 I/O 速度,而以一種特殊的方式分配其內存的緩衝區。
實際上,直接緩衝區的準確定義是與實現相關的。Sun 的文檔是這樣描述直接緩衝區的:
給定一個直接字節緩衝區,Java 虛擬機將盡最大努力直接對它執行本機 I/O 操作。也就是說,它會在每一次調用底層操作系統的本機 I/O 操作之前(或之後),嘗試避免將緩衝區的內容拷貝到一箇中間緩衝區中(或者從一箇中間緩衝區中拷貝數據)。
您可以在例子程序 FastCopyFile.java 中看到直接緩衝區的實際應用,這個程序是 CopyFile.java 的另一個版本,它使用了直接緩衝區以提高速度。
還可以用內存映射文件創建直接緩衝區。
內存映射文件 I/O
內存映射文件 I/O 是一種讀和寫文件數據的方法,它可以比常規的基於流或者基於通道的 I/O 快得多。
內存映射文件 I/O 是通過使文件中的數據神奇般地出現爲內存數組的內容來完成的。這其初聽起來似乎不過就是將整個文件讀到內存中,但是事實上並不是這樣。一般來說,只有文件中實際讀取或者寫入的部分纔會送入(或者 映射 )到內存中。
內存映射並不真的神奇或者多麼不尋常。現代操作系統一般根據需要將文件的部分映射爲內存的部分,從而實現文件系統。Java 內存映射機制不過是在底層操作系統中可以採用這種機制時,提供了對該機制的訪問。
儘管創建內存映射文件相當簡單,但是向它寫入可能是危險的。僅只是改變數組的單個元素這樣的簡單操作,就可能會直接修改磁盤上的文件。修改數據與將數據保存到磁盤是沒有分開的。
將文件映射到內存
瞭解內存映射的最好方法是使用例子。在下面的例子中,我們要將一個 FileChannel
(它的全部或者部分)映射到內存中。爲此我們將使用 FileChannel.map()
方法。下面代碼行將文件的前 1024 個字節映射到內存中:
1 2 |
|
map()
方法返回一個 MappedByteBuffer
,它是 ByteBuffer
的子類。因此,您可以像使用其他任何 ByteBuffer
一樣使用新映射的緩衝區,操作系統會在需要時負責執行行映射。
分散和聚集
概述
分散/聚集 I/O 是使用多個而不是單個緩衝區來保存數據的讀寫方法。
一個分散的讀取就像一個常規通道讀取,只不過它是將數據讀到一個緩衝區數組中而不是讀到單個緩衝區中。同樣地,一個聚集寫入是向緩衝區數組而不是向單個緩衝區寫入數據。
分散/聚集 I/O 對於將數據流劃分爲單獨的部分很有用,這有助於實現複雜的數據格式。
分散/聚集 I/O
通道可以有選擇地實現兩個新的接口: ScatteringByteChannel
和 GatheringByteChannel
。一個 ScatteringByteChannel
是一個具有兩個附加讀方法的通道:
long read( ByteBuffer[] dsts );
long read( ByteBuffer[] dsts, int offset, int length );
這些 long read()
方法很像標準的 read
方法,只不過它們不是取單個緩衝區而是取一個緩衝區數組。
在 分散讀取 中,通道依次填充每個緩衝區。填滿一個緩衝區後,它就開始填充下一個。在某種意義上,緩衝區數組就像一個大緩衝區。
分散/聚集的應用
分散/聚集 I/O 對於將數據劃分爲幾個部分很有用。例如,您可能在編寫一個使用消息對象的網絡應用程序,每一個消息被劃分爲固定長度的頭部和固定長度的正文。您可以創建一個剛好可以容納頭部的緩衝區和另一個剛好可以容難正文的緩衝區。當您將它們放入一個數組中並使用分散讀取來向它們讀入消息時,頭部和正文將整齊地劃分到這兩個緩衝區中。
我們從緩衝區所得到的方便性對於緩衝區數組同樣有效。因爲每一個緩衝區都跟蹤自己還可以接受多少數據,所以分散讀取會自動找到有空間接受數據的第一個緩衝區。在這個緩衝區填滿後,它就會移動到下一個緩衝區。
聚集寫入
聚集寫入 類似於分散讀取,只不過是用來寫入。它也有接受緩衝區數組的方法:
long write( ByteBuffer[] srcs );
long write( ByteBuffer[] srcs, int offset, int length );
聚集寫對於把一組單獨的緩衝區中組成單個數據流很有用。爲了與上面的消息例子保持一致,您可以使用聚集寫入來自動將網絡消息的各個部分組裝爲單個數據流,以便跨越網絡傳輸消息。
從例子程序 UseScatterGather.java 中可以看到分散讀取和聚集寫入的實際應用。
文件鎖定
概述
文件鎖定初看起來可能讓人迷惑。它 似乎 指的是防止程序或者用戶訪問特定文件。事實上,文件鎖就像常規的 Java 對象鎖 ― 它們是 勸告式的(advisory) 鎖。它們不阻止任何形式的數據訪問,相反,它們通過鎖的共享和獲取賴允許系統的不同部分相互協調。
您可以鎖定整個文件或者文件的一部分。如果您獲取一個排它鎖,那麼其他人就不能獲得同一個文件或者文件的一部分上的鎖。如果您獲得一個共享鎖,那麼其他人可以獲得同一個文件或者文件一部分上的共享鎖,但是不能獲得排它鎖。文件鎖定並不總是出於保護數據的目的。例如,您可能臨時鎖定一個文件以保證特定的寫操作成爲原子的,而不會有其他程序的干擾。
大多數操作系統提供了文件系統鎖,但是它們並不都是採用同樣的方式。有些實現提供了共享鎖,而另一些僅提供了排它鎖。事實上,有些實現使得文件的鎖定部分不可訪問,儘管大多數實現不是這樣的。
在本節中,您將學習如何在 NIO 中執行簡單的文件鎖過程,我們還將探討一些保證被鎖定的文件儘可能可移植的方法。
鎖定文件
要獲取文件的一部分上的鎖,您要調用一個打開的 FileChannel
上的 lock()
方法。注意,如果要獲取一個排它鎖,您必須以寫方式打開文件。
1 2 3 |
|
在擁有鎖之後,您可以執行需要的任何敏感操作,然後再釋放鎖:
1 |
|
在釋放鎖後,嘗試獲得鎖的其他任何程序都有機會獲得它。
本小節的例子程序 UseFileLocks.java 必須與它自己並行運行。這個程序獲取一個文件上的鎖,持有三秒鐘,然後釋放它。如果同時運行這個程序的多個實例,您會看到每個實例依次獲得鎖。
文件鎖定和可移植性
文件鎖定可能是一個複雜的操作,特別是考慮到不同的操作系統是以不同的方式實現鎖這一事實。下面的指導原則將幫助您儘可能保持代碼的可移植性:
- 只使用排它鎖。
- 將所有的鎖視爲勸告式的(advisory)。
連網和異步 I/O
概述
連網是學習異步 I/O 的很好基礎,而異步 I/O 對於在 Java 語言中執行任何輸入/輸出過程的人來說,無疑都是必須具備的知識。NIO 中的連網與 NIO 中的其他任何操作沒有什麼不同 ― 它依賴通道和緩衝區,而您通常使用 InputStream
和 OutputStream
來獲得通道。
本節首先介紹異步 I/O 的基礎 ― 它是什麼以及它不是什麼,然後轉向更實用的、程序性的例子。
異步 I/O
異步 I/O 是一種 沒有阻塞地 讀寫數據的方法。通常,在代碼進行 read()
調用時,代碼會阻塞直至有可供讀取的數據。同樣, write()
調用將會阻塞直至數據能夠寫入。
另一方面,異步 I/O 調用不會阻塞。相反,您將註冊對特定 I/O 事件的興趣 ― 可讀的數據的到達、新的套接字連接,等等,而在發生這樣的事件時,系統將會告訴您。
異步 I/O 的一個優勢在於,它允許您同時根據大量的輸入和輸出執行 I/O。同步程序常常要求助於輪詢,或者創建許許多多的線程以處理大量的連接。使用異步 I/O,您可以監聽任何數量的通道上的事件,不用輪詢,也不用額外的線程。
我們將通過研究一個名爲 MultiPortEcho.java 的例子程序來查看異步 I/O 的實際應用。這個程序就像傳統的 echo server,它接受網絡連接並向它們迴響它們可能發送的數據。不過它有一個附加的特性,就是它能同時監聽多個端口,並處理來自所有這些端口的連接。並且它只在單個線程中完成所有這些工作。
Selectors
本節的闡述對應於 MultiPortEcho
的源代碼中的 go()
方法的實現,因此應該看一下源代碼,以便對所發生的事情有個更全面的瞭解。
異步 I/O 中的核心對象名爲 Selector
。Selector
就是您註冊對各種 I/O 事件的興趣的地方,而且當那些事件發生時,就是這個對象告訴您所發生的事件。
所以,我們需要做的第一件事就是創建一個 Selector
:
1 |
|
然後,我們將對不同的通道對象調用 register()
方法,以便註冊我們對這些對象中發生的 I/O 事件的興趣。register()
的第一個參數總是這個 Selector
。
打開一個 ServerSocketChannel
爲了接收連接,我們需要一個 ServerSocketChannel
。事實上,我們要監聽的每一個端口都需要有一個 ServerSocketChannel
。對於每一個端口,我們打開一個 ServerSocketChannel
,如下所示:
1 2 3 4 5 6 |
|
第一行創建一個新的 ServerSocketChannel
,最後三行將它綁定到給定的端口。第二行將 ServerSocketChannel
設置爲 非阻塞的 。我們必須對每一個要使用的套接字通道調用這個方法,否則異步 I/O 就不能工作。
選擇鍵
下一步是將新打開的 ServerSocketChannels
註冊到 Selector
上。爲此我們使用 ServerSocketChannel.register() 方法,如下所示:
1 |
|
register()
的第一個參數總是這個 Selector
。第二個參數是 OP_ACCEPT
,這裏它指定我們想要監聽 accept 事件,也就是在新的連接建立時所發生的事件。這是適用於 ServerSocketChannel
的唯一事件類型。
請注意對 register()
的調用的返回值。 SelectionKey
代表這個通道在此 Selector
上的這個註冊。當某個 Selector
通知您某個傳入事件時,它是通過提供對應於該事件的 SelectionKey
來進行的。SelectionKey
還可以用於取消通道的註冊。
內部循環
現在已經註冊了我們對一些 I/O 事件的興趣,下面將進入主循環。使用 Selectors
的幾乎每個程序都像下面這樣使用內部循環:
1 2 3 4 5 6 7 8 9 |
|
首先,我們調用 Selector
的 select()
方法。這個方法會阻塞,直到至少有一個已註冊的事件發生。當一個或者更多的事件發生時, select()
方法將返回所發生的事件的數量。
接下來,我們調用 Selector
的 selectedKeys()
方法,它返回發生了事件的 SelectionKey
對象的一個 集合
。
我們通過迭代 SelectionKeys
並依次處理每個 SelectionKey
來處理事件。對於每一個 SelectionKey
,您必須確定發生的是什麼 I/O 事件,以及這個事件影響哪些 I/O 對象。
監聽新連接
程序執行到這裏,我們僅註冊了 ServerSocketChannel
,並且僅註冊它們“接收”事件。爲確認這一點,我們對 SelectionKey
調用 readyOps()
方法,並檢查發生了什麼類型的事件:
1 2 3 4 5 6 |
|
可以肯定地說, readOps()
方法告訴我們該事件是新的連接。
接受新的連接
因爲我們知道這個服務器套接字上有一個傳入連接在等待,所以可以安全地接受它;也就是說,不用擔心 accept()
操作會阻塞:
1 2 |
|
下一步是將新連接的 SocketChannel
配置爲非阻塞的。而且由於接受這個連接的目的是爲了讀取來自套接字的數據,所以我們還必須將 SocketChannel
註冊到 Selector
上,如下所示:
1 2 |
|
注意我們使用 register()
的 OP_READ
參數,將 SocketChannel
註冊用於 讀取 而不是 接受 新連接。
刪除處理過的 SelectionKey
在處理 SelectionKey
之後,我們幾乎可以返回主循環了。但是我們必須首先將處理過的 SelectionKey
從選定的鍵集合中刪除。如果我們沒有刪除處理過的鍵,那麼它仍然會在主集合中以一個激活的鍵出現,這會導致我們嘗試再次處理它。我們調用迭代器的 remove()
方法來刪除處理過的 SelectionKey
:
1 |
|
現在我們可以返回主循環並接受從一個套接字中傳入的數據(或者一個傳入的 I/O 事件)了。
傳入的 I/O
當來自一個套接字的數據到達時,它會觸發一個 I/O 事件。這會導致在主循環中調用 Selector.select()
,並返回一個或者多個 I/O 事件。這一次, SelectionKey
將被標記爲 OP_READ
事件,如下所示:
1 2 3 4 5 6 |
|
與以前一樣,我們取得發生 I/O 事件的通道並處理它。在本例中,由於這是一個 echo server,我們只希望從套接字中讀取數據並馬上將它發送回去。關於這個過程的細節,請參見 參考資料 中的源代碼 (MultiPortEcho.java)。
回到主循環
每次返回主循環,我們都要調用 select
的 Selector()
方法,並取得一組 SelectionKey
。每個鍵代表一個 I/O 事件。我們處理事件,從選定的鍵集中刪除 SelectionKey
,然後返回主循環的頂部。
這個程序有點過於簡單,因爲它的目的只是展示異步 I/O 所涉及的技術。在現實的應用程序中,您需要通過將通道從 Selector
中刪除來處理關閉的通道。而且您可能要使用多個線程。這個程序可以僅使用一個線程,因爲它只是一個演示,但是在現實場景中,創建一個線程池來負責 I/O 事件處理中的耗時部分會更有意義。
字符集
概述
根據 Sun 的文檔,一個 Charset
是“十六位 Unicode 字符序列與字節序列之間的一個命名的映射”。實際上,一個 Charset
允許您以儘可能最具可移植性的方式讀寫字符序列。
Java 語言被定義爲基於 Unicode。然而在實際上,許多人編寫代碼時都假設一個字符在磁盤上或者在網絡流中用一個字節表示。這種假設在許多情況下成立,但是並不是在所有情況下都成立,而且隨着計算機變得對 Unicode 越來越友好,這個假設就日益變得不能成立了。
在本節中,我們將看一下如何使用 Charsets
以適合現代文本格式的方式處理文本數據。這裏將使用的示例程序相當簡單,不過,它觸及了使用 Charset
的所有關鍵方面:爲給定的字符編碼創建 Charset
,以及使用該 Charset
解碼和編碼文本數據。
編碼/解碼
要讀和寫文本,我們要分別使用 CharsetDecoder
和 CharsetEncoder
。將它們稱爲 編碼器 和 解碼器 是有道理的。一個 字符 不再表示一個特定的位模式,而是表示字符系統中的一個實體。因此,由某個實際的位模式表示的字符必須以某種特定的 編碼 來表示。
CharsetDecoder
用於將逐位表示的一串字符轉換爲具體的 char
值。同樣,一個 CharsetEncoder
用於將字符轉換回位。
在下一個小節中,我們將考察一個使用這些對象來讀寫數據的程序。
處理文本的正確方式
現在我們將分析這個例子程序 UseCharsets.java。這個程序非常簡單 ― 它從一個文件中讀取一些文本,並將該文本寫入另一個文件。但是它把該數據當作文本數據,並使用 CharBuffer
來將該數句讀入一個 CharsetDecoder
中。同樣,它使用 CharsetEncoder
來寫回該數據。
我們將假設字符以 ISO-8859-1(Latin1) 字符集(這是 ASCII 的標準擴展)的形式儲存在磁盤上。儘管我們必須爲使用 Unicode 做好準備,但是也必須認識到不同的文件是以不同的格式儲存的,而 ASCII 無疑是非常普遍的一種格式。事實上,每種 Java 實現都要求對以下字符編碼提供完全的支持:
- US-ASCII
- ISO-8859-1
- UTF-8
- UTF-16BE
- UTF-16LE
- UTF-16
示例程序
在打開相應的文件、將輸入數據讀入名爲 inputData
的 ByteBuffer
之後,我們的程序必須創建 ISO-8859-1 (Latin1) 字符集的一個實例:
1 |
|
然後,創建一個解碼器(用於讀取)和一個編碼器 (用於寫入):
1 2 |
|
爲了將字節數據解碼爲一組字符,我們把 ByteBuffer
傳遞給 CharsetDecoder
,結果得到一個 CharBuffer
:
1 |
|
如果想要處理字符,我們可以在程序的此處進行。但是我們只想無改變地將它寫回,所以沒有什麼要做的。
要寫回數據,我們必須使用 CharsetEncoder
將它轉換回字節:
1 |
|
在轉換完成之後,我們就可以將數據寫到文件中了。
結束語和參考資料
結束語
正如您所看到的, NIO 庫有大量的特性。在一些新特性(例如文件鎖定和字符集)提供新功能的同時,許多特性在優化方面也非常優秀。
在基礎層次上,通道和緩衝區可以做的事情幾乎都可以用原來的面向流的類來完成。但是通道和緩衝區允許以 快得多 的方式完成這些相同的舊操作 ― 事實上接近系統所允許的最大速度。
不過 NIO 最強大的長度之一在於,它提供了一種在 Java 語言中執行進行輸入/輸出的新的(也是迫切需要的)結構化方式。隨諸如緩衝區、通道和異步 I/O 這些概念性(且可實現的)實體而來的,是我們重新思考 Java 程序中的 I/O過程的機會。這樣,NIO 甚至爲我們最熟悉的 I/O 過程也帶來了新的活力,同時賦予我們通過和以前不同並且更好的方式執行它們的機會。