使用Java NIO 和 NIO2實現檔案輸入/輸出

banq發表於2022-01-17

當您需要快速移動大量檔案資料或套接字資料時,請使用這些低階 Java API。
本文是關於在檔案輸入/輸出方面實現高效能的。高效能不僅意味著快速執行 I/O 操作,而且還消耗(或佔用)JVM 和其他地方的最少資源。
 

介紹 NIO 和緩衝區
最好的起點是新增第一個 API 以解決 Java 檔案輸入/輸出的效能問題。新的輸入/輸出 (NIO) 作為JSR 51新增到 Java 1.4 中。缺乏非阻塞通訊和其他 I/O 特性一直是 Java 早期版本的主要批評,NIO 的到來帶來了廣泛而有用的特性集,包括

  • 不需要遍歷 Java 堆的 I/O 操作的抽象層
  • 編碼和解碼字符集的能力
  • 可以將檔案對映到記憶體中儲存的資料的介面
  • 執行非阻塞 I/O 的能力
  • 一個新的正規表示式庫

隨著 Java 成長為一種用於伺服器端開發的有吸引力的語言,這些功能尤其重要。最近的版本延續了這一趨勢,並新增了其他面向效能的 API,例如非同步 I/O。這些將在稍後討論。
不過,首先,考慮一下 NIOBufferChannel類。它們每個都提供一個類,該類充當特定(原始)型別元素的線性序列的容器。為簡單起見,以下示例使用ByteBuffer( 的子類Buffer)。
請注意,NIO 為高效能 I/O提供了低階抽象,但這些 API 對於開發人員來說並不總是像前面文章中介紹的高度抽象的 API 那樣容易使用。僅當您有特定的效能需求時才應使用低階 API。
位元組緩衝區可以由堆上 Javabyte[]陣列或 Java 堆之外的記憶體區域(但仍在 JVM 程式的 C 堆內)支援。在這兩種方法中,第二種更為常見。
  • 在第一種情況下,堆上陣列緩衝區本質上提供了一個更物件導向的底層byte[].
  • 第二種情況稱為直接緩衝區方法,這種方法儘可能繞過Java堆。這可以帶來效能優勢,例如,當您在其他堆外資料之間進行大量複製時。在實踐中,直接緩衝區比堆上陣列緩衝區更常用。

ByteBuffer 提供了三種建立緩衝區的靜態方法。
  • allocateDirect() 用於執行本機 I/O 操作
  • allocate() 用於建立新的堆分配緩衝區
  • wrap() 用於設定由已經存在的位元組陣列支援的緩衝區

您可以在以下程式碼中看到它是如何工作的:

ByteBuffer b =
   ByteBuffer.allocateDirect(128 * 1024 * 1024);
ByteBuffer b2 =
   ByteBuffer.allocate(1024 * 1024);

byte[] data = {1, 2, 3, 4, 5};
ByteBuffer b3 = ByteBuffer.wrap(data);


位元組緩衝區都是關於對位元組的低階訪問,這意味著您必須手動處理細節。這包括需要處理資料格式的位元組順序以及 Java 數字原語(包括位元組)已簽名的事實。
緩衝區低階細節的 API 非常簡單。

b.order(ByteOrder.LITTLE_ENDIAN);

int capacity = b.capacity();
int position = b.position();
int limit = b.limit();
int remaining = b.remaining();
boolean more = b.hasRemaining();

如您所見,當您使用ByteBufferAPI 時,您必須處理非常底層的方面,例如緩衝區的容量和您在其中的當前位置。
您可以透過以下兩種方式之一在緩衝區中讀取和寫入資料:一次一個值或作為批次操作。透過使用批次操作,您可以期望從這些低階函式中獲得效能提升。
這是處理單個值的 API。

b.put((byte)101);
b.putChar('a');
b.putInt(0xcafebabe);

double d = b.getDouble()


單值 API 還包含一個過載,用於緩衝區內的絕對定位。

b.put(0, (byte)9);

批次 API 與 abyte[]或 a一起使用ByteBuffer,並將可能大量的值作為單個操作進行操作,如下所示。

b.put(data);
b.put(b2);

b.get(data, 0, data.length);


 

介紹通道
緩衝區是您使用堆上還是堆外操作的記憶體中抽象。在堆外操作的情況下,您應該將資料從緩衝區移動到也是堆外的其他地方,而不必透過 Java 堆傳輸資料。
例如,您可能希望直接從緩衝區讀取資料到檔案或套接字或從檔案或套接字寫入資料。這是透過Channel從 package中獲得第二個物件 a 來實現的java.nio.channels。通道物件表示可以支援讀取或寫入操作的實體。檔案和套接字是常見的示例,但您可以想象用於特殊目的的其他自定義實現。
通道物件在開啟狀態下建立,隨後可以關閉。但是,一旦關閉,它們將無法重新開啟。通道被認為是進出緩衝區的單向流。這意味著它們要麼是可讀的,要麼是可寫的——但不是兩者兼而有之。
瞭解渠道的關鍵是

  • 從通道讀取將位元組放入緩衝區
  • 寫入通道從緩衝區中獲取位元組

因此,通道上的關鍵方法是
  • read() 從通道讀取到緩衝區(用於可讀通道)。
  • write() 從緩衝區寫入通道(對於可寫通道)。

這些方法經常與compact()緩衝區物件上的方法結合使用。該compact()方法丟棄緩衝區中當前位置之前的所有資料,並將所有後面的資料複製到緩衝區的開頭。該方法還將游標的位置跳過到已複製的位元組之後。這允許緊跟操作緊隨其後,例如,另一個read().
透過實際操作,這可能是最容易理解的。例如,假設您有一個大檔案,要以 16 MB 的塊進行校驗和。

FileInputStream fis = getSomeStream();
boolean fileOK = true;

try (FileChannel fchan = fis.getChannel()) {
   var buffy =
      ByteBuffer.allocateDirect(16 * 1024 * 1024);

   while(fchan.read(buffy) !=
      -1 || buffy.position() > 0 || fileOK) {
      fileOK = computeChecksum(buffy);
      buffy.compact();
      }
   }
   catch (IOException e) {
   System.out.println("Exception in I/O");
}


上面的程式碼將盡可能地使用本機 I/O,並避免在 Java 堆內外大量複製位元組。如果computeChecksum()您使用的方法得到了很好的實現,那麼這可能是一個非常高效的實現。
 

對映位元組緩衝區
對映位元組緩衝區是一種包含記憶體對映檔案(或檔案區域)的直接位元組緩衝區。這些緩衝區是從一個FileChannel物件建立的,但這裡有一個重要的注意事項:在記憶體對映操作之後一定不能使用File對應的物件,MappedByteBuffer否則將引發異常。為了緩解這種情況,您可以使用try嚴格限定物件的範圍,如下所示:

try (var raf = new RandomAccessFile(new
     File("input.txt"), "rw");
     var fc = raf.getChannel()) {
  MappedByteBuffer mbf =
    fc.map(FileChannel.MapMode.READ_WRITE, 0,
    fc.size());
  byte[] b = new byte[(int)fc.size()];
  mbf.get(b, 0, b.length);

  // Zero the in-memory copy
  for (int i = 0; i < fc.size(); i = i + 1) {
    b[i] = 0;
  }

  // Reposition to the start of the file
  mbf.position(0);

  // Zero the file
  mbf.put(b);
}


對映緩衝區擴充套件ByteBuffer了特定於記憶體對映檔案區域的附加操作,但它在其他方面是直接位元組緩衝區。對映檔案的要點是可以在外部更改內容。這意味著對映位元組緩衝區中的資料可以隨時更改,例如對映檔案的相應區域的內容被另一個程式更改時。

請注意,檔案對映的精確語義取決於作業系統,因此 JDK 無法保證對檔案磁碟副本的任何更改何時會顯示在記憶體對映副本中。

SeekableByteChannel
對檔案 I/O 的併發訪問由介面處理java.nio.channels.SeekableByteChannel。這個介面 ( FileChannel) 的主要實現可以儲存您從檔案中讀取的當前位置,以及您正在寫入的檔案中的位置。這意味著您可以讓多個執行緒在不同位置讀取和/或寫入同一通道,從而實現更快的檔案 I/O。

建立可搜尋的頻道非常容易。

var readChannel =
   Files.newByteChannel(Path.of("temp.txt"),
   StandardOpenOption.READ);

var writeChannel =
   Files.newByteChannel(Path.of("temp.txt"),
   StandardOpenOption.WRITE);

通道現在可以使用 上的方法SeekableByteChannel,包括

  • position(),返回此通道的位置
  • position(long newPosition),設定此通道的位置
  • read(ByteBuffer dst),它將一個位元組序列讀入給定的緩衝區
  • size(),它返回此通道連線到的實體的當前大小
  • write(ByteBuffer src),它將位元組從緩衝區寫入通道

您現在可以傳遞readChannel給讀取執行緒和writeChannel寫入執行緒,並且這些通道可以獨立使用——儘管最終在同一個檔案上操作。
儘管可以使用緩衝區實現所有功能,但對於大型 I/O 操作的功能仍然存在限制。例如,緩衝區的分配限制為 2 GB,因為建構函式引數是 int。這意味著對比這更大的檔案的操作必須零碎完成。
這不是假設。想象一下任務,例如在檔案系統之間傳輸 10 GB 的資料,當在單個執行緒上同步完成時效能很差。
在帶有 Java 7 的 NIO.2 到來之前,處理這些型別的操作通常是透過編寫自定義多執行緒程式碼並管理一個單獨的執行緒池來執行後臺複製來完成的。這種型別的低階編碼很容易出錯,坦率地說,這是應該委託給框架功能的程式碼型別。
 

NIO.2 的非同步操作
NIO.2 為基於套接字和基於檔案的 I/O 新增了非同步功能,允許您充分利用硬體的功能。對於任何希望在伺服器端和系統程式設計空間中保持相關性的語言來說,這是一個重要的特性。
那麼,術語非同步 I/O是什麼意思?非同步 I/O 只是一種輸入/輸出處理,它允許在讀取和寫入完成之前進行其他活動。
NIO.2 API 提供了許多您可以使用的新非同步通道。

  • AsynchronousFileChannel 用於基於檔案的 I/O
  • AsynchronousSocketChannel用於基於套接字的 I/O;這支援超時
  • AsynchronousServerSocketChannel 對於接受連線的非同步套接字
  • AsynchronousDatagramChannel用於“一勞永逸”的 I/O;它不檢查有效的連線

使用 NIO.2 非同步 I/O API 時可以採用兩種主要風格:Future風格和Callback風格。
未來風格。Future 樣式使用Future來自java.util.concurrent. 該Future物件表示您的非同步操作的結果,並且仍然處於掛起狀態或將在操作完成後完全實現。
通常,get()當非同步 I/O 活動完成時,您將使用該方法(有或沒有超時)來檢索結果。以下示例從檔案中讀取 100 個位元組,然後獲取結果(這將是實際讀取的位元組數)。

var file = Path.of("/usr/ben/foobar.txt");

try (var channel =
  AsynchronousFileChannel.open(file)) {
    var buffer = ByteBuffer.allocate(100);
    Future<Integer> result = channel.read(buffer, 0);

    BusinessProcess.doSomethingElse();

    var bytesRead = result.get();
    System.out.println("Bytes read [" + bytesRead + "]");
  } catch (IOException | ExecutionException |
    InterruptedException e) {
    e.printStackTrace();
}

Future物件相對來說比較簡單,因為它們有一個get()方法可以返回操作的結果——如果操作還沒有完成就會阻塞。這些物件還具有isDone()告訴您操作是否已完成的非阻塞方法。這意味著您可以透過簡單地定期檢查isDone()然後僅get()在操作完成後才呼叫來避免無限期阻塞。
回撥樣式。如果 Future 樣式對您來說似乎違反直覺,有一種替代技術,稱為 Callback 樣式,它使用該CompletionHandler介面。一些開發人員更喜歡使用回撥樣式,因為它類似於事件處理程式碼。
java.nio.channels.CompletionHandler<V, A>介面(其中是V結果型別,並且A是您從中獲取結果的附加物件)有兩個必須給出實現的方法。當然,這意味著您不能使用 lambda 表示式來表示一個。相反,通常使用匿名內部類。
必須為這些方法completed(V, A)和failed(V, A)提供一個實現,該實現描述當非同步 I/O 操作成功完成或由於某種原因失敗時程式應如何執行。當非同步 I/O 活動完成時,將呼叫這兩種方法中的一種(並且只有一種)。
以下是和之前一樣的任務,從檔案中讀取 100 個位元組,但這次使用的是CompletionHandler<Integer, ByteBuffer>介面:

var file = Path.of("/usr/ben/foobar.txt");

try (var channel = AsynchronousFileChannel.open(file)) {
    var buffer = ByteBuffer.allocate(100);
    var handler = new CompletionHandler<Integer,
        ByteBuffer>() {
        public void completed(Integer result,
            ByteBuffer attachment) {
            System.out.println("Bytes read [" + result + "]");
        }

        public void failed(Throwable exception,
            ByteBuffer attachment) {
            exception.printStackTrace();
        }
   };

   channel.read(buffer, 0, buffer, handler);
} catch (IOException e) {
    e.printStackTrace();
}


上面的示例都是基於檔案的,但也可以使用套接字 API 執行非常相似的任務。


 

相關文章