1. 分段讀取檔案:ByteBuffer 與編碼
如今我們很少處理小型的文字檔。通常面對的是龐大的伺服器日誌、報表、CSV 檔,或是數 GB 的資料傾印。因此,重點不只是在於讀取檔案,而是要有效率地讀,且不讓應用程式「卡住」。
非同步作法正是為此而生: 它不會阻塞主執行緒 — 無論是介面或伺服端邏輯, — 允許平行讀寫大量資料,並在需要同時處理多個檔案時,讓應用程式更具延展性。
要弄清的一點是: 非同步 I/O 並不會讓磁碟本身更快 — 沒有奇蹟。它只是讓你的程式在等待磁碟完成操作時,不必發呆,可以去做其他事情。
非同步讀取如何運作?
非同步通道 (AsynchronousFileChannel) 讀取的不是字串,而是位元組區塊到 ByteBuffer 物件。這就好比你搬的是一箱箱字母,而不是一個個單字。讀完之後,你需要把這些位元組轉成字串 — 而且要考慮編碼!
範例: 以區塊方式非同步讀取檔案
我們寫一個簡單範例: 以 4096 位元組為一個區塊非同步讀檔,並將內容輸出到主控台。
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncTextReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
int position = 0;
Future<Integer> future = channel.read(buffer, position);
while (future.get() > 0) {
buffer.flip();
// 將位元組解碼為字串 (UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += chunk.getBytes(StandardCharsets.UTF_8).length;
future = channel.read(buffer, position);
}
}
}
}
重點:
- 以分段(緩衝區)方式讀取檔案,而非一次讀完整個檔案。
- 讀取後,使用 Charset 將位元組解碼為字串。
- 別忘了呼叫 buffer.clear() —— 否則下一次 read 不會生效!
為什麼僅僅解碼位元組還不夠?
問題在於,字元可能會跨兩個區塊「被切開」,特別是使用多位元組編碼(例如,"UTF-8")時。如果緩衝區中的最後一個位元組只是某個字元的一半,下一個區塊就會以該字元的「剩餘部分」開始。若沒有特別處理,你會得到亂碼,甚至發生解碼錯誤。
2. 位元組轉字串: 處理斷裂
行被分割的問題
假設你有字串 "你好\n世界\n",而緩衝區剛好結束在 "你",而 "好\n世界\n" 落在下一個區塊。若只是把字串硬拼起來,可能會遺失字元或得到不正確的字串。
解法: 使用 CharsetDecoder
Java 提供了 CharsetDecoder 類別,能正確處理此類情形。它會「記住」尚未解碼的位元組,並在區塊邊界正確還原字元。
CharsetDecoder 使用範例
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;
CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // 你的位元組
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// 現在 charBuffer 已含有正確解碼的字元
在實務中,你會在每次讀取之間保留「剩餘」資料,並在解碼時一併考量。
3. 文字檔的非同步寫入
讀取只是工作的一半。寫入同樣以位元組區塊進行,而這些位元組必須先從字串(經由編碼)取得。
範例: 非同步將字串寫入檔案
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
public class AsyncTextWriteExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("output.txt");
String text = "你好,世界!\n";
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
Future<Integer> future = channel.write(buffer, 0);
// 為了示範先等待完成(一般不必這樣做!)
future.get();
System.out.println("資料已以非同步方式寫入。");
}
}
}
說明: 在真正的非同步情境下,不要在主執行緒呼叫 future.get() —— 這會把非同步程式碼變回同步。最好使用 CompletionHandler(參見前一講)。
4. 實作: 非同步讀取大型文字檔並計算行數
我們來實作一個任務: 以非同步方式讀取大型文字檔,並計算換行符號("\n")的數量。結果 — 在主控台輸出行數。
使用 CompletionHandler 的範例
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncLineCounter {
public static void main(String[] args) throws IOException {
Path path = Path.of("bigfile.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
AtomicLong position = new AtomicLong(0);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
StringBuilder leftover = new StringBuilder();
AtomicLong lines = new AtomicLong(0);
channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
if (result == -1) {
// 檔案已讀到結尾
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("檔案中的行數: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
buffer.flip();
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
String chunk = leftover.toString() + charBuffer.toString();
leftover.setLength(0);
// 計算行數
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// 剩餘部分(最後一個 \n 之後的片段)
if (last < chunk.length()) {
leftover.append(chunk.substring(last));
}
buffer.clear();
position.addAndGet(result);
channel.read(buffer, position.get(), null, this);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("讀取錯誤: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// 為避免程式過早結束(僅作示範!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- 我們使用 CompletionHandler 來撰寫真正的非同步程式碼。
- 每次讀取後,緩衝區都會透過 CharsetDecoder 解碼。
- 未以 "\n" 結尾的行之剩餘部分會帶到下一個區塊。
- 讀到檔案結尾後,若 leftover 仍有內容,也算作一行。
- 為求簡單,範例會「睡」2000 毫秒以等待非同步操作完成(在實際應用中通常不需要 — 通常會有主迴圈或 UI)。
5. 以非同步方式將結果寫入檔案
假設我們想把結果(例如行數)寫入新檔案 — 同樣以非同步方式。
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncWriteResult {
public static void main(String[] args) throws IOException {
String result = "檔案中的行數: 12345\n";
ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
Path path = Path.of("result.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer written, Object attachment) {
System.out.println("結果已以非同步方式寫入!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("寫入錯誤: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. 處理部分資料與編碼的建議
跨區塊的部分行
如果一行被拆在兩個區塊之間,請不要手動去「拼接」位元組! 使用 CharsetDecoder,它會妥善處理缺少的位元組,不遺漏任何字元。
處理不同編碼
"UTF-8" 是現代應用程式的標準,但若檔案使用其他編碼(例如,"Windows-1251" 或 "UTF-16"),請使用對應的 Charset:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
使用 CharsetDecoder 與 CharsetEncoder
當你以分段方式讀寫資料時,正確處理編碼非常重要。字元可能會被拆在兩個區塊之間,若沒有額外處理,就會變成一團亂碼。
為了避免這種情況,請使用 CharsetDecoder 與 CharsetEncoder。
讀取時呼叫 decode(ByteBuffer, CharBuffer, endOfInput),寫入時呼叫 encode(CharBuffer, ByteBuffer, endOfInput)。
它們會確保即使某個字元被分割在兩個區塊之間,也依然能被正確組裝與處理。
7. 非同步處理文字檔的常見錯誤
錯誤 №1: 忽略行的剩餘部分。 如果不在區塊之間保存行尾的「尾巴」,部分行可能會遺失或被錯誤解碼。
錯誤 №2: 不正確的緩衝區操作. 忘了呼叫 buffer.clear() 之後再處理 —— 下一次 read 可能無法運作或資料會不正確。
錯誤 №3: 使用了不相符的編碼. 若以與檔案實際編碼不一致的 Charset 來解碼位元組,可能會出現亂碼,甚至錯誤。
錯誤 №4: 阻塞主執行緒. 如果你在 UI 執行緒呼叫 future.get() 或 Thread.sleep(),就失去了非同步的意義。請使用 CompletionHandler 與反應式作法。
錯誤 №5: 操作完成後未關閉通道. 無論是否發生錯誤,所有操作完成後務必關閉通道(channel.close())。
GO TO FULL VERSION