1. 為什麼用單執行緒讀大型檔案——就像一塊塊搬磚
當你處理大型檔案——數十或數百 MB,甚至 GB——以單執行緒讀寫很快就會成為瓶頸。單一執行緒無法承載負載:磁碟輸送資料的速度可能比程式處理的速度還快。
即便你用的是高速 SSD,瓶頸往往不在磁碟,而在各種額外開銷——上下文切換、緩衝區處理、記憶體中的資料轉換。結果就是效能下滑,而 CPU 其他核心在旁發呆,因為沒有被善加利用。
假設你想計算一個巨大的日誌中的單字數量。若採用序列處理,單一執行緒會慢吞吞地啃完整個檔案,而你只能乾等。把檔案切成多個區塊並交由多個執行緒處理,速度就會快上許多:每個執行緒處理自己的部分,最終幾乎能把磁碟的潛力榨乾。
在實務中往往是這樣:在吞吐量為 2 GB/s 的 SSD 上,單執行緒讀取僅約 300–500 MB/s。若改用平行讀取,就能把裝置的效能盡量發揮出來。
2. Chunking——讓檔案替你工作
當檔案大到無法一次處理時,最合理的做法就是 把它分成多個部分。這個技巧稱作 chunking(源自 chunk,意為「塊」)。概念很簡單:把大檔案切成數個邏輯區段,並把各段分派給不同的執行緒。
每個執行緒都知道要從哪個位移(offset)開始、在哪裡結束。它只讀自己的區塊、處理資料,然後把結果彙整成總結。
這種作法可以同時用滿所有 CPU 核心,顯著加速處理,尤其在使用現代 SSD 或 NVMe 磁碟時。對於像是計算行數、文字搜尋或統計彙整這類任務,chunking 的效果就像渦輪增壓——幾乎不用多做什麼就能提升速度。
如何選擇區塊大小
區塊大小幾乎就像食物分量:太小會切到手軟,太大又難以下嚥。實際數值取決於你的任務與機器能力。
一般來說,每個執行緒選在 8–64 MB 的範圍效果不錯。多數情況下取約 10–20 MB 就夠了;沒有絕對的黃金數字——需要透過實驗微調。重點是:區塊要夠大,避免花太多時間在多餘的執行緒切換上;也不要大到把 CPU 快取塞爆或佔滿記憶體。
若處理的是文字——例如計算單字或尋找比對——務必要避免在行或單字中途把區塊切斷。常見解法很簡單:讓相鄰區塊之間有些許重疊,或把邊界調整到最近的換行符號。如此一來處理才精準,結果也更乾淨可預期。
3. 定位存取的工具:FileChannel 與 MappedByteBuffer
FileChannel: Positioned IO
FileChannel 是 java.nio.channels 套件中的類別,可讓你在較低層級操作檔案,包括從/寫入檔案的任意位置。
關鍵方法:
- position(long newPosition) — 設定讀寫位置(offset)。
- read(ByteBuffer dst, long position) — 從指定位置讀取資料到緩衝區(不會改變通道的目前位置!)。
- write(ByteBuffer src, long position) — 從指定位置將資料寫入檔案。
範例:讀取檔案的一段
try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
long chunkSize = 16 * 1024 * 1024; // 16 MB
long offset = 0;
ByteBuffer buffer = ByteBuffer.allocate((int) chunkSize);
int bytesRead = channel.read(buffer, offset);
// buffer 包含檔案的前 16 MB
}
優點:
- 可從任意位置讀寫。
- 適合平行處理:每個執行緒各自處理自己的區塊。
MappedByteBuffer: Memory-mapped files
MappedByteBuffer 是一種特殊的緩衝區,可將檔案的一部分「對映」(map)到記憶體。作業系統會自動處理把資料從磁碟載入到記憶體以及回寫。
運作方式:
- 你把檔案的一段對映到記憶體。
- 讀寫該緩衝區——作業系統會自動載入所需的頁面。
- 不需要顯式呼叫 read/write——一切透過記憶體完成。
範例:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
long chunkSize = 16 * 1024 * 1024; // 16 MB
long offset = 0;
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, chunkSize);
// 現在 buffer 的行為就像位元組陣列,但資料會在訪問時按需自磁碟載入
}
優點:
- 速度非常快(尤其在 SSD 上)。
- 簡單:像操作陣列一樣讀寫。
缺點:
- 使用虛擬記憶體——若檔案很大,可能「吃」掉大量記憶體。
- 記憶體釋放較難掌控(緩衝區可能比你需要的時間更久地留在記憶體)。
- 對超大型檔案不一定方便(在 32 位元系統上超過 2–4 GB 時)。
4. 範例:平行讀取並計算單字數
看個案例:透過平行處理,計算大型文字檔(例如大小 10 GB 的日誌)中的單字數量。
步驟 1:將檔案分成多個區塊
- 取得檔案大小:long fileSize = Files.size(path);
- 選擇區塊大小,例如 16 MB。
- 為每個區塊計算位移:offset = chunkIndex * chunkSize;
- 最後一個區塊的大小可能較小。
步驟 2:為執行緒建立任務
- 對每個區塊建立 Callable<Integer>(或 Runnable),其會:
- 透過 FileChannel.read(ByteBuffer, offset) 或 MappedByteBuffer 開啟對應的檔案區段;
- 計算該區塊中的單字數;
- 回傳結果(單字數)。
步驟 3:透過 ExecutorService 執行任務
- 建立執行緒池:ExecutorService pool = Executors.newFixedThreadPool(N);
- 將任務提交至執行緒池:List<Future<Integer>> results = pool.invokeAll(tasks);
- 彙整結果:將所有 Future 的值加總。
程式碼範例(簡化版):
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
public class ParallelWordCount {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
long fileSize = Files.size(path);
int chunkSize = 16 * 1024 * 1024; // 16 MB
int chunks = (int) ((fileSize + chunkSize - 1) / chunkSize);
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < chunks; i++) {
long offset = (long) i * chunkSize;
long size = Math.min(chunkSize, fileSize - offset);
results.add(pool.submit(() -> {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, size);
byte[] bytes = new byte[(int) size];
buffer.get(bytes);
String text = new String(bytes);
// 重要:要處理區塊邊界,避免把單字切斷!
return countWords(text);
}
}));
}
int totalWords = 0;
for (Future<Integer> f : results) {
totalWords += f.get();
}
pool.shutdown();
System.out.println("Total words: " + totalWords);
}
private static int countWords(String text) {
// 最簡單的做法:以空白切分,並過濾空字串
String[] words = text.split("\\s+");
int count = 0;
for (String w : words) {
if (!w.isBlank()) count++;
}
return count;
}
}
注意:在實務中要仔細處理區塊邊界,避免把單字或行切成兩半。通常會做一點點 overlap(例如 +100 位元組),並修正區塊的起訖位置。
5. 總結與最佳實務
- 面對大型檔案,請使用分塊與平行處理。
- 使用 FileChannel 進行定位存取,MappedByteBuffer 用於 memory-mapped 檔案。
- 區塊大小以實驗調整;參考 CPU 快取與磁碟吞吐量。
- 小心處理區塊邊界(尤其是文字檔)。
- 平行處理請使用 ExecutorService 與執行緒池。
- 不要濫用執行緒數量:通常對 SSD 而言,2–4 條執行緒就足夠。
- 留意記憶體使用量:MappedByteBuffer 可能佔用大量虛擬記憶體。
6. 處理大型檔案與 chunking 時的常見錯誤
錯誤 №1:一次把整個檔案讀進記憶體。 在處理大型檔案時,這可能導致 OutOfMemoryError。請改為分塊逐步讀取。
錯誤 №2:錯誤處理區塊邊界。 如果切分檔案時未考量行或單字的邊界,資料可能被「切斷」,結果會不正確。
錯誤 №3:區塊大小不佳。 區塊太小會帶來多餘的執行緒管理開銷,而太大則會低效使用記憶體。
錯誤 №4:未關閉 FileChannel。 這會導致資源洩漏。請使用 try-with-resources 以確保通道被正確關閉。
錯誤 №5:過多的執行緒。 若執行緒太多,磁碟無法及時處理請求,效能可能不升反降。
GO TO FULL VERSION