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() 並不是萬靈丹。
GO TO FULL VERSION