1. 什麼時候多執行緒有幫助
當有許多可同時進行的工作時,就需要多執行緒。比如要處理數十個檔案——複製、重新計算或分析——把不同部分交給不同的執行緒,通常比全都串行完成更簡單。這就像不是讓一個朋友翻你的照片庫,而是一次叫來五個朋友:進度會更快也更有趣。
它在面對批次檔案處理、分段下載或複製大型檔案,或在讀取資料後需要對不同部分並行計算時特別有用。
但多執行緒並非總是有幫助。如果只有一個小檔案,為它啟動十來個執行緒沒有意義。如果磁碟或網路已經滿載,新開的執行緒只會拖慢。若多個執行緒在沒有同步的情況下同時寫入同一檔案——很可能會得到資料損壞的混亂結果。
簡單說,多執行緒是一種工具。就像鐵鎚:能釘釘子,也可能敲到手指。關鍵在於知道何時、如何使用它。
2. Java 的多執行緒 I/O 工具
你可能已經知道,Java 有幾種並行執行任務的方法:
- 經典的 Thread——手動建立執行緒。
- 透過 ExecutorService 的執行緒池——現代、靈活且好用。
- CompletableFuture 與平行串流(Stream API)——面向更進階的任務(稍後的講座將詳述)。
先從最簡單的開始:在不同執行緒中處理多個檔案。
範例 1:經典 Thread
public class FileCopyTask extends Thread {
private final Path source;
private final Path target;
public FileCopyTask(Path source, Path target) {
this.source = source;
this.target = target;
}
@Override
public void run() {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("檔案已複製: " + source);
} catch (IOException e) {
System.err.println("複製錯誤 " + source + ": " + e.getMessage());
}
}
}
// 在獨立執行緒中啟動多個複製任務
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}
優點: 簡單、易懂。
缺點: 手動管理大量執行緒不方便,難以控制同時執行的執行緒數量。
範例 2:ExecutorService —— 執行緒池
ExecutorService 讓你把任務委派給執行緒池,由它決定同時使用多少執行緒。
import java.util.concurrent.*;
public class MultiFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4); // 最多 4 個執行緒
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
System.out.println("已複製: " + file);
} catch (IOException e) {
System.err.println("錯誤: " + file + " " + e.getMessage());
}
});
}
executor.shutdown(); // 不再接受新任務
executor.awaitTermination(1, TimeUnit.MINUTES); // 等待所有任務完成
}
}
優點:
- 容易擴充(可設定所需的執行緒數)。
- 易於控制任務完成(方法 shutdown()、awaitTermination(...))。
- 適合處理上百、上千個檔案。
3. 多執行緒 I/O 的問題與限制
資源競爭
如果嘗試在沒有同步的情況下,從多個執行緒同時讀寫同一檔案——結果將不可預期。就像兩個人同時在書的同一頁上寫字:只會變成一團亂。為了協調,請使用例如 synchronized、顯式鎖,或專門的寫入執行緒。
作業系統與檔案系統的限制
- 不是所有檔案系統都能良好支援對同一檔案的同時寫入。
- 作業系統可能會限制同時開啟的檔案數量。
- 傳統硬碟(特別是 HDD)在大量隨機存取時表現不佳。
寫入共用資源時的同步
如果多個執行緒寫入同一檔案(例如日誌),務必使用同步(例如透過 synchronized、鎖,或專門的寫入執行緒)。
小檔案時的低效率
對於小檔案,建立執行緒與執行緒切換的額外開銷,可能大於平行化帶來的效益。
4. 實用範例
平行複製檔案
假設我們有一個資料夾裡有一堆日誌,需要複製到封存目錄。
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
public class ParallelFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
// ... 可加入任意數量的檔案
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Path target = Path.of("archive", file.getFileName().toString());
Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("已複製: " + file);
} catch (IOException e) {
System.err.println("錯誤: " + file + " " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
}
}
評論:
- 我們使用 4 個執行緒的池——通常足以讓磁碟保持忙碌,但不至於過載系統。
- 對於 1000 個檔案可以把池擴到 8,但不宜過大。
使用 Stream API 對檔案行進行平行處理
自 Java 8 起,可以使用平行串流處理檔案內容:
import java.nio.file.*;
import java.io.IOException;
public class ParallelLineProcessing {
public static void main(String[] args) throws IOException {
Path path = Path.of("biglog.txt");
// Files.lines 會回傳 Stream<String> —— 檔案行的串流
Files.lines(path)
.parallel() // 轉為平行串流
.filter(line -> line.contains("ERROR"))
.forEach(line -> System.out.println("錯誤: " + line));
}
}
重要:
- 當處理本身是計算密集(CPU-bound)而非讀取本身(IO-bound)時,平行串流才會加速。
- 如果行處理很簡單(例如只用 System.out.println 列印),可能不會有明顯提升。
讀寫同一大型檔案的不同區段
Java 允許透過 FileChannel 與定位方法,同時讀寫同一檔案的不同區段。這屬於進階主題,但原理很簡單:每個執行緒處理自己的檔案區塊。
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;
public class FileChunkReader implements Runnable {
private final Path path;
private final long position;
private final long size;
public FileChunkReader(Path path, long position, long 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((int) size);
channel.read(buffer, position);
System.out.println("已讀取從位置 " + position + " 開始、大小為 " + size + " 的區塊");
// 這裡可以處理 buffer
} catch (IOException e) {
System.err.println("讀取區塊時發生錯誤: " + e.getMessage());
}
}
}
// 範例:以 4 個執行緒每次讀取 1 MB
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 MB
int chunks = (int) Math.ceil((double) fileSize / chunkSize);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < chunks; i++) {
long position = i * chunkSize;
long size = Math.min(chunkSize, fileSize - position);
executor.submit(new FileChunkReader(file, position, size));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
評論:
- 每個執行緒讀取自己的檔案區塊,互不干擾。
- 這種方法在 Torrent 與下載器中很常見。
寫入共用檔案時的同步
如果多個執行緒寫入同一檔案(例如日誌),需要同步存取,避免得到「大雜燴」的行內容:
import java.io.*;
public class SafeLogger {
private final Writer writer;
public SafeLogger(String filename) throws IOException {
this.writer = new BufferedWriter(new FileWriter(filename, true));
}
public synchronized void log(String message) throws IOException {
writer.write(message);
writer.write(System.lineSeparator());
writer.flush();
}
public void close() throws IOException {
writer.close();
}
}
評論:
- log 方法標註為 synchronized,以確保同一時間只有一個執行緒寫入檔案。
- 這樣可行,但在執行緒很多時可能成為瓶頸——更好的做法是寫入不同檔案後再合併。
5. 什麼時候不該使用多執行緒
多執行緒很誘人:執行緒越多,應該就越快吧!但實務上並非總是如此。如果只處理幾個小檔案,串行處理更簡單也更可靠。你花在啟動執行緒與彼此協調的時間,往往不划算。
有時問題根本不在磁碟,而在網路——這時增加執行緒無法加速,因為瓶頸在別處。另一個陷阱是對同一檔案的並行寫入。如果你對同步經驗不多,最好別嘗試:很容易導致資料毀損。
最後,如果你的磁碟或檔案系統不喜歡被數十個執行緒同時頻繁存取——多執行緒不但救不了場,還會讓情況更糟。
簡單說,如果你覺得「執行緒越多越好」——多半不是這樣。有時候,一個穩健的執行緒比十個匆忙的執行緒更乾淨、快速又可靠。
6. FileChannel 的進階任務速覽
FileChannel(位於 java.nio.channels)是用於低階檔案操作的工具,允許從任意位置讀寫資料。這能實作例如平行下載或分塊處理大型檔案。
範例:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
int bytesRead = channel.read(buffer, position); // 從位置 0 讀取 1024 位元組
// 處理 buffer
}
重要:
- FileChannel 並未同步——若多個執行緒共用同一個 channel,需要自行實作同步。
- 要進行平行處理,為每個執行緒分別開啟自己的 channel 會更簡單。
7. 多執行緒 I/O 的常見錯誤
錯誤 №1:未同步的寫入同一檔案。
結果是資料損壞、怪異符號,有時整個檔案無法讀取。務必同步存取,或寫入不同檔案。
錯誤 №2:執行緒過多。
如果為了複製 1000 個檔案而開了 1000 個執行緒,你的電腦可能會「生氣」(OutOfMemoryError、卡頓、崩潰)。請使用執行緒池(ExecutorService)並限制數量。
錯誤 №3:未關閉執行緒/檔案。
每個開啟的執行緒或檔案都是作業系統資源。如果不關閉,可能會收到「Too many open files」錯誤。請使用 try-with-resources,或記得呼叫 close()。
錯誤 №4:過早結束程式。
若未等待所有執行緒完成(例如沒有呼叫 executor.awaitTermination(...)),程式可能在所有檔案複製完成之前就結束。
錯誤 №5:未考慮位置的並行寫入同一檔案區段。
如果多個執行緒寫入同一檔案區域——資料會互相覆蓋或混淆。對於按位置寫入,請使用 channel 並明確劃分區間。
GO TO FULL VERSION