CodeGym /課程 /JAVA 25 SELF /將大型檔案分割成區塊

將大型檔案分割成區塊

JAVA 25 SELF
等級 59 , 課堂 3
開放

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

FileChanneljava.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:過多的執行緒。 若執行緒太多,磁碟無法及時處理請求,效能可能不升反降。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION