CodeGym /課程 /JAVA 25 SELF /NIO 通道與 ByteBuffer

NIO 通道與 ByteBuffer

JAVA 25 SELF
等級 41 , 課堂 1
開放

1. NIO 通道簡介

在傳統的 Java IO(java.io)中,一切遵循「一個串流——一個檔案或資源」的原則。只要開始讀寫,串流就會被阻塞並等待操作完成。對簡單情境來說這很方便,但在高負載系統中這種做法會成為瓶頸:如果有成千上萬個連線,就會有成千上萬個執行緒在等待。

在 NIO(New IO)中,做法不同。這裡的輸入/輸出可以是非阻塞的,執行緒不必空等。當某些資料尚在傳輸時,它可以切換去處理其他任務。這讓我們能以極少的執行緒同時服務海量連線。

差異也體現在細節上。在「舊式」IO 中,工作圍繞著會讀寫位元組或字元的串流展開,但在操作期間總是阻塞。而在 NIO 中,核心概念是通道(Channels)與緩衝區(Buffers)。它們讓非阻塞 I/O 成為可能(對伺服器很重要),並能採用 zero-copy 手法,讓資料可直接傳遞,繞過 JVM 中多餘的緩衝區拷貝。

比較:串流 (Streams) vs 通道 (Channels)

串流(InputStream/OutputStream):

  • 一次讀/寫一個位元組或一個位元組陣列。
  • 無法直接控制檔案中的位置。
  • 對非常大的檔案支援效率不佳。

通道(Channel):

  • 透過緩衝區(Buffer)讀寫資料。
  • 可以管理位置(包含隨機存取)。
  • 支援非同步與非阻塞模式。
  • 可使用 zero-copy 以實現高速拷貝。

2. FileChannel 與 SeekableByteChannel

使用緩衝區進行資料讀寫

FileChannel 是用於操作檔案的主要通道。它可以從 FileInputStreamFileOutputStream 取得,或透過 NIO.2 —— Files.newByteChannel(回傳 SeekableByteChannel)。

範例:透過 FileChannel 與 ByteBuffer 讀取檔案

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
    public static void main(String[] args) throws Exception {
        try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
             FileChannel channel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(1024); // 1 KB 緩衝區

            int bytesRead = channel.read(buffer); // 讀入緩衝區
            while (bytesRead != -1) {
                buffer.flip(); // 將緩衝區切換為讀取模式
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear(); // 清空緩衝區以便下一次讀取
                bytesRead = channel.read(buffer);
            }
        }
    }
}

寫入檔案:

try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
     FileChannel channel = file.getChannel()) {

    ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
    channel.write(buffer);
}

NIO.2:透過 Files.newByteChannel 開啟通道

import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;

Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
    ByteBuffer buf = ByteBuffer.allocate(256);
    ch.read(buf);
}

定位(position())與調整大小(truncate()

  • position() —— 允許取得或設定檔案中的目前位置(類似「游標」)。
  • truncate(long size) —— 將檔案截斷為指定大小。
channel.position(100);   // 移動到第 100 個位元組
channel.truncate(1024);  // 將檔案截斷至 1 KB

直接存取與位置式存取檔案

  • 直接存取: 可以讀寫檔案的任意位置,而非僅限於循序操作。
  • 位置式存取: 可以在指定位置讀寫資料,而不改變通道的目前位置。
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128);   // 從位置 128 讀取 4 個位元組,且不改變 channel.position()

3. ByteBuffer:運作方式

核心參數:capacitylimitpositionmark

  • capacity —— 緩衝區的最大大小(建立時決定)。
  • limit —— 可讀/寫的邊界(預設等於 capacity)。
  • position —— 目前位置(寫入位置/讀取來源)。
  • mark —— 可設定的「書籤」,之後可回到該位置。

緩衝區的生命週期:

  1. 將資料寫入緩衝區(例如從通道 read() 讀入)。
  2. flip() —— 將緩衝區切換為讀取模式(position = 0limit = 當前的 position)。
  3. 從緩衝區讀取資料(get())。
  4. clear() —— 清空緩衝區以便下一次寫入(position = 0limit = capacity)。

範例:

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip();                // 現在可以讀取
byte value = buffer.get();    // 42
buffer.clear();               // 準備好進行新的寫入

建立緩衝區:allocate() vs allocateDirect()

ByteBuffer 有兩種主要方式可建立緩衝區,其差異在實務上相當明顯。allocate() 會在 JVM 堆積中配置緩衝區:建立速度快、適合多數任務,但在進行原生 I/O 時,可能在堆積與作業系統記憶體之間發生額外拷貝。

allocateDirect() 則在 JVM 堆積之外(原生記憶體)配置空間。此類緩衝區建立成本較高、管理更複雜,但在讀寫大型檔案或網路操作時,常因減少不必要的拷貝而更快。

觀念很簡單:若你追求大量資料處理時的效能,請使用「直接」緩衝區。對於小而頻繁的操作,建立成本的額外負擔可能超過收益。

ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);

4. 高效能操作:transferTo() 與 transferFrom()

方法 transferTo()transferFrom()

FileChannel 提供兩個可實現「零拷貝」的方式 —— transferTo()transferFrom()。其核心在於資料可在檔案通道之間,或例如在檔案與網路之間直接搬移。JVM 幾乎不參與:操作由作業系統直接完成,Java 內部的緩衝區不會被觸及。

因此,複製大型檔案會快上許多:更少的拷貝、更少在使用者空間與核心空間間的切換、更低的 CPU 負載。

範例:使用 zero-copy 複製檔案

import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ZeroCopyExample {
    public static void main(String[] args) throws Exception {
        try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
             FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

            long size = src.size();
            long transferred = src.transferTo(0, size, dst);
            System.out.println("已複製的位元組數: " + transferred);
        }
    }
}

zero-copy 何時真正生效?

  • 在同一個磁碟上於檔案之間進行複製時。
  • 在透過網路傳送檔案時(例如使用 SocketChannel)。
  • 當作業系統支援 zero-copy 時(Linux、macOS、Windows 均支援)。

優點:

  • 最少複製: 資料不會經過 JVM 緩衝區。
  • 高速度: 更少切換、較低 CPU 負載。
  • 更省記憶體: 無需大型的使用者層緩衝區。

範例:「一行」複製檔案

Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// 內部若可行,可能使用 zero-copy

5. 常見錯誤

錯誤 #1:在從緩衝區讀取前忘了呼叫 flip() 在將資料寫入緩衝區後務必呼叫 flip(),否則讀取行為不會如預期:position/limit 仍停留在「寫入模式」。

錯誤 #2:對小型操作使用 allocateDirect() Direct 緩衝區適合大量資料,但對小型請求其建立成本過高。預設請優先選擇 allocate()

錯誤 #3:忘了關閉通道。 對通道與串流請一律使用 try-with-resources,以避免描述元洩漏。

錯誤 #4:混淆 position/limit/capacity 在讀寫之前先確認緩衝區處於何種模式:寫入之後需要 flip(),讀取之後若要再次寫入,則需 clear()compact()

錯誤 #5:以為 zero-copy「總是有效」。 在某些配置(不同裝置/不同檔案系統/特定旗標)下,zero-copy 可能不可用 —— 這時會回退為一般複製,效能也會不同。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION