本ページはプロモーションが含まれています

Java入門

スレッドセーフとは?Javaの実装方法5選と選び方【落とし穴つき】

CATEGORY
REV.
DRAWN BY
TOM
SHEETS
8枚
Thread Safe(実装方法5選と落とし穴)

トム

SESで約8年、環境を変えられないもどかしさの中にいました。32歳で自社開発企業に転職し、今はClaude Codeと自動化で、仕事も時間も組み直している途中です。
・Javaバックエンドエンジニア(Java歴12年以上)
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎
詳細なプロフィール

𝕏

スレッドセーフ(スレッド安全性)とは、複数のスレッドから同時にアクセスされても、データの不整合や予期せぬエラーを起こさずに正しく動作することです。

Javaでマルチスレッドのコードを書くとき、これを軽視すると、データの破壊・デッドロック・再現困難なバグの温床になります。

結論から言うと、Javaではsynchronized・volatile・AtomicInteger・ConcurrentHashMap・不変オブジェクトの5つで対策でき、「共有する可変データがあるか」「更新操作は単純か」「読み取りと書き込みのどちらが多いか」で選び分けます。

この記事では5つの実装方法をコード例つきで比較し、どれを選べばよいかはフロー図で整理します。

Java歴12年の開発経験をもとに、現場でよく見る落とし穴と確認方法もあわせて紹介します。

押さえるべきポイントは次の5つです。

  • スレッドセーフとは何か(定義・問題事例)
  • Javaでスレッドセーフを実現する主な実装方法(synchronized・volatile・AtomicInteger・ConcurrentHashMap)
  • シングルトンパターンのスレッドセーフな実装方法
  • 実践で踏みやすい落とし穴と対策
  • スレッドセーフの確認・チェック方法

PARTS LIST /目次

スレッドセーフとは何か?

まず、「スレッドセーフ」とは何かを理解するために、Javaにおけるスレッドとその重要性から見ていきましょう。

Javaのスレッド(Thread)とマルチスレッドの基本

プログラムの実行単位を「プロセス」と呼びます。一つのプロセスの中で、さらに枝分かれして同時に実行される処理の流れが「スレッド」です。

一つのプログラムで複数のスレッドを動かすことを「マルチスレッド」と呼びます。マルチスレッドを利用すると、例えば以下のようなメリットがあります。

メリット

  • 応答性の向上: 時間のかかる処理を別のスレッドに任せることで、ユーザーインターフェースが固まらず、操作を受け付け続けられる。
  • 処理能力の向上: マルチコアCPUの場合、複数のスレッドを別々のコアで同時に実行することで、全体の処理時間を短縮できる。
  • リソースの有効活用: ネットワークからの応答待ちなど、処理が一時的に停止する時間に、他のスレッドを実行することで、CPUを有効に活用できる。

マルチスレッドはJavaアプリケーションのパフォーマンスや使いやすさを向上させるために欠かせない技術です。

スレッドセーフの定義

スレッドセーフとは、複数のスレッドから同時にアクセスされても、データの不整合や予期せぬエラーが発生せず正しく動作することを指します。

特別な同期制御なしにインスタンスを安全に共有できる状態が「スレッドセーフ」です。

スレッドセーフとメモリ領域(ヒープ・スタック)の関係

この住み分けは図で見ると一発です。図で動かすJVMメモリ|スタックとヒープ・参照・GCの関係では、メソッドを呼ぶたびにスタックへ作業台が積まれ、newした箱はヒープに置かれる様子が動きます。スタックはスレッドごとに別々、ヒープは全スレッドで共有——ローカル変数が安全でフィールドが危ないのは、この一枚がそのまま理由になっています。

「なぜ複数スレッドでデータの問題が起きるのか」を理解するには、Javaのメモリ領域を知ることが重要です。

Javaには主に2つのメモリ領域があります。

メモリ領域格納されるものスレッド間の共有スレッドセーフ性
スタック領域ローカル変数、メソッド呼び出し情報各スレッドで独立安全(共有されない)
ヒープ領域インスタンス変数、クラス変数(static)全スレッドで共有危険(同時アクセス可能)

ローカル変数はスタック領域(スレッド固有)に置かれるためスレッドセーフです。

一方、インスタンス変数やクラス変数はヒープ領域に置かれ全スレッドから参照できるため、適切な制御がないとデータ破壊につながります。

public class Counter {
    private int count = 0; // ヒープ領域(インスタンス変数)→全スレッドで共有→危険

    public void increment() {
        int tmp = count; // スタック領域(ローカル変数)→スレッド固有→安全
        tmp++;
        count = tmp;     // ここで複数スレッドが同時に書き込むとデータが壊れる
    }
}
スレッドセーフでないと起きる更新の消失
順番
スレッドA
スレッドB
count
1
count を読む(0)
―
0
2
―
count を読む(0)
0
3
1 を書き込む
―
1
4
―
1 を書き込む
✕ 1
NOTEcount++ を2回実行したのに結果は1(期待値は2)。「読む→足す→書く」の途中に割り込まれると更新が失われます。

スレッドセーフでないと何が起きる?

もしプログラムがスレッドセーフでない場合、複数のスレッドが同時にデータにアクセスすると問題が発生します。これを「競合状態(Race Condition)」と呼びます。

代表的な問題は以下のとおりです。

データの不整合

あるスレッドがデータを更新している途中で、別のスレッドがそのデータを読み込んだり更新したりすることがあります。

すると、データが中途半端な状態になったり、意図しない値になったりします。

例: 銀行口座の残高を更新する処理で、あるスレッドが入金処理をしている最中に、別のスレッドが出金処理をすると、最終的な残高が合わなくなる。

予期せぬ例外

データが不正な状態になることで、NullPointerExceptionやArrayIndexOutOfBoundsExceptionなどの予期せぬ例外が発生します。

デッドロック

複数のスレッドが互いに相手のリソース(データやロック)を待ってしまい、処理が全く進まなくなる状態です。

こうなると、アプリケーションが停止してしまうこともあります。

ライブロックとスターベーション

デッドロック以外にも、マルチスレッド特有の問題があります。

  • ライブロック(Livelock): 2つのスレッドが互いに相手に「どうぞ」と譲り合いを繰り返し、どちらも実際の処理が進まない状態。デッドロックと異なり、スレッドは動き続けているが何も進まない。
  • スターベーション(Starvation): 優先度の低いスレッドが、優先度の高いスレッドに常にCPU時間を奪われ、永遠に実行機会を得られない状態。

Javaでスレッドセーフを実現する5つの方法

スレッドセーフを実現するには主に5つのアプローチがあります。用途に応じた使い分けがパフォーマンスの鍵です。

手法スレッドセーフアトミック性パフォーマンス推奨ユースケース
synchronized○○低(待機発生)シンプルな排他制御
volatile○(可視性のみ)✕中フラグ変数の読み書き
AtomicInteger等○○高(CAS使用)カウンター・数値更新
ReentrantLock○○中〜高タイムアウト付きロック
ConcurrentHashMap等○○(一部操作)高(分割ロック)高並列な読み書き

synchronizedでスレッドセーフを実現する

最も基本的なスレッドセーフ実現方法は「同期化(Synchronization)」です。

Javaではsynchronizedキーワードを使って実現します。

synchronizedは、メソッドや特定のコードブロックに対して、一度に1つのスレッドだけが実行できるようにします。

この仕組みを「排他制御」や「ロック」と呼びます。

ただし、synchronizedを多用するとスレッドがロックを待つ時間が増え、パフォーマンスが低下します。多用は避けましょう。

synchronizedメソッド

public synchronized void increment() {
    count++; // この操作は一度に一つのスレッドしか実行できない
}

メソッド全体を同期化します。このメソッドが実行されている間、他のスレッドは同じインスタンスのsynchronizedメソッドを実行できません。

synchronizedブロック

public void increment() {
    // ... 他の処理 ...
    synchronized (this) {
        count++; // このブロックだけを同期化
    }
    // ... 他の処理 ...
}

コードの一部だけを同期化します。同期化の範囲を最小限にすることで、パフォーマンスへの影響を抑えられます。

synchronizedの後ろの(this)は、ロックを取得する対象(モニターオブジェクト)を指定します。

synchronized なし vs あり:実行結果の比較

実際にsynchronizedの有無でどれほど結果が変わるか確認してみましょう。

// 非スレッドセーフなカウンター(synchronizedなし)
public class UnsafeCounter {
    private int count = 0;
    public void increment() { count++; }
    public int getCount() { return count; }
}

// スレッドセーフなカウンター(synchronizedあり)
public class SafeCounter {
    private int count = 0;
    public synchronized void increment() { count++; }
    public int getCount() { return count; }
}

// 1000スレッドで各1000回インクリメント → 期待値は 1,000,000
// UnsafeCounter の結果例: 987,342(毎回変わる不正な値)
// SafeCounter  の結果例: 1,000,000(常に正しい値)
2つのスレッドが同時に count++ すると(synchronized なし/あり)
順番
synchronized なし
synchronized あり
1
A:count を読む(0)B:― / count 0
A:ロック取得・読む(0)B:ロック待ち / count 0。一度に1つのスレッドだけが実行
2
B:count を読む(0)A:0+1 を計算中 / count 0。Aの途中でBも古い値 0 を読む
A:1 を書き込むB:ロック待ち(入れずに待つ) / count 1
3
A:1 を書き込むB:0+1 を計算中 / count 1
A:ロック解放 → B:ロック取得・読む(1)Bは最新の 1 を読む / count 1
4
B:1 を書き込むBも 1 で上書き / count 1
B:2 を書き込む1+1 を書き込む / count 2
結果
✕ count 1(期待値 2)更新が消えた
○ count 2(期待どおり)
NOTE2回だけ実行した場合の模式図です。上のコードは1000スレッド×1000回です。

volatile修飾子の役割

volatile修飾子は、主に「メモリの可視性」を保証するために使われます。

マルチスレッド環境では、各スレッドが変数をCPUキャッシュに保持するため、あるスレッドが変数の値を変更しても他のスレッドからは見えないことがあります。

volatileを付けた変数は常にメインメモリから読み書きされるため、あるスレッドによる変更が、他のすべてのスレッドから必ず見えるようになります。

より正確には、Javaのメモリモデルで「volatile変数への書き込みは、その後に同じ変数を読むスレッドに必ず見える(happens-before関係)」と保証されます。書き込みより前に行った、ほかの変数への変更も一緒に見えるようになります。

private volatile boolean running = true;

public void stop() {
    running = false; // この変更は他のスレッドからすぐに見える
}

public void run() {
    while (running) {
        // ... 処理 ...
    }
}

volatileは、変数の変更を他のスレッドに即座に知らせることで、特定の状況でのスレッドセーフに貢献します。

ただし、volatileはアトミック性(複数の操作を一体として実行すること)は保証しません。

例えば、count++のような「読み込み→変更→書き込み」という一連の操作は、volatileだけではスレッドセーフになりません。

この場合はsynchronizedや後述するAtomicクラスが必要です。

java.util.concurrentで高速化する

java.util.concurrentパッケージはJava 5で導入され、現在のJava(21〜26)ではさらに拡張されています。

スレッドセーフなコレクション、より柔軟なロック機構、アトミックな操作を行うクラスなどが含まれます。

あわせて読む

Atomicクラス

AtomicInteger, AtomicLong, AtomicReferenceなどがあります。

これらは、count++のような複合操作をアトミックに実行できます。synchronizedより軽量で高速です。

private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet(); // アトミックにインクリメント
}

なぜAtomicIntegerはsynchronizedより高速なのか: 内部では「CAS(Compare-And-Swap)」という方式で更新します。

これは、更新前に「期待する値」と「実際の値」を比較し、一致していればその場で新しい値に書き換える、一致していなければ最新の値を取得して再試行する、という動きです。

ロックを取得してスレッドを待たせる必要がないため、高並列でアクセスが競合する場面ほどsynchronizedより有利になります。

Lockインターフェース(ReentrantLock)

ReentrantLockなどがあります。synchronizedよりも高度なロック機能を提供します。

例えば、ロックの取得を試みて、取得できなかったら待たずに別の処理を行う、といった制御が可能です。

import java.util.concurrent.locks.ReentrantLock;

public class SafeCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock(); // ロック取得
        try {
            count++;
        } finally {
            lock.unlock(); // 必ずfinallyで解放
        }
    }

    // タイムアウト付きでロックを試みる
    public boolean tryIncrement() throws InterruptedException {
        if (lock.tryLock()) { // ロックを取得できた場合のみ実行
            try {
                count++;
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false; // ロック取得できなかった場合
    }
}

Concurrentコレクション

ConcurrentHashMap・CopyOnWriteArrayListは、synchronizedでラップされたコレクションよりも高いパフォーマンスを発揮するスレッドセーフなコレクションです。

Mapを複数スレッドで共有するときの選択肢には、ConcurrentHashMapとCollections.synchronizedMap()があります。違いは次の3点です。

  • ロックの範囲:synchronizedMap()はMap全体を1つのロックで守ります。ConcurrentHashMapは読み取りにロックを使わず、書き込みもエントリ単位の細かい同期で済むため、同時アクセスが多いほど有利です。
  • 反復中の変更:HashMapを包んだsynchronizedMap()は、反復中に変更するとConcurrentModificationExceptionになります。ConcurrentHashMapのイテレータは例外を出しません(反復を始めた後の変更は、見えることも見えないこともあります)。
  • nullの扱い:ConcurrentHashMapはキーにも値にもnullを入れられません(NullPointerException)。HashMapを包んだsynchronizedMap()は入れられます。

ConcurrentHashMapで値を集計するときは、getとputを組み合わせず、mergeやcomputeのようなアトミックなメソッドを使います。

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

// NG: get と put の間に別のスレッドが割り込み、数え漏れが出る
Integer v = counts.get("java");
counts.put("java", v == null ? 1 : v + 1);

// OK: merge は「読み取り→加算→書き込み」を1つのアトミックな操作で行う
counts.merge("java", 1, Integer::sum);

JDK 23で8スレッドに1万回ずつ数えさせると、mergeは80,000件と正しく数えられました。get→putの書き方は15,778件で、大きく数え漏れています(結果は実行のたびに変わります)。

シングルトンパターンのスレッドセーフな実装

マルチスレッド環境で特に注意が必要なのが「シングルトンパターン」です。

シングルトンはインスタンスを1つだけ生成する設計ですが、スレッドセーフに実装しないと複数のインスタンスが生成される危険があります。

NG例: スレッドセーフでないシングルトン

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {           // ①複数スレッドが同時にnullチェック
            instance = new Singleton();   // ②複数インスタンスが生成される可能性
        }
        return instance;
    }
}

①のnullチェックと②のインスタンス生成の間に別のスレッドが割り込むと、複数のインスタンスが生成されてしまいます。

OK例1: synchronizedで解決(シンプル・低パフォーマンス)

public class Singleton {
    private static Singleton instance;

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

シンプルで確実ですが、getInstance()が呼ばれるたびにロックを取得するためパフォーマンスが低下します。

OK例2: ダブルチェックロッキング(パフォーマンスと安全性を両立)

public class Singleton {
    // volatileが必須(Java5以降)
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {              // 1回目のチェック(ロックなし)
            synchronized (Singleton.class) {
                if (instance == null) {      // 2回目のチェック(ロックあり)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

インスタンス生成後はロックが発生しないため高パフォーマンスです。volatileを付けることで、インスタンスの部分的な初期化問題を防ぎます。

OK例3: Initialization-on-demand holderパターン(最も推奨)

public class Singleton {
    // 内部クラスはSingleton.getInstance()が初めて呼ばれたときに初期化される
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

JVMのクラスロードの仕組みを利用した遅延初期化です。

synchronized不要でスレッドセーフかつ高パフォーマンスのため、最も推奨されるパターンです。

ThreadLocalでスレッドごとに変数を分ける

ThreadLocalを使うと、各スレッドが自分専用のコピーを持てます。スレッド間でデータを共有しないため、同期化なしにスレッドセーフを実現できます。

典型的なユースケースがSimpleDateFormatです。

SimpleDateFormatはスレッドセーフではないため、複数スレッドで共有すると日付のフォーマット結果が壊れることがあります。

ThreadLocalを使えばスレッドごとに独立したインスタンスを保持できます。

// スレッドごとに独立したSimpleDateFormatを持つ
private static final ThreadLocal<SimpleDateFormat> dateFormat =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public String formatDate(Date date) {
    return dateFormat.get().format(date); // 各スレッドが自分専用のインスタンスを使用
}

// スレッド終了時は必ずremove()を呼ぶ
public void cleanup() {
    dateFormat.remove(); // メモリリーク防止
}

注意点: スレッドプール環境(TomcatなどのWebサーバー)ではスレッドが再利用されます。

remove()を忘れると、前のリクエストのデータが次に混入し、バグやメモリリークを起こします。使用後は必ずremove()を呼び出しましょう。

現在(2026年9月・最新はJava 27)では、SimpleDateFormatの代わりに不変なDateTimeFormatter(Java 8以降)の使用を推奨します。

DateTimeFormatterはスレッドセーフなためThreadLocalも不要です。

新規プロジェクトではRecord型と組み合わせた不変設計を採用するとよりシンプルになります。

不変オブジェクト設計でスレッドセーフ

不変(Immutable)オブジェクトは、一度生成した後に状態が変わりません。変更されないため、複数のスレッドから同時にアクセスされても安全です。同期化のコストもゼロです。

Stringクラスが安全な理由はここにあります。Stringはインスタンス生成後に値が変わらないため、スレッドセーフです。

不変クラスを設計する条件は次の4つです。

  1. すべてのフィールドをfinalにする
  2. クラスをfinalにする(継承を禁止)
  3. セッターを提供しない
  4. 可変オブジェクトへの参照をコピーして渡す(防御的コピー)
// 不変クラスの例
public final class Money {
    private final int amount;
    private final String currency;

    public Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public int getAmount() { return amount; }
    public String getCurrency() { return currency; }

    // 変更が必要な場合は新しいインスタンスを返す
    public Money add(int value) {
        return new Money(this.amount + value, this.currency);
    }
}

可変クラスと不変クラスの最大の違いは「状態を変える操作」の扱いです。

可変クラスはフィールドを直接書き換えますが、不変クラスは変更結果を新しいインスタンスとして返します。

スレッドセーフのレベル分類|immutable・unconditionally thread safe・conditionally thread safe

「このクラスは安全に共有できるのか」を素早く判断するために、Effective Java(第3版 項目82)が定義する5段階の分類が役立ちます。

レベル意味該当クラスの例(本記事内)
immutable状態が変わらないため常に安全String、Money(不変クラス例)
unconditionally thread safeクラス内部で同期済み。呼び出し側は同期不要ConcurrentHashMap、AtomicLong
conditionally thread safe一部の操作は呼び出し側で追加の同期が必要Collections.synchronizedList()、Vector(イテレータ・複合操作は呼び出し側で同期)
not thread safe呼び出し側が全面的に同期する必要があるArrayList、HashMap、SimpleDateFormat
thread hostile同期しても安全に使えない。設計ミスであり修正・破棄が必要意図して作られることはなく、発見したら要修正

このレベルを意識しておくと、外部ライブラリや標準クラスを安全に使えるかどうかの判断が速くなります。

ドキュメントに明記がない場合は「not thread safe」を前提に扱うのが安全です。

Javaの主要クラスのスレッドセーフ性一覧

Javaの標準クラスにはスレッドセーフなものとそうでないものが混在しています。

マルチスレッド環境で共有するクラスの使い分けを押さえると、実行時のバグを未然に防げます。

スレッドセーフなクラス(Vector・StringBuffer・ConcurrentHashMap等)

以下がスレッドセーフなクラスの例です。

  • Vector: ArrayListの古いバージョンで、すべてのパブリックメソッドにsynchronizedが付いています。そのため、ArrayListに比べてパフォーマンスが劣ります。
  • Hashtable: HashMapの古いバージョンです。パフォーマンス面でConcurrentHashMapに劣ることが多いです。
  • StringBuffer: 文字列を扱うクラスで、メソッドが同期化されています。
  • ConcurrentHashMap: 高い並行性を実現するように設計されたHashMapです。読み取り操作はロックなしで行え、書き込み操作も部分的なロックで行うため、Hashtableよりもはるかに高いパフォーマンスを発揮します。
  • CopyOnWriteArrayList: 読み取り操作が多く、書き込み操作が少ない場合に有効なリストです。書き込みが発生すると、元の配列をコピーして新しい配列を作成するため、読み取り操作はロックなしで安全に行えます。

スレッドセーフでないクラスと代替クラス(比較表)

日常的によく使う多くのクラスは、パフォーマンスを優先するため、スレッドセーフではありません。

マルチスレッド環境で共有する場合は、スレッドセーフな代替クラスへの切り替えを検討してください。

非スレッドセーフスレッドセーフな代替注意点
ArrayListCopyOnWriteArrayList / Collections.synchronizedList()ArrayListはマルチスレッドでデータ破壊の危険あり
HashMapConcurrentHashMap / Collections.synchronizedMap()HashMapはマルチスレッドで無限ループに陥る可能性あり
StringBuilderStringBufferシングルスレッドならStringBuilderの方が高速
SimpleDateFormatDateTimeFormatter(Java 8以降)SimpleDateFormatは内部状態を持ちスレッドセーフでない

Collections.synchronizedXxx()でラップする方法

非スレッドセーフなコレクションをマルチスレッド環境でどうしても使いたい場合は、Collectionsクラスのユーティリティメソッドが使えます。

元のコレクションをラップし、スレッドセーフなビューを作成できます。

  • Collections.synchronizedList(new ArrayList<>())
  • Collections.synchronizedMap(new HashMap<>())
  • Collections.synchronizedSet(new HashSet<>())

これらのメソッドは、元のコレクションの各メソッド呼び出しをsynchronizedブロックで囲んだ新しいコレクションを返します。

Collectionsクラスのラッパーを使えば、非スレッドセーフなコレクションを簡単にスレッドセーフ化できます。

ただし、この方法はコレクション全体をロックするため、ConcurrentHashMapなどのjava.util.concurrentパッケージのクラスに比べてパフォーマンスが劣る場合があります。

また、イテレータ(拡張for文など)を使う場合は、手動で同期化する必要がある点に注意が必要です。

List<String> list = Collections.synchronizedList(new ArrayList<>());
// ... listに要素を追加 ...

// イテレータを使う場合は、手動で同期化が必要
synchronized (list) {
    for (String item : list) {
        System.out.println(item);
    }
}

実践でよくあるスレッドセーフの落とし穴

スレッドセーフの手法を覚えても、組み合わせ方次第で問題が起きます。現場でよく見るパターンとその対策を紹介します。

複数の操作を安全に行うには?

synchronizedメソッドやスレッドセーフなクラスを使っても、複数の操作を組み合わせる場合には注意が必要です。個々の操作がスレッドセーフでも、それらを組み合わせた一連の処理がスレッドセーフであるとは限らないからです。

例えば、Vectorはスレッドセーフですが、以下のコードはスレッドセーフではありません。

Vector<String> vector = new Vector<>();
// ... vectorに要素を追加 ...

// Check-then-Act: スレッドセーフではない!①チェックと②削除の間に別のスレッドが割り込む可能性
if (!vector.isEmpty()) { // ①チェック
    String item = vector.remove(0); // ②削除
    // ... itemを使った処理 ...
}

上記のコードでは、isEmpty()で空でないことを確認してからremove(0)を呼び出しています。

しかし、①のチェックと②の削除の間に、別のスレッドがvectorから最後の要素を削除してしまう可能性があります。

すると、②のremove(0)は要素がないのに削除しようとして、ArrayIndexOutOfBoundsExceptionが発生します。

複数の操作を組み合わせる場合は、一連の操作全体を一つのアトミックな操作として同期化する必要があります。

synchronized (vector) { // vectorをロックして一連の処理を実行
    if (!vector.isEmpty()) {
        String item = vector.remove(0);
        // ... itemを使った処理 ...
    }
}

複数の操作を組み合わせる場合は、その一連の処理全体をsynchronizedブロックで囲む必要があります。

個々の操作が安全でも、操作の間に他のスレッドが割り込むと、前提条件が崩れる可能性があるためです。

複数フィールドの一貫性を保つ(不変条件・Invariant)

「不変条件(Invariant)」とは、ある処理の前後で常に成り立っていなければならない条件のことです。

個々のフィールドをそれぞれスレッドセーフにしても、フィールド間の関係性まで保護できるとは限りません。

例えば、下限lowerと上限upperの2つのフィールドを持ち、「lower <= upper」という不変条件を満たす必要がある範囲オブジェクトを考えます。

public class NumberRange {
    // INVARIANT: lower <= upper
    private final AtomicInteger lower = new AtomicInteger(0);
    private final AtomicInteger upper = new AtomicInteger(0);

    // スレッドセーフではない! ①と②の間に別スレッドが割り込むとlower > upperになりうる
    public void setLower(int value) {
        if (value > upper.get()) { // ①
            throw new IllegalArgumentException();
        }
        lower.set(value); // ②
    }
}

AtomicIntegerを使っているため個々のフィールドへの読み書きはスレッドセーフですが、「①チェックと②書き込みの間」に別のスレッドがupperを変更すると、lower <= upperという不変条件が崩れます。

この場合は、lowerとupperをまとめて1つのロックで保護する必要があります。

スレッドセーフでも不十分なケースとは

スレッドセーフなクラスを使っているからといって、常に安全とは限りません。使い方によっては、やはり競合状態が発生することがあります。

例えば、「キーが存在しなければ追加する」という処理を考えてみましょう。

ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

String key = "test";
String value = "value";

// スレッドセーフではない! ①getと②putの間に別のスレッドがputする可能性
if (map.get(key) == null) { // ①get
    map.put(key, value); // ②put
}

ConcurrentHashMapのgetやputはスレッドセーフですが、①と②の間はアトミックではありません。

複数の操作を組み合わせる場合は、ConcurrentHashMapが提供するアトミックなメソッドputIfAbsentを使います。

map.putIfAbsent(key, value); // キーが存在しない場合のみアトミックにputする

スレッドセーフなクラスを使う場合でも、提供されているアトミックなメソッドを適切に利用することが重要です。

自前で複数の操作を組み合わせると、意図せず競合状態を生み出す可能性があるからです。

パフォーマンスとのトレードオフ

スレッドセーフを実現するための同期化には、必ずコストがかかります。

synchronizedを使うと、ロックの取得と解放にオーバーヘッドが生じ、複数のスレッドがロックを待つことで、全体の処理速度が低下します。

そのため、スレッドセーフは重要ですが、必要以上に同期化を行うことは避けるべきです。

あわせて読む

実務での使い分け判断フロー

「どの手法を使えばいいか」は以下の3ステップで判断します。

  1. 共有状態があるか? → なければスレッドセーフの考慮不要(ローカル変数のみ・不変オブジェクトならOK)
  2. 変更操作は単純か? → カウンターや単一フラグの更新ならAtomicInteger/volatileで十分。複数フィールドの一貫性が必要ならsynchronizedかReentrantLock
  3. 読み書きの比率は? → 読み取り多・書き込み少ならConcurrentHashMap/CopyOnWriteArrayList。書き込みも多いならsynchronizedブロックで範囲を最小化

この判断に不変オブジェクト・ThreadLocalの検討も加えて、実装方法の選び方を1枚の図にまとめると次のようになります。

スレッドセーフの実装方法の選び方(Q1から順に答える)
QUESTION / 質問
はい
いいえ
Q1. 複数スレッドで共有する可変データがある?
Q2へ
対策不要ローカル変数のみ
Q2. 不変オブジェクトにできる?
不変オブジェクト(finalフィールド)
Q3へ
Q3. スレッドごとに別々に持てばよい?
ThreadLocal
Q4へ
Q4. Map・Listを共有する?
ConcurrentHashMap
CopyOnWriteArrayList
Q5へ
Q5. 更新はカウンター・単一値の更新だけ?
AtomicInteger など
Q6へ
Q6. フラグの読み書きだけ?
volatile
Q7へ複数フィールドを一貫して更新する
Q7. タイムアウト・公平性が必要?
ReentrantLock
synchronizedロック範囲は最小に

迷ったら「不変オブジェクト設計にできないか?」を最初に検討しましょう。同期化コストがゼロで、コードの見通しも良くなります。

それが無理な場合に初めてロック系の手法を選びます。

ポイント

  • 同期化の範囲を最小限にする: synchronizedメソッドよりもsynchronizedブロックを使い、ロック範囲をできるだけ狭く。
  • ロックの粒度を考える: ConcurrentHashMapのように、オブジェクト全体ではなく部分的にロックする仕組みを利用。
  • 不変(Immutable)オブジェクトを活用する: 変更されないオブジェクトは、そもそもスレッドセーフについて心配する必要がありません。可能な限りオブジェクトを不変に設計することは、スレッドセーフなプログラムを書くための強力な戦略。
  • スレッドローカル(ThreadLocal)を使う: 各スレッドが自分専用の変数を持つようにすれば、共有による問題を避けられます。ただし、メモリ使用量が増える可能性に注意が必要。

デッドロックを防ぐための実装パターン

デッドロックは、複数のスレッドが互いに相手のロックを待ち続けて処理が止まる状態です。一度発生するとアプリケーションが応答不能になります。

あわせて読む

以下の3つのパターンで防げます。

ロック取得順序を統一する

複数のロックを取得する場合、すべてのスレッドで同じ順序でロックを取得するルールを設けます。

// NG: スレッドAはlockA→lockB、スレッドBはlockB→lockAの順でロックするとデッドロック
// OK: 常にlockA→lockBの順でロック取得する
synchronized (lockA) {
    synchronized (lockB) {
        // 処理
    }
}

ReentrantLock.tryLock()でタイムアウト制御する

ReentrantLockのtryLock(timeout)を使うと、指定した時間内にロックを取得できなかった場合に諦めて処理を中断できます。

一定時間で諦めてロックを手放すため、互いに待ち続けるデッドロックの状態にはなりません(呼び出し側はfalseを受け取ったら、少し待って再試行するなどの対応を書きます)。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public boolean tryTransfer(ReentrantLock lockA, ReentrantLock lockB) throws InterruptedException {
    if (lockA.tryLock(500, TimeUnit.MILLISECONDS)) {
        try {
            if (lockB.tryLock(500, TimeUnit.MILLISECONDS)) {
                try {
                    // 両方のロックを取得できた場合のみ処理
                    return true;
                } finally {
                    lockB.unlock();
                }
            }
        } finally {
            lockA.unlock();
        }
    }
    return false; // タイムアウト → デッドロック回避
}

jstackでデッドロックを検出する

本番環境でアプリケーションが応答しなくなった場合、jstackコマンドでスレッドダンプを取得するとデッドロックの発生を確認できます。

# JavaプロセスのPIDを確認
jps -l

# スレッドダンプを取得(デッドロックは "Found one Java-level deadlock:" で確認)
jstack <PID>

Spring Bootのシングルトンとスレッドセーフの注意点

Spring Bootのサービスクラスやコントローラーはデフォルトでシングルトンです。

つまり、アプリケーション全体で1つのインスタンスを複数のリクエスト(スレッド)が共有します。

シングルトンBeanのインスタンスフィールドにリクエスト固有のデータを保持すると、高負荷時に別ユーザーのデータが混入するバグが発生します。

// NG: インスタンスフィールドにリクエストデータを持つ(シングルトンでは危険)
@Service
public class OrderService {
    private String currentUserId; // 複数スレッドで共有される → 危険!

    public void processOrder(String userId, Order order) {
        this.currentUserId = userId; // 別スレッドが上書きする可能性
        // ...
    }
}

// OK: メソッドの引数で渡す(スタック領域 = スレッド固有)
@Service
public class OrderService {
    public void processOrder(String userId, Order order) {
        // userIdはメソッド引数なのでスタック領域に置かれ、スレッドセーフ
        // ...
    }
}

リクエスト固有の情報(ユーザーID・セッション情報など)はメソッドの引数で渡すか、ThreadLocal(SpringではRequestContextHolder等)を使いましょう。

スレッドセーフの確認方法・静的解析ツールの活用

作成したコードがスレッドセーフかどうかを確認する手順を紹介します。レビューと静的解析の両方を活用しましょう。

コードレビューのチェックポイント

まず、以下の観点でコードを確認します。

  1. 共有変数の有無: インスタンス変数・クラス変数(static変数)が複数のスレッドからアクセスされていないか確認する
  2. 可変性(Mutability)の確認: 共有される変数が変更(書き込み)されるかどうか確認する。読み取り専用なら問題ない
  3. アトミック性の確認: 共有変数への複合操作(「読み込み→変更→書き込み」)が同期化されているか確認する
  4. Check-then-Actパターンの確認: 「チェックしてから操作」という処理が同期化されているか確認する

静的解析ツールによるチェック

ツールを使って自動的にスレッドセーフの問題を検出することもできます。

  • SpotBugs(旧FindBugs): Javaのバグパターンを静的解析するツール。スレッドセーフに関するバグパターン(IS2_INCONSISTENT_SYNC: 同期化の不一致など)を検出できます。MavenプラグインやGradle、IntelliJ IDEAのプラグインとして利用可能。
  • IntelliJ IDEAのスレッドセーフ警告: @GuardedByアノテーションを使うと、フィールドへのアクセスが適切にロックされているかをIDEが警告してくれます。
import javax.annotation.concurrent.GuardedBy;

public class SafeCounter {
    @GuardedBy("this") // thisロックで保護されていることを明示
    private int count = 0;

    public synchronized void increment() {
        count++;
    }
}

注意: @GuardedByはJava標準APIではなく、JSR-305ライブラリに含まれます。使用するにはMavenに以下の依存を追加してください。

<dependency>
    <groupId>com.google.code.findbugs</groupId>
    <artifactId>jsr305</artifactId>
    <version>3.0.2</version>
</dependency>

Java 21以降の仮想スレッド(Virtual Thread)とスレッドセーフ

Java 21(2023年9月、LTS)でProject Loom由来の仮想スレッド(Virtual Thread)が正式採用されました。

2025年9月にはJava 25(LTS)がリリースされ、仮想スレッドはさらに安定しています。

2026年9月時点の最新版はJava 27(2026年9月15日リリース)です。

仮想スレッドとは

仮想スレッドはJVMが管理する軽量なスレッドです。

数万〜数百万の仮想スレッドを低コストで作成できるため、I/O待ちが多いWebアプリケーションで特に効果的です。

あわせて読む
// 仮想スレッドの起動(Java 21以降)
Thread.ofVirtual().start(() -> {
    System.out.println("仮想スレッドで実行中: " + Thread.currentThread().isVirtual());
});

// ExecutorServiceでの使用
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> System.out.println("タスク実行"));
}

スレッドセーフの考え方は従来と同じ

仮想スレッドを使っても、スレッドセーフの基本原則は変わりません。ヒープ領域の共有データへの同時アクセスは依然として危険です。

synchronized・volatile・Atomicクラスを適切に使う必要があります。

synchronizedとのピン留め問題に注意

Java 21〜23では、仮想スレッドがsynchronizedブロック内でI/O待ちになると、仮想スレッドがOSスレッド(キャリアスレッド)に「ピン留め」されます。

この状態では仮想スレッドのメリット(軽量なI/O待機)が失われます。Java 21〜23で仮想スレッドを使う場合は、synchronizedの代わりにReentrantLockを使うことでピン留めを回避できます。

この問題はJava 24のJEP 491で解消され、synchronizedの中でブロックしても仮想スレッドはキャリアスレッドから外れられるようになりました。Java 24以降(LTSならJava 25)では、ピン留め対策のためだけにsynchronizedをReentrantLockへ書き換える必要はありません。

ただしJEP 491の後も、ネイティブメソッド呼び出し中やクラスの初期化・ロード中にブロックした場合はピン留めが残ります。発生しているかどうかは、JDK Flight Recorder(JFR)のjdk.VirtualThreadPinnedイベントで確認できます。

Javaスレッドセーフに関するよくある質問

Q. StringBuilderはスレッドセーフですか?

いいえ、StringBuilderはスレッドセーフではありません。マルチスレッド環境で文字列を組み立てる場合はStringBufferを使うか、ローカル変数としてStringBuilderを使えばスレッドセーフになります(ローカル変数はスタック領域に置かれスレッド間で共有されないため)。シングルスレッド環境ではStringBuilderの方が高速です。

Q. synchronizedとReentrantLockはどちらを使うべき?

基本的にはsynchronizedで十分です。ReentrantLockが必要になるのは次の場合です。タイムアウト付きのロック取得(tryLock)、公平性の制御(fairness)、複数条件変数(Condition)が必要なときです。なお、Java 21〜23の仮想スレッドではピン留め回避のためReentrantLockが推奨されていましたが、Java 24以降(JEP 491)はその理由での書き換えは不要です。

Q. Spring Bootのコントローラーはスレッドセーフ?

Spring Bootのコントローラー(@Controller / @RestController)はデフォルトでシングルトンのため、インスタンスフィールドにリクエスト固有のデータを持つとスレッドセーフではなくなります。リクエストデータはメソッド引数で受け取るか、@RequestScopeのBeanに保持するのが安全です。

Q. スレッドセーフとそうでないクラスの見分け方は?

公式ドキュメントに明記があればそれに従います。明記がない場合は「不変(Immutable)かどうか」「複数スレッドで共有する設計か」を確認し、不明なら「not thread safe」として同期化する運用が安全です。

Q. @GuardedByアノテーションとは何ですか?

フィールドがどのロックオブジェクトで保護されているかをコード上に明示するアノテーションです。Java標準APIではなくJSR-305ライブラリに含まれ、IntelliJ IDEAなどのIDEが同期漏れを警告してくれます。

Q. ConcurrentHashMapはスレッドセーフなのに、なぜ注意が必要なのですか?

get・putなど個々の操作はスレッドセーフですが、「値がなければputする」のような複数操作の組み合わせはアトミックではありません。putIfAbsentなど、ConcurrentHashMapが提供するアトミックなメソッドを使う必要があります。

Q. マルチスレッドのバグはどうやってテストすればいいですか?

単体テストだけでは再現しにくいため、多数のスレッドで同時に処理を実行する負荷テストと、jstackによるスレッドダンプ確認を組み合わせます。SpotBugsなどの静的解析ツールでの事前検出も有効です。

まとめ:Javaスレッドセーフの要点と次のステップ

スレッドセーフは設計段階で決まります。後付けの同期化は破綻しやすいため、共有状態を最小化することが鉄則です。

スレッドセーフを判断する3つの視点

あるコードがスレッドセーフかどうかを考える際には、以下の3つの視点が役立ちます。

  1. 状態の共有 (Shared State): 複数のスレッドからアクセスされる可能性のあるデータ(インスタンス変数、静的変数など)はあるか? 共有されているデータがなければ、通常スレッドセーフの問題は起きない。
  2. 可変性 (Mutability): 共有されているデータは変更されるか? データが不変(読み取り専用)であれば、複数のスレッドから同時にアクセスされても問題ない。
  3. アトミック性 (Atomicity): 共有されている可変データへのアクセス(特に読み書きを伴う一連の操作)は、アトミックに行われているか? synchronizedやAtomicクラスなどで保護されているか確認。

開発時に意識したいこと

スレッドセーフに関して開発時に意識したいことは以下のとおりです。

  • 設計段階から考慮する: マルチスレッドを後から付け足すのは困難のため、最初からスレッドセーフを意識して設計する。
  • 共有するデータは最小限に: 可能な限り、スレッド間でデータを共有しない設計をする。
  • 不変性を活用する: 変更する必要のないデータは、final修飾子を使うなどして不変にする。
  • 適切な同期化手法を選ぶ: 状況に応じてsynchronized, volatile, Lock, Atomicクラス、Concurrentコレクションを使い分ける。
  • テストを徹底する: マルチスレッドのバグは再現が難しいため、負荷テストと並行処理を想定したテストが必須です。
  • Java 24以降はsynchronizedでも仮想スレッドがピン留めされにくい: Java 21でVirtual Threadが正式採用され、Java 24(JEP 491)でsynchronizedのピン留めが解消されました。最新LTSはJava 25(2025年9月)、最新版はJava 27(2026年9月)です。新規プロジェクトでの活用を検討しましょう。スレッドセーフの考え方は従来スレッドと変わりません。

おすすめの参考資料・書籍

さらにJavaのスレッドセーフや並行処理について深く学びたい方には、以下の資料がおすすめです。

『Java並行処理プログラミング ―その「基盤」と「最新API」を究める―』はJavaの並行処理に関するバイブル的な一冊。

java.util.concurrentの設計を主導したDoug Leaも共著者に名を連ねる一冊で、理論から実践まで深く学べます。難易度は高めですが、読む価値があります。

この記事で学んだこと(確認リスト)

  • □ スレッドセーフとは何かを自分の言葉で説明できる
  • □ synchronized・volatile・AtomicIntegerの使い分けがわかる
  • □ シングルトンパターンのスレッドセーフな実装方法(3パターン)を知っている
  • □ よくある落とし穴(複数操作の非アトミック性・Spring Beanの注意点)を把握している
  • □ SpotBugsを使ったスレッドセーフの静的解析方法がわかる
REVISIONS / 改訂履歴
REV.DATE内容
52026-10-16図解を描き直し(図面のデザインにそろえた)
42026-10-14雰囲気だけの挿絵を外した
32026-08-012026年8月時点の情報で全体を見直し
22026-05-042026年5月時点の情報で全体を見直し
12025-05-25初版
NEXT READ本棚から1本

AIで開発を組み直すなら、まずこの5冊から

入門・実践・設計まで、Claude Codeの本をレベル別に5冊選んで比べました。

→ おすすめ5冊を見る

  • この記事を書いた人
  • 最新記事

トム

SESで約8年、環境を変えられないもどかしさの中にいました。32歳で自社開発企業に転職し、今はClaude Codeと自動化で、仕事も時間も組み直している途中です。
・Javaバックエンドエンジニア(Java歴12年以上)
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎
詳細なプロフィール

𝕏

Java入門