CodeGym /課程 /JAVA 25 SELF /檔案的平行處理:ForkJoin、Parallel Streams

檔案的平行處理:ForkJoin、Parallel Streams

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

1. 平行串流 (parallelStream):簡單又方便

如果你已經用過 Stream API,就會知道過濾、轉換與彙整集合是多麼方便。更棒的是:只要一行就能讓任何串流變成平行——呼叫 parallel() 後,集合元素就會被同時處理。

這對於一組彼此獨立的檔案操作特別有用:計算行數、尋找子字串、複製、壓縮等等。

範例:對目錄中所有檔案進行平行行數統計

方案 1:逐一處理

import java.nio.file.*;
import java.io.IOException;
import java.util.List;

public class LogLineCounter {
    public static void main(String[] args) throws IOException {
        Path logDir = Paths.get("logs");
        long totalLines = 0;
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(logDir, "*.log")) {
            for (Path file : stream) {
                long lines = Files.lines(file).count();
                totalLines += lines;
            }
        }
        System.out.println("所有日誌的總行數: " + totalLines);
    }
}

註解:一切按順序進行,一個檔案接著一個。如果檔案很多又很大,等待時間會很長。

方案 2:平行!

import java.nio.file.*;
import java.io.IOException;
import java.util.stream.Stream;

public class LogLineCounterParallel {
    public static void main(String[] args) throws IOException {
        Path logDir = Paths.get("logs");
        try (Stream<Path> files = Files.list(logDir)) {
            long totalLines = files
                .filter(path -> path.toString().endsWith(".log"))
                .parallel() // 魔法就在這一行!
                .mapToLong(file -> {
                    try (Stream<String> lines = Files.lines(file)) {
                        return lines.count();
                    } catch (IOException e) {
                        e.printStackTrace();
                        return 0L;
                    }
                })
                .sum();
            System.out.println("所有日誌的總行數: " + totalLines);
        }
    }
}

註解:關鍵就在 .parallel()。在多核心處理器上,程式通常會明顯更快。

它如何運作?

  • parallel() 會把一般串流轉成平行串流。底層使用共用的 ForkJoinPool(預設平行度約等於核心數)。
  • 每個檔案獨立處理,並透過終端操作(例如 sum())匯總結果。
  • 若檔案很少,可能感受不到加速;若有上百個,通常就會有明顯收益。

重要!

  • 平行串流不會讓 I/O 本身變快;它只是讓多個操作同時進行。在快速的儲存裝置(SSD)上通常有幫助,但在慢速裝置(HDD)上可能會撞上磁碟的「瓶頸」。

2. ForkJoinPool:在實務中「分而治之」

ForkJoin 是一個用於「分而治之」的平行運算框架:把大任務拆成子任務,並行執行後再合併結果。這一切由專用的執行緒池——ForkJoinPool——來管理。平行串流在幕後正是使用它;你也可以直接操控它以獲得更高的彈性。

這種模式特別適合遞迴結構(例如目錄樹)、大型資料陣列,以及容易分解為獨立部分的任務。

範例:在目錄樹中進行遞迴搜尋

找出所有 ".txt" 檔案(包含巢狀資料夾),並計算總行數。

import java.nio.file.*;
import java.util.concurrent.*;
import java.util.*;
import java.io.IOException;
import java.util.stream.Collectors;

public class FolderLineCounter extends RecursiveTask<Long> {
    private final Path dir;

    public FolderLineCounter(Path dir) {
        this.dir = dir;
    }

    @Override
    protected Long compute() {
        List<FolderLineCounter> subTasks = new ArrayList<>();
        long lines = 0;
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
            for (Path entry : stream) {
                if (Files.isDirectory(entry)) {
                    FolderLineCounter task = new FolderLineCounter(entry);
                    task.fork(); // 啟動子任務
                    subTasks.add(task);
                } else if (entry.toString().endsWith(".txt")) {
                    try (Stream<String> fileLines = Files.lines(entry)) {
                        lines += fileLines.count();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 收集合併子任務的結果
        for (FolderLineCounter task : subTasks) {
            lines += task.join();
        }
        return lines;
    }

    public static void main(String[] args) {
        Path root = Paths.get("big_folder");
        ForkJoinPool pool = new ForkJoinPool();
        FolderLineCounter counter = new FolderLineCounter(root);
        long totalLines = pool.invoke(counter);
        System.out.println("所有 .txt 檔案的總行數: " + totalLines);
    }
}

這裡發生了什麼:

  • 每個資料夾會建立一個獨立的任務(FolderLineCounter),對於子目錄則建立各自的子任務(fork())。
  • 檔案行數在當前節點即地計算,並在所有子任務 join() 之後匯總。

ForkJoin 的優勢是什麼?

  • 對大型階層結構(目錄樹)特別有效。
  • 能盡量讓 CPU 核心保持忙碌。
  • 能精確控制平行化策略與任務切分邊界。

3. 實務應用情境

大量檔案處理

例如,需要把上千張照片複製到備份資料夾。

import java.nio.file.*;
import java.util.List;
import java.util.stream.Collectors;

public class ParallelFileCopier {
    public static void main(String[] args) throws Exception {
        Path sourceDir = Paths.get("photos");
        Path destDir = Paths.get("photos_backup");
        Files.createDirectories(destDir);

        List<Path> files = Files.list(sourceDir)
                .filter(Files::isRegularFile)
                .collect(Collectors.toList());

        files.parallelStream().forEach(file -> {
            try {
                Path destFile = destDir.resolve(file.getFileName());
                Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        System.out.println("所有檔案都已複製!");
    }
}

註解:每個檔案在獨立的執行緒中複製。當檔案很多時,加速效果會很明顯。

平行壓縮/解壓縮

同樣地,你可以透過 parallelStream() 或自訂的 ForkJoinPool 來平行化壓縮、雜湊運算、影像格式轉換等工作。

4. 重要注意事項與限制

  • I/O 操作不一定會因平行化而加速。 如果磁碟或網路是瓶頸,開上一百個平行任務只會增加資源競爭。
  • 不要啟動過多的執行緒。 預設情況下,平行串流會使用共用的 ForkJoinPool.commonPool(),其平行度 ≈ 核心數。你可以透過屬性 "java.util.concurrent.ForkJoinPool.common.parallelism" 進行調整,但請審慎評估。
  • 別忘了同步。 如果多個執行緒寫入同一個檔案/物件——請使用同步與佇列;對於彼此獨立的檔案則不需要同步。

5. 快速認識 FileChannel 與定位式存取

對於進階情境(例如平行讀取同一個大型檔案的不同區段)可以使用 java.nio.channels.FileChannel,它支援定位式讀寫。

範例:在不同執行緒中讀取檔案的不同區段

import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.nio.ByteBuffer;

public class FileChunkReader implements Runnable {
    private final Path path;
    private final long position;
    private final int size;

    public FileChunkReader(Path path, long position, int size) {
        this.path = path;
        this.position = position;
        this.size = size;
    }

    @Override
    public void run() {
        try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
            ByteBuffer buffer = ByteBuffer.allocate(size);
            channel.read(buffer, position);
            // 處理資料
            System.out.println("已讀取 " + buffer.position() + " 位元組,自位置 " + position);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

註解:可同時啟動多個這樣的任務——每個任務讀取各自的範圍。不過要小心:並非所有磁碟與檔案系統都適合承受高強度的平行存取。

6. 檔案平行處理的常見錯誤

錯誤 №1:未同步就對同一個檔案進行平行寫入。 資料會交錯而被破壞。請使用佇列、緩衝與寫入同步。

錯誤 №2:過度平行化. 平行串流在硬體較弱/檔案很小的情況下會產生額外成本,可能反而變慢。

錯誤 №3:忽略 I/O 錯誤. 在平行串流中,例外很容易被「吃掉」——請在 lambda 內處理、記錄日誌,並將失敗納入考量。

錯誤 №4:資源未關閉. 對於串流/通道務必使用 try-with-resources,否則會導致洩漏與各種怪錯。

錯誤 №5:把 parallel() 當成「魔法」。 平行化只有在工作量足夠且資源可用(CPU、快速磁碟)時才會加速;僅僅呼叫 parallel() 並不是萬靈丹。

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