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 是用於操作檔案的主要通道。它可以從 FileInputStream、FileOutputStream 取得,或透過 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:運作方式
核心參數:capacity、limit、position、mark
- capacity —— 緩衝區的最大大小(建立時決定)。
- limit —— 可讀/寫的邊界(預設等於 capacity)。
- position —— 目前位置(寫入位置/讀取來源)。
- mark —— 可設定的「書籤」,之後可回到該位置。
緩衝區的生命週期:
- 將資料寫入緩衝區(例如從通道 read() 讀入)。
- flip() —— 將緩衝區切換為讀取模式(position = 0,limit = 當前的 position)。
- 從緩衝區讀取資料(get())。
- clear() —— 清空緩衝區以便下一次寫入(position = 0,limit = 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 可能不可用 —— 這時會回退為一般複製,效能也會不同。
GO TO FULL VERSION