1. 緩衝區錯誤:ByteBuffer、position 與 limit
非同步的讀寫方法以物件 ByteBuffer 運作。與一般陣列不同,緩衝區有「內部游標」——位置(position)與上限(limit),用來決定哪些位元組會被讀取或寫入。若對這些屬性管理不當,結果可能與預期不符,甚至會破壞整體邏輯。
在程式碼中長什麼樣?
錯誤用法範例:
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
// 糟了!我們嘗試直接從緩衝區讀取字串:
String str = new String(buf.array()); // 這是錯的!
// ...
}
// ...
});
哪裡不對?
- 讀取之後,緩衝區處於「寫入」模式:position 指向已讀取資料的末端,而 limit 指向緩衝區大小。若立刻從緩衝區讀取,你會得到一堆垃圾資料(全部 1024 位元組,即使實際只讀到 10 個)。
- 呼叫 buf.array() 會回傳整個內部陣列,而不僅是已讀取的那一段。此外,對於直接緩衝區(allocated via ByteBuffer.allocateDirect),array() 會丟出 UnsupportedOperationException。
正確作法?
在從緩衝區讀取資料之前,必須先呼叫 buffer.flip()——這會將緩衝區切換為「讀取」模式:
public void completed(Integer result, ByteBuffer buf) {
buf.flip(); // 現在 position = 0,limit = 已讀取的位元組數
String str = StandardCharsets.UTF_8.decode(buf).toString();
// ... 處理字串
}
重複使用緩衝區
若要在下一個操作中重用緩衝區,處理完資料後別忘了呼叫 buffer.clear() 或 buffer.compact():
- clear()——完全「重置」邊界:position=0、limit=capacity(),舊資料視為垃圾。
- compact()——把尚未讀取的位元組保留在緩衝區開頭,並讓其可繼續寫入。
注意:如果 result 等於 -1,表示已到達檔案結尾——不再需要額外處理。
2. 平行存取:競態與不一致
AsynchronousFileChannel 允許同時啟動多個作業。但若未妥善控管流程,很容易產生「損毀」的資料或導致程式崩潰。
問題 1:同時讀入同一個緩衝區
// 兩個同時的讀取寫入同一個緩衝區
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);
兩個讀取都寫進同一個緩衝區!若幾乎同時完成,緩衝區內容將不可預期。
問題 2:同時寫入同一個檔案
若兩個執行緒同時寫入檔案的同一區段,結果取決於哪個操作先完成。這是典型的競態條件(race condition),可能導致資料受損。
如何避免?
- 每個非同步作業使用獨立的緩衝區(ByteBuffer),避免執行緒互相干擾記憶體。
- 不要對檔案的同一範圍發起平行寫入;請分開偏移量或同步存取。
- 若需要嚴格順序,只在前一個作業完成後再啟動下一個——例如在 completed(...)(CompletionHandler)中串接。
3. 資源洩漏:忘了關閉通道
非同步通道屬於系統資源。若不關閉(channel.close()),檔案會在系統中保持「被占用」,可能造成記憶體洩漏;在 Windows 上還可能鎖住檔案,讓其他程式無法存取。
常見錯誤:
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... 啟動了操作
// 在所有作業完成後忘了呼叫 channel.close()!
正確作法?
使用 try-with-resources,並且在離開區塊之前務必等待所有作業完成:
CountDownLatch latch = new CountDownLatch(1);
try (AsynchronousFileChannel channel =
AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(4096);
channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
@Override public void completed(Integer r, ByteBuffer b) {
// 處理...
latch.countDown();
}
@Override public void failed(Throwable ex, ByteBuffer b) {
ex.printStackTrace();
latch.countDown();
}
});
latch.await(); // 等待非同步作業完成
}
// 會自動關閉
4. 例外處理:忽略 CompletionHandler 中的錯誤
在非同步程式碼中,錯誤不會在主執行緒「拋出」——它們會進入介面 CompletionHandler 的 failed(...) 方法。若未實作或留空,錯誤就會悄悄消失,程式行為也會變得詭異。
「隱形」錯誤的範例:
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
// ... 處理結果
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
// 糟了,空的!錯誤不見了
}
});
正確作法?
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("讀取檔案時發生錯誤: " + exc.getMessage());
exc.printStackTrace();
// 例如:關閉通道、更新度量、通知使用者等。
}
5. 遺失對 Future/CompletionHandler 的參照
如果透過 Future 啟動了非同步作業,卻忘了保存參照,你將無法取消作業或等待其完成。類似地,若使用 CompletionHandler 卻沒有同步所有作業的完成,程式可能會過早結束。
範例:
channel.read(buffer, 0, buffer, handler); // handler 為匿名,沒有任何地方保存
// 程式立即結束,未等待讀取完成
正確作法?
- 使用 Future<Integer> 時:保留參照,並在需要時呼叫 future.get() 或 future.cancel(true)。
- 使用 CompletionHandler 時:採用同步機制(CountDownLatch、Semaphore),在關閉通道/程式之前正確等待所有作業完成。
6. 編碼相關錯誤
讀寫文字檔案需要正確處理字元編碼。若直接把位元組當字串讀取,可能出現亂碼或遺失部分資料,特別是在分塊讀取檔案時。
問題:
// 以 1024 位元組為單位讀取檔案,然後轉成字串
String chunk = new String(buffer.array(), "UTF-8");
若一個字元被切分到兩個緩衝區之間(例如,某個 UTF-8 字元的其中一個位元組留在前一個緩衝區末尾,而其餘位元組在下一個緩衝區開頭),你會得到不正確的字元或解碼錯誤。
正確作法? 使用 CharsetDecoder,並在每次讀取之間保留「殘留」的未完成字元:
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);
// 在每次 completed(...) 時:
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false 表示尚未到達輸入結束
if (cr.isError()) {
cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();
// 當沒有更多輸入時:
decoder.flush(charBuf);
7. 程式過早結束
非同步作業在其他執行緒中執行。若主執行緒先於所有作業完成就結束,程式會在尚未取得結果時退出。
範例:
// 啟動非同步讀取
channel.read(buffer, 0, buffer, handler);
// 主執行緒立即結束——程式關閉,作業來不及完成
正確作法?
使用 CountDownLatch、Semaphore 或至少 Thread.sleep(...)(示範用),來等待所有作業完成:
CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
@Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // 等待作業完成
8. 與 ExecutorService 整合不當
AsynchronousFileChannel 允許指定自訂的 ExecutorService 來處理事件。若提供的執行緒池執行緒太少,甚至只有一個,所有作業將會序列化而非並行。若池子過大,則會有額外的情境切換開銷。
範例:
ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// 所有非同步作業本質上都會變成同步!
正確作法?
- 依據實際負載與同時作業數量選擇池的大小。
- 大多數情境可採用 ForkJoinPool.commonPool() 或 Executors.newCachedThreadPool()。
- 請記住,所提供的 ExecutorService 管理的是回呼(completed/failed)的呼叫,而非實際的磁碟 I/O。
GO TO FULL VERSION