1. マルチスレッドが役立つとき
同時に処理できる作業がたくさんあるときにマルチスレッドは必要になります。たとえば数十個のファイルを処理する(コピーする、再計算する、分析する)なら、すべてを逐次的に行うより、異なる部分を異なるスレッドに任せたほうが簡単です。写真アーカイブを一人で仕分ける代わりに、友人を五人呼ぶようなものです。作業はもっと速く、そして楽しく進みます。
特に、ファイルのバッチ処理、ファイルの分割ダウンロードやコピー、あるいはデータを読み込んだ後に異なる部分ごとに並列で何か計算する必要がある場合に有効です。
しかし、マルチスレッドがいつも役に立つわけではありません。小さなファイルが 1 つだけなら、そのために 10 個のスレッドを立ち上げるのは無意味です。ディスクやネットワークがすでに限界まで使われているなら、新しいスレッドは処理を遅くするだけです。さらに、複数のスレッドが同期なしで同じファイルに同時に書き込むと、本当にデータ破損のカオスが起きます。
要するに、マルチスレッドは道具です。金槌のようなものです。釘を打つこともできれば、指を打ってしまうこともあります。大事なのは、いつ、どのように使うかを知ることです。
2. マルチスレッド IO のための Java のツール
ご存じのとおり、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. マルチスレッド IO の問題と制約
リソース競合
同期なしで複数のスレッドから同じファイルを同時に読み書きしようとすると、結果は予測不能です。2 人が同じ本のページに同時に書き込むようなもので、ぐちゃぐちゃになります。調整には synchronized、明示的なロック、もしくは専用の書き込みスレッドなどを使いましょう。
ファイルシステムと OS の制約
- すべてのファイルシステムが 1 つのファイルへの同時書き込みを得意としているわけではありません。
- OS は同時に開けるファイル数を制限している場合があります。
- ハードディスク(特に HDD)はランダムアクセスが多いと性能が落ちます。
共有リソースへの書き込み時の同期
複数のスレッドが 1 つのファイル(たとえばログ)に書き込む場合は、必ず同期を取りましょう(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));
}
}
重要:
- 並列ストリームは、読み取り自体(IO-bound)ではなく、計算量の多い処理(CPU-bound)を行う場合に高速化が期待できます。
- 処理が単純(たとえば単に System.out.println で出力するだけ)なら、速度向上は見込めないかもしれません。
1 つの大きなファイルの異なる部分の読み書き
Java では、FileChannel と位置指定メソッドを使って、1 つのファイルの異なる領域を同時に読み書きできます。これは上級向けですが、原理は簡単です。各スレッドがファイルの自分のチャンクを担当します。
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);
コメント:
- 各スレッドは自分のチャンクを読み、他と干渉しません。
- このアプローチは、トレントやダウンローダーなどで使われています。
共通ファイルへの書き込み時の同期
複数のスレッドが同じファイル(たとえばログ)に書き込む場合、行の「ごちゃ混ぜ」を避けるためにアクセスを同期する必要があります。
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 指定されており、同時に 1 つのスレッドだけがファイルに書き込めます。
- これは有効ですが、スレッド数が多いとボトルネックになり得ます。別々のファイルに書き出し、後で結合するほうがよい場合があります。
5. マルチスレッドを使うべきでないとき
マルチスレッドは魅力的です。「スレッドが多いほど速いはず!」と思いがちですが、実際にはそうとは限りません。小さなファイルを数個だけ処理するなら、逐次処理のほうが簡単で確実です。スレッドの起動や調整にかける時間が、結局見合わないことが多いのです。
問題がディスクではなくネットワークにある場合もあります。そうなるとスレッドを増やしても何も速くなりません。ボトルネックが別の場所にあるからです。もう 1 つの落とし穴は、同じファイルへの並行書き込みです。同期に不慣れなら、手を出さないほうが無難です。データ破損のリスクが高いからです。
そして最後に、ディスクやファイルシステムが多数のスレッドからのアクセスを嫌うなら、マルチスレッドは状況を改善するどころか悪化させます。
「スレッドは多いほど良い」と思えるときほど、そうではないことが多いのです。時には、落ち着いた 1 本のスレッドのほうが、せっかちな 10 本よりも、きれいに、速く、信頼性高く仕事をこなします。
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 はスレッドセーフではありません。同じチャネルを複数スレッドで扱う場合、同期は自分で実装する必要があります。
- 並列処理を行うなら、スレッドごとに個別のチャネルを開くほうが簡単です。
7. マルチスレッド IO でありがちなミス
エラー №1: 同一ファイルへの書き込みを無同期で行う。
結果としてデータ破損や文字化けが発生し、ファイルが読めなくなることもあります。常にアクセスを同期するか、別々のファイルに書き出しましょう。
エラー №2: スレッドが多すぎる。
1000 個のファイルをコピーするのに 1000 本のスレッドを開くと、マシンが不機嫌になるかもしれません(OutOfMemoryError、極端な遅延、クラッシュ)。スレッドプール(ExecutorService)を使い、その数を制限しましょう。
エラー №3: ストリーム/ファイルを閉じない。
開いたストリームは OS のリソースです。閉じないと「Too many open files」エラーになります。try-with-resources を使うか、close() を忘れずに呼び出しましょう。
エラー №4: プログラムを早く終了させすぎる。
すべてのスレッドの終了を待たない(たとえば executor.awaitTermination(...) を呼ばない)と、ファイルのコピーが終わる前にプログラムが終了してしまうことがあります。
エラー №5: 位置を考慮せずに同じ領域へ並行書き込みを行う。
複数のスレッドが同じファイル領域に書くと、データが混ざってしまいます。位置指定での書き込みにはチャネルを使い、レンジを明確に分割してください。
GO TO FULL VERSION