nio的實現原理

wangyunpeng0319發表於2017-06-03

在開始之前

關於本教程

新的輸入/輸出 (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 獲取通道:

FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();

下一步是建立緩衝區:

ByteBuffer buffer = ByteBuffer.allocate( 1024 );

最後,需要將資料從通道讀到緩衝區中,如下所示:

fc.read( buffer );

您會注意到,我們不需要告訴通道要讀 多少資料 到緩衝區中。每一個緩衝區都有複雜的內部統計機制,它會跟蹤已經讀了多少資料以及還有多少空間可以容納更多的資料。我們將在 緩衝區內部細節 中介紹更多關於緩衝區統計機制的內容。

寫入檔案

在 NIO 中寫入檔案類似於從檔案中讀取。首先從 FileOutputStream 獲取一個通道:

FileOutputStream fout = new FileOutputStream( "writesomebytes.txt" );
FileChannel fc = fout.getChannel();

下一步是建立一個緩衝區並在其中放入一些資料 - 在這裡,資料將從一個名為 message 的陣列中取出,這個陣列包含字串 "Some bytes" 的 ASCII 位元組(本教程後面將會解釋 buffer.flip() 和 buffer.put() 呼叫)。

ByteBuffer buffer = ByteBuffer.allocate( 1024 );

for (int i=0; i<message.length; ++i) {
     buffer.put( message[i] );
}
buffer.flip();

最後一步是寫入緩衝區中:

fc.write( buffer );

注意在這裡同樣不需要告訴通道要寫入多資料。緩衝區的內部統計機制會跟蹤它包含多少資料以及還有多少資料要寫入。

讀寫結合

下面我們將看一下在結合讀和寫時會有什麼情況。我們以一個名為 CopyFile.java 的簡單程式作為這個練習的基礎,它將一個檔案的所有內容拷貝到另一個檔案中。CopyFile.java 執行三個基本操作:首先建立一個 Buffer,然後從原始檔中將資料讀到這個緩衝區中,然後將緩衝區寫入目標檔案。這個程式不斷重複 ― 讀、寫、讀、寫 ― 直到原始檔結束。

CopyFile 程式讓您看到我們如何檢查操作的狀態,以及如何使用 clear() 和 flip() 方法重設緩衝區,並準備緩衝區以便將新讀取的資料寫到另一個通道中。

執行 CopyFile 例子

因為緩衝區會跟蹤它自己的資料,所以 CopyFile 程式的內部迴圈 (inner loop) 非常簡單,如下所示:

fcin.read( buffer );
fcout.write( buffer );

第一行將資料從輸入通道 fcin 中讀入緩衝區,第二行將這些資料寫到輸出通道 fcout 。

檢查狀態

下一步是檢查拷貝何時完成。當沒有更多的資料時,拷貝就算完成,並且可以在 read() 方法返回 -1 是判斷這一點,如下所示:

int r = fcin.read( buffer );

if (r==-1) {
     break;
}

重設緩衝區

最後,在從輸入通道讀入緩衝區之前,我們呼叫 clear() 方法。同樣,在將緩衝區寫入輸出通道之前,我們呼叫 flip() 方法,如下所示:

buffer.clear();
int r = fcin.read( buffer );

if (r==-1) {
     break;
}

buffer.flip();
fcout.write( buffer );

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 的狀態如下所示:

Buffer state

回想一下 ,limit 決不能大於 capacity,此例中這兩個值都被設定為 8。我們通過將它們指向陣列的尾部之後(如果有第8個槽,則是第8個槽所在的位置)來說明這點。

Array

position 設定為0。如果我們讀一些資料到緩衝區中,那麼下一個讀取的資料就進入 slot 0 。如果我們從緩衝區寫一些資料,從緩衝區讀取的下一個位元組就來自 slot 0 。 position 設定如下所示:

Position setting

由於 capacity 不會改變,所以我們在下面的討論中可以忽略它。

第一次讀取

現在我們可以開始在新建立的緩衝區上進行讀/寫操作。首先從輸入通道中讀一些資料到緩衝區中。第一次讀取得到三個位元組。它們被放到陣列中從 position 開始的位置,這時 position 被設定為 0。讀完之後,position 就增加到 3,如下所示:

Position increased to 3

limit 沒有改變。

第二次讀取

在第二次讀取時,我們從輸入通道讀取另外兩個位元組到緩衝區中。這兩個位元組儲存在由 position 所指定的位置上, position 因而增加 2:

Position increased by 2

limit 沒有改變。

flip

現在我們要將資料寫到輸出通道中。在這之前,我們必須呼叫 flip() 方法。這個方法做兩件非常重要的事:

  1. 它將 limit 設定為當前 position
  2. 它將 position 設定為 0。

前一小節中的圖顯示了在 flip 之前緩衝區的情況。下面是在 flip 之後的緩衝區:

Buffer after the flip

我們現在可以將資料從緩衝區寫入通道了。 position 被設定為 0,這意味著我們得到的下一個位元組是第一個位元組。 limit 已被設定為原來的 position,這意味著它包括以前讀到的所有位元組,並且一個位元組也不多。

第一次寫入

在第一次寫入時,我們從緩衝區中取四個位元組並將它們寫入輸出通道。這使得 position 增加到 4,而 limit 不變,如下所示:

Position advanced to 4, limit unchanged

第二次寫入

我們只剩下一個位元組可寫了。 limit在我們呼叫 flip() 時被設定為 5,並且 position 不能超過 limit。所以最後一次寫入操作從緩衝區取出一個位元組並將它寫入輸出通道。這使得 position 增加到 5,並保持 limit 不變,如下所示:

Position advanced to 5, limit unchanged

clear

最後一步是呼叫緩衝區的 clear() 方法。這個方法重設緩衝區以便接收更多的位元組。 Clear 做兩種非常重要的事情:

  1. 它將 limit 設定為與 capacity 相同。
  2. 它設定 position 為 0。

下圖顯示了在呼叫 clear() 後緩衝區的狀態:

State of the buffer after clear() has been called

緩衝區現在可以接收新的資料了。

訪問方法

到目前為止,我們只是使用緩衝區將資料從一個通道轉移到另一個通道。然而,程式經常需要直接處理資料。例如,您可能需要將使用者資料儲存到磁碟。在這種情況下,您必須將這些資料直接放入緩衝區,然後用通道將緩衝區寫入磁碟。

或者,您可能想要從磁碟讀取使用者資料。在這種情況下,您要將資料從通道讀到緩衝區中,然後檢查緩衝區中的資料。

在本節的最後,我們將詳細分析如何使用 ByteBuffer 類的 get() 和 put() 方法直接訪問緩衝區中的資料。

get() 方法

ByteBuffer 類中有四個 get() 方法:

  1. byte get();
  2. ByteBuffer get( byte dst[] );
  3. ByteBuffer get( byte dst[], int offset, int length );
  4. byte get( int index );

第一個方法獲取單個位元組。第二和第三個方法將一組位元組讀到一個陣列中。第四個方法從緩衝區中的特定位置獲取位元組。那些返回 ByteBuffer 的方法只是返回撥用它們的緩衝區的 this 值。

此外,我們認為前三個 get() 方法是相對的,而最後一個方法是絕對的。 相對 意味著 get() 操作服從 limit 和 position 值 ― 更明確地說,位元組是從當前 position 讀取的,而 position 在 get 之後會增加。另一方面,一個 絕對 方法會忽略 limit 和 position 值,也不會影響它們。事實上,它完全繞過了緩衝區的統計方法。

上面列出的方法對應於 ByteBuffer 類。其他類有等價的 get() 方法,這些方法除了不是處理位元組外,其它方面是是完全一樣的,它們處理的是與該緩衝區類相適應的型別。

put()方法

ByteBuffer 類中有五個 put() 方法:

  1. ByteBuffer put( byte b );
  2. ByteBuffer put( byte src[] );
  3. ByteBuffer put( byte src[], int offset, int length );
  4. ByteBuffer put( ByteBuffer src );
  5. 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 中看到這些方法的實際應用。

緩衝區的使用:一個內部迴圈

下面的內部迴圈概括了使用緩衝區將資料從輸入通道拷貝到輸出通道的過程。

while (true) {
     buffer.clear();
     int r = fcin.read( buffer );

     if (r==-1) {
       break;
     }

     buffer.flip();
     fcout.write( buffer );
}

read() 和 write() 呼叫得到了極大的簡化,因為許多工作細節都由緩衝區完成了。 clear() 和 flip() 方法用於讓緩衝區在讀和寫之間切換。

關於緩衝區的更多內容

概述

到目前為止,您已經學習了使用緩衝區進行日常工作所需要掌握的大部分內容。我們的例子沒怎麼超出標準的讀/寫過程種類,在原來的 I/O 中可以像在 NIO 中一樣容易地實現這樣的標準讀寫過程。

本節將討論使用緩衝區的一些更復雜的方面,比如緩衝區分配、包裝和分片。我們還會討論 NIO 帶給 Java 平臺的一些新功能。您將學到如何建立不同型別的緩衝區以達到不同的目的,如可保護資料不被修改的 只讀 緩衝區,和直接對映到底層作業系統緩衝區的 直接 緩衝區。我們將在本節的最後介紹如何在 NIO 中建立記憶體對映檔案。

緩衝區分配和包裝

在能夠讀和寫之前,必須有一個緩衝區。要建立緩衝區,您必須 分配 它。我們使用靜態方法 allocate() 來分配緩衝區:

ByteBuffer buffer = ByteBuffer.allocate( 1024 );

allocate() 方法分配一個具有指定大小的底層陣列,並將它包裝到一個緩衝區物件中 ― 在本例中是一個 ByteBuffer

您還可以將一個現有的陣列轉換為緩衝區,如下所示:

byte array[] = new byte[1024];
ByteBuffer buffer = ByteBuffer.wrap( array );

本例使用了 wrap() 方法將一個陣列包裝為緩衝區。必須非常小心地進行這類操作。一旦完成包裝,底層資料就可以通過緩衝區或者直接訪問。

緩衝區分片

slice() 方法根據現有的緩衝區建立一種 子緩衝區 。也就是說,它建立一個新的緩衝區,新緩衝區與原來的緩衝區的一部分共享資料。

使用例子可以最好地說明這點。讓我們首先建立一個長度為 10 的 ByteBuffer

ByteBuffer buffer = ByteBuffer.allocate( 10 );

然後使用資料來填充這個緩衝區,在第 n 個槽中放入數字 n

for (int i=0; i<buffer.capacity(); ++i) {
     buffer.put( (byte)i );
}

現在我們對這個緩衝區 分片 ,以建立一個包含槽 3 到槽 6 的子緩衝區。在某種意義上,子緩衝區就像原來的緩衝區中的一個 視窗 

視窗的起始和結束位置通過設定 position 和 limit 值來指定,然後呼叫 Buffer 的 slice() 方法:

buffer.position( 3 );
buffer.limit( 7 );
ByteBuffer slice = buffer.slice();

片 是緩衝區的 子緩衝區 。不過, 片段 和 緩衝區 共享同一個底層資料陣列,我們在下一節將會看到這一點。

緩衝區份片和資料共享

我們已經建立了原緩衝區的子緩衝區,並且我們知道緩衝區和子緩衝區共享同一個底層資料陣列。讓我們看看這意味著什麼。

我們遍歷子緩衝區,將每一個元素乘以 11 來改變它。例如,5 會變成 55。

for (int i=0; i<slice.capacity(); ++i) {
     byte b = slice.get( i );
     b *= 11;
     slice.put( i, b );
}

最後,再看一下原緩衝區中的內容:

buffer.position( 0 );
buffer.limit( buffer.capacity() );

while (buffer.remaining()>0) {
     System.out.println( buffer.get() );
}

結果表明只有在子緩衝區視窗中的元素被改變了:

$ java SliceBuffer
0
1
2
33
44
55
66
7
8
9

緩衝區片對於促進抽象非常有幫助。可以編寫自己的函式處理整個緩衝區,而且如果想要將這個過程應用於子緩衝區上,您只需取主緩衝區的一個片,並將它傳遞給您的函式。這比編寫自己的函式來取額外的引數以指定要對緩衝區的哪一部分進行操作更容易。

只讀緩衝區

只讀緩衝區非常簡單 ― 您可以讀取它們,但是不能向它們寫入。可以通過呼叫緩衝區的 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 個位元組對映到記憶體中:

MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE,
     0, 1024 );

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() 方法。注意,如果要獲取一個排它鎖,您必須以寫方式開啟檔案。

RandomAccessFile raf = new RandomAccessFile( "usefilelocks.txt", "rw" );
FileChannel fc = raf.getChannel();
FileLock lock = fc.lock( start, end, false );

在擁有鎖之後,您可以執行需要的任何敏感操作,然後再釋放鎖:

lock.release();

在釋放鎖後,嘗試獲得鎖的其他任何程式都有機會獲得它。

本小節的例子程式 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 中的核心物件名為 SelectorSelector 就是您註冊對各種 I/O 事件的興趣的地方,而且當那些事件發生時,就是這個物件告訴您所發生的事件。

所以,我們需要做的第一件事就是建立一個 Selector

Selector selector = Selector.open();

然後,我們將對不同的通道物件呼叫 register() 方法,以便註冊我們對這些物件中發生的 I/O 事件的興趣。register() 的第一個引數總是這個 Selector

開啟一個 ServerSocketChannel

為了接收連線,我們需要一個 ServerSocketChannel。事實上,我們要監聽的每一個埠都需要有一個 ServerSocketChannel 。對於每一個埠,我們開啟一個 ServerSocketChannel,如下所示:

ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking( false );

ServerSocket ss = ssc.socket();
InetSocketAddress address = new InetSocketAddress( ports[i] );
ss.bind( address );

第一行建立一個新的 ServerSocketChannel ,最後三行將它繫結到給定的埠。第二行將 ServerSocketChannel 設定為 非阻塞的 。我們必須對每一個要使用的套接字通道呼叫這個方法,否則非同步 I/O 就不能工作。

選擇鍵

下一步是將新開啟的 ServerSocketChannels 註冊到 Selector上。為此我們使用 ServerSocketChannel.register() 方法,如下所示:

SelectionKey key = ssc.register( selector, SelectionKey.OP_ACCEPT );

register() 的第一個引數總是這個 Selector。第二個引數是 OP_ACCEPT,這裡它指定我們想要監聽 accept 事件,也就是在新的連線建立時所發生的事件。這是適用於 ServerSocketChannel 的唯一事件型別。

請注意對 register() 的呼叫的返回值。 SelectionKey 代表這個通道在此 Selector 上的這個註冊。當某個 Selector 通知您某個傳入事件時,它是通過提供對應於該事件的 SelectionKey 來進行的。SelectionKey 還可以用於取消通道的註冊。

內部迴圈

現在已經註冊了我們對一些 I/O 事件的興趣,下面將進入主迴圈。使用 Selectors 的幾乎每個程式都像下面這樣使用內部迴圈:

int num = selector.select();

Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();

while (it.hasNext()) {
     SelectionKey key = (SelectionKey)it.next();
     // ... deal with I/O event ...
}

首先,我們呼叫 Selector 的 select() 方法。這個方法會阻塞,直到至少有一個已註冊的事件發生。當一個或者更多的事件發生時, select() 方法將返回所發生的事件的數量。

接下來,我們呼叫 Selector 的 selectedKeys() 方法,它返回發生了事件的 SelectionKey 物件的一個 集合 

我們通過迭代 SelectionKeys 並依次處理每個 SelectionKey 來處理事件。對於每一個 SelectionKey,您必須確定發生的是什麼 I/O 事件,以及這個事件影響哪些 I/O 物件。

監聽新連線

程式執行到這裡,我們僅註冊了 ServerSocketChannel,並且僅註冊它們“接收”事件。為確認這一點,我們對 SelectionKey 呼叫 readyOps() 方法,並檢查發生了什麼型別的事件:

if ((key.readyOps() & SelectionKey.OP_ACCEPT)
     == SelectionKey.OP_ACCEPT) {

     // Accept the new connection
     // ...
}

可以肯定地說, readOps() 方法告訴我們該事件是新的連線。

接受新的連線

因為我們知道這個伺服器套接字上有一個傳入連線在等待,所以可以安全地接受它;也就是說,不用擔心 accept() 操作會阻塞:

ServerSocketChannel ssc = (ServerSocketChannel)key.channel();
SocketChannel sc = ssc.accept();

下一步是將新連線的 SocketChannel 配置為非阻塞的。而且由於接受這個連線的目的是為了讀取來自套接字的資料,所以我們還必須將 SocketChannel 註冊到 Selector上,如下所示:

sc.configureBlocking( false );
SelectionKey newKey = sc.register( selector, SelectionKey.OP_READ );

注意我們使用 register() 的 OP_READ 引數,將 SocketChannel 註冊用於 讀取 而不是 接受 新連線。

刪除處理過的 SelectionKey

在處理 SelectionKey 之後,我們幾乎可以返回主迴圈了。但是我們必須首先將處理過的 SelectionKey 從選定的鍵集合中刪除。如果我們沒有刪除處理過的鍵,那麼它仍然會在主集合中以一個啟用的鍵出現,這會導致我們嘗試再次處理它。我們呼叫迭代器的 remove() 方法來刪除處理過的 SelectionKey

it.remove();

現在我們可以返回主迴圈並接受從一個套接字中傳入的資料(或者一個傳入的 I/O 事件)了。

傳入的 I/O

當來自一個套接字的資料到達時,它會觸發一個 I/O 事件。這會導致在主迴圈中呼叫 Selector.select(),並返回一個或者多個 I/O 事件。這一次, SelectionKey 將被標記為 OP_READ 事件,如下所示:

} else if ((key.readyOps() & SelectionKey.OP_READ)
     == SelectionKey.OP_READ) {
     // Read the data
     SocketChannel sc = (SocketChannel)key.channel();
     // ...
}

與以前一樣,我們取得發生 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) 字符集的一個例項:

Charset latin1 = Charset.forName( "ISO-8859-1" );

然後,建立一個解碼器(用於讀取)和一個編碼器 (用於寫入):

CharsetDecoder decoder = latin1.newDecoder();
CharsetEncoder encoder = latin1.newEncoder();

為了將位元組資料解碼為一組字元,我們把 ByteBuffer 傳遞給 CharsetDecoder,結果得到一個 CharBuffer

CharBuffer cb = decoder.decode( inputData );

如果想要處理字元,我們可以在程式的此處進行。但是我們只想無改變地將它寫回,所以沒有什麼要做的。

要寫回資料,我們必須使用 CharsetEncoder 將它轉換回位元組:

ByteBuffer outputData = encoder.encode( cb );

在轉換完成之後,我們就可以將資料寫到檔案中了。

相關文章