CodeGym /課程 /JAVA 25 SELF /非同步檔案作業錯誤剖析

非同步檔案作業錯誤剖析

JAVA 25 SELF
等級 56 , 課堂 4
開放

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=0limit=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 中的錯誤

在非同步程式碼中,錯誤不會在主執行緒「拋出」——它們會進入介面 CompletionHandlerfailed(...) 方法。若未實作或留空,錯誤就會悄悄消失,程式行為也會變得詭異。

「隱形」錯誤的範例:

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 時:採用同步機制(CountDownLatchSemaphore),在關閉通道/程式之前正確等待所有作業完成。

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);
// 主執行緒立即結束——程式關閉,作業來不及完成

正確作法?

使用 CountDownLatchSemaphore 或至少 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。
1
任務
JAVA 25 SELF, 等級 56, 課堂 4
上鎖
即時訊息傳送:非同步寫入與可靠關閉 ✉️
即時訊息傳送:非同步寫入與可靠關閉 ✉️
1
任務
JAVA 25 SELF, 等級 56, 課堂 4
上鎖
系統啟動同步:等待載入設定 ⚙️
系統啟動同步:等待載入設定 ⚙️
1
問卷/小測驗
非同步檔案操作,等級 56,課堂 4
未開放
非同步檔案操作
非同步檔案操作
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION