CodeGym /課程 /JAVA 25 SELF /多執行緒讀寫檔案

多執行緒讀寫檔案

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

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 並明確劃分區間。

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