1. 前言
在現代世界,資料成長的速度飛快。有時你必須面對大小達數十甚至數百 GB 的檔案——可能是日誌、資料庫傾印或巨大壓縮檔。嘗試把這種檔案整個載入記憶體通常結局不妙:程式不是把所有 RAM 都「吃光」,就是開始變得極度緩慢。
原因很明顯。記憶體不是無限的,若檔案超過其容量,你就可能遇到 OutOfMemoryError。即便記憶體足夠,在單一執行緒中順序讀取並處理巨型檔案,也可能耗上數小時。再加上磁碟本身的限制:讀取速度是固定的,但如果使用多個執行緒,尤其在 SSD 上,往往可以顯著加速處理。
因此結論很簡單:大型檔案需要分塊(chunks)處理,並在可能的情況下採用平行處理。這種方法能讓你在面對數以 GB 計的資料時不再痛苦。
2. 解法:Chunking 模式
Chunking 是一種將大型檔案拆分為較小且可管理的區塊(chunks)的模式,這些區塊可以彼此獨立處理。
類比:
與其一次吞下一整顆西瓜,不如把它切成一片片來吃。更簡單也更快!
如何運作?
- 確定檔案大小。
- 使用 File.length() 或 Files.size(Path) 取得檔案的位元組數。
- 計算區塊大小(chunk size)。
- 通常會選擇 10–20 MB(可多或少——取決於任務與硬體)。
- 建議將區塊大小存於變數 chunkSize,並盡量與磁碟區塊大小對齊,以獲得最佳效能。
- 建立任務清單。
- 每個任務處理一個區塊:讀取、剖析、加密、壓縮等。
- 可以使用執行緒池平行啟動這些任務。
視覺化:
+-------------------+
| File |
+-------------------+
| [chunk 1] |
| [chunk 2] |
| [chunk 3] |
| ... |
| [chunk N] |
+-------------------+
3. 平行處理的實作
使用 ExecutorService 或 ForkJoinPool
要平行處理各個區塊,請使用 Java 的標準多執行緒工具:
- ExecutorService —— 固定大小的執行緒池(Executors.newFixedThreadPool(n))。
- ForkJoinPool —— 適用於遞迴任務與「分而治之」的方法。
範例:
ExecutorService pool = Executors.newFixedThreadPool(4); // 4 個執行緒
for (int i = 0; i < chunkCount; i++) {
final int chunkIndex = i;
pool.submit(() -> {
processChunk(file, chunkIndex, chunkSize);
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS);
每個任務都會讀取並獨立處理自己的檔案區塊。
4. 關鍵機制:RandomAccessFile 與 FileChannel
RandomAccessFile
RandomAccessFile 允許在檔案中「移動位置」,從指定位置開始讀取。
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(chunkStart); // 移動到區塊起始位置
byte[] buffer = new byte[chunkSize];
int bytesRead = raf.read(buffer);
// 處理 buffer
}
- seek(long pos) —— 將「游標」移到指定位置。
- 可以只讀取所需的位元組範圍。
FileChannel
FileChannel 是更現代且快速的方法(尤其面對大型檔案)。
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(chunkSize);
channel.position(chunkStart);
int bytesRead = channel.read(buffer);
// 處理 buffer
}
- position(long newPosition) —— 設定讀取位置。
- 可以只讀取需要的區段,而不影響檔案的其他部分。
5. 比較:chunking 與 transferTo/transferFrom
transferTo/transferFrom
FileChannel.transferTo() 與 transferFrom() 支援所謂的 零拷貝(zero-copy)。概念很簡單:資料可在檔案與串流之間直接搬移,繞過 JVM 的緩衝區,讓操作非常快速。唯一限制是資料無法在搬移過程中「即時修改」,只能做複製;但對許多任務而言,這種方式能顯著加速大資料處理。
範例:
try (FileChannel src = FileChannel.open(srcPath, READ);
FileChannel dst = FileChannel.open(dstPath, WRITE)) {
src.transferTo(0, src.size(), dst);
}
Chunking
總之,Chunking 是以區塊方式處理大型檔案的方法。它不僅適用於資料複製,也適用於處理:可以在過程中剖析、加密、壓縮或搜尋。每個區塊都能獨立處理,必要時甚至可以平行化,從而大幅加速。
概念很簡單:如果工作只是單純複製,優先使用 transferTo 或 transferFrom,資料能直接搬移、快速而且避免多餘拷貝。但如果需要對內容進行搜尋、修改、分析——chunking 就是不可或缺的工具。
6. 限制與陷阱
執行緒開銷
- 建立過多執行緒會導致效能下降(內容切換(context switch)、資源爭用)。
- 執行緒數量通常選擇等於或略高於 CPU 核心數。
磁碟限制
- 即便你開了 100 條執行緒,磁碟也不會超過其最大讀取速度。
- 在 SSD 上平行讀取可能帶來提升;在 HDD 上幾乎沒有幫助。
需要同步
- 若各區塊的處理彼此獨立——事情就很簡單。
- 若需要彙整結果(例如計算檔案中所有數字的總和),就必須同步對共享變數的存取(例如使用 AtomicLong,或將結果收集到獨立清單中)。
區塊邊界
- 若是文字檔,要小心不要把一行或一個字元切成兩半。
- 對二進位檔(壓縮檔、圖片)——通常可以任意切分。
- 對文字檔,常見作法是讓區塊之間「重疊」,或尋找最近的換行符。
7. 範例:在大型檔案中平行計算數字總和
任務:
有一個包含數百萬個數字(每行一個)的檔案,需要快速計算它們的總和。
步驟:
- 確定檔案大小。
- 選擇區塊大小(例如 10 MB)。
- 對每個區塊:
- 找到最近的換行符(避免把數字切開)。
- 讀取該區塊、剖析數字並計算總和。
- 彙整所有區塊的總和。
程式碼骨架:
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Long>> results = new ArrayList<>();
for (int i = 0; i < chunkCount; i++) {
final int chunkIndex = i;
results.add(pool.submit(() -> {
// 開啟 RandomAccessFile,尋找區塊邊界
// 讀取、剖析數字並計算總和
long chunkSum = 0L;
return chunkSum;
}));
}
long total = 0;
for (Future<Long> f : results) {
total += f.get();
}
pool.shutdown();
System.out.println("總和: " + total);
8. 總結與最佳實務
- Chunking —— 處理大型檔案的通用模式:分塊、獨立處理、彙整結果。
- 使用 RandomAccessFile 或 FileChannel 從指定位置讀取。
- 若要平行處理——使用 ExecutorService 或 ForkJoinPool。
- 若只需複製——使用 transferTo/transferFrom(zero-copy)。
- 注意區塊大小、執行緒數量以及磁碟限制。
- 對文字檔——務必小心處理行邊界。
- 對二進位檔,若無格式特殊性,一般可任意切分。
9. 使用 chunking 的常見錯誤
錯誤 №1:檔案太大。 嘗試把整個檔案載入記憶體——就會遇到 OutOfMemoryError。
錯誤 №2:執行緒過多。 建立太多執行緒——系統因內容切換(context switch)而開始「卡頓」。
錯誤 №3:把行切斷。 忽略文字檔的行邊界——導致「破掉」的行與剖析錯誤。
錯誤 №4:錯誤使用方法。 嘗試用 transferTo/transferFrom 來處理資料——行不通,這些方法只用於複製。
錯誤 №5:忘了同步。 未同步彙整結果——得到不正確的總和或其他 bug。
錯誤 №6:資源洩漏。 未關閉檔案/通道——造成資源洩漏。
GO TO FULL VERSION