概要

Java でマルチスレッド処理を書く場面は、バッチの並列実行や非同期ログ出力など、業務システムでも少なくありません。Thread クラスを継承する方法、Runnable を実装する方法、ラムダ式で書く方法の3パターンがあり、どれを選ぶかで保守性やテストしやすさが変わります。この記事では、スレッドの生成から start/join による制御、interrupt による安全な中断まで、実務で必要な基本操作を一通り整理します。Java 21 で導入された仮想スレッド(Virtual Threads)との違いにも触れ、今後のコード選択の判断材料を提供します。

使いどころ

バッチ処理で複数ファイルの取り込みを並列化し、全スレッドの完了を join で待ち合わせる

ログ出力や通知送信を別スレッドに委譲し、メイン処理のレスポンスタイムを短縮する

タイムアウト付きの外部API呼び出しを別スレッドで実行し、interrupt で中断可能にする

コード例

ThreadBasicDemo.java
public class ThreadBasicDemo {

    // Runnable をラムダ式で記述(推奨パターン)
    public static void main(String[] args) throws InterruptedException {

        // パターン1: ラムダ式で Runnable を直接書く
        var t1 = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("[" + Thread.currentThread().getName() + "] count: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); // フラグを再セット
                    System.out.println(Thread.currentThread().getName() + ": 中断");
                    return;
                }
            }
        }, "worker-1");

        var t2 = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("[" + Thread.currentThread().getName() + "] count: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        }, "worker-2");

        t1.start(); // 新スレッドで実行開始
        t2.start();

        t1.join();  // t1 の完了を待つ
        t2.join();  // t2 の完了を待つ

        System.out.println("全スレッド完了");

        // スレッド情報の確認
        var current = Thread.currentThread();
        System.out.println("メインスレッド名: " + current.getName());
        System.out.println("デーモン: " + current.isDaemon());
        System.out.println("優先度: " + current.getPriority());
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

var による型推論と record で Runnable を実装するパターンが加わり、コードが簡潔になる。

Java 17
// Java 17: ラムダ式 + var で簡潔に
var t = new Thread(() -> {
    System.out.println("Hello from " +
        Thread.currentThread().getName());
}, "worker-1");
t.start();
t.join();

Library Comparison

標準 API(Thread / Runnable)スレッドの基本制御を直接扱い、動作を細かく把握したいとき。学習用途や小規模な並列処理に向く。スレッドプール管理やタスク結果の取得は自前で書く必要がある。大量タスクの管理は ExecutorService に委ねるほうが安全。
ExecutorServiceスレッドの生成・破棄をフレームワークに任せ、タスクの投入と結果取得に集中したいとき。Thread を直接使うより抽象度が高い分、スレッドの細かい制御(優先度・デーモン設定等)はやりにくい。
Spring @AsyncSpring 環境でメソッド単位の非同期化をアノテーションだけで実現したいとき。フレームワーク依存が生まれる。スレッドプールの設定を意識しないと、デフォルトのプールサイズで詰まる場合がある。

注意点

start() ではなく run() を直接呼ぶとメインスレッドで同期実行される。新規スレッドは生成されないため、並行処理にならない

InterruptedException を catch したら Thread.currentThread().interrupt() で割り込みフラグを再セットするのが原則。握りつぶすとキャンセル伝播が途切れる

Thread を継承するとそのクラスは他のクラスを継承できなくなる。Runnable 実装やラムダ式のほうが拡張性が高い

スレッド名を設定しておかないと、ログやスレッドダンプでの特定が困難になる。new Thread(task, "worker-1") のように明示する

join() にタイムアウトを設定しないと、相手スレッドが終了しない限りメインスレッドが永久にブロックされる

FAQ

Thread 継承と Runnable 実装のどちらを使うべきですか。

Runnable 実装(またはラムダ式)を推奨します。Java は単一継承のため、Thread を継承すると他のクラスを継承できなくなります。テストも Runnable のほうが書きやすいです。

仮想スレッドは従来のスレッドを完全に置き換えますか。

I/O バウンドな処理には適していますが、CPU バウンドな処理では従来のプラットフォームスレッドが有利です。用途に応じて使い分ける必要があります。

スレッドの run() と start() の違いは何ですか。

start() は新しいスレッドを作成してその中で run() を呼びます。run() を直接呼ぶと呼び出し元のスレッドで同期実行されるため、並行処理になりません。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます