【Java入門】synchronized キーワードの使い方と基本


Java入門: synchronized キーワード徹底解説 – マルチスレッド環境でのデータ競合を防ぐ

はじめに

現代のソフトウェア開発において、複数の処理を同時に実行する「並行処理(Concurrency)」や「並列処理(Parallelism)」は、アプリケーションの性能向上や応答性維持のために不可欠な技術となっています。Javaも、この並行処理を強力にサポートしており、その中心となる概念の一つに「スレッド(Thread)」があります。

しかし、複数のスレッドが同時に動作する環境では、予期せぬ問題が発生する可能性があります。特に、複数のスレッドが共有するデータに同時にアクセスする場合、処理の順序によって結果が変わってしまう「データ競合(Race Condition)」が発生し、プログラムの正しさが損なわれることがあります。

Javaでは、このようなデータ競合を防ぎ、スレッドセーフなコードを書くための仕組みがいくつか提供されています。その中でも最も基本的で重要なキーワードが、今回詳しく解説する synchronized です。

この記事では、Java初心者の方でも理解できるように、マルチスレッド環境の基礎から始まり、なぜ synchronized が必要なのか、そしてその使い方、内部的な仕組み、注意点までを約5000語かけて徹底的に解説します。この記事を読めば、Javaにおけるスレッドセーフなプログラミングの第一歩を踏み出し、安全なマルチスレッドアプリケーションを開発するための基礎知識を習得できるでしょう。

さあ、synchronized の世界に飛び込んでいきましょう!

第1章: マルチスレッド環境の基礎と問題点

synchronized の重要性を理解するためには、まずマルチスレッド環境の基本的な考え方と、そこで発生しうる問題を把握する必要があります。

1.1 スレッドとは何か?

Javaにおいて、スレッドとはプログラムの実行単位の一つです。通常、Javaプログラムは起動時に一つのメインスレッドを持ちますが、必要に応じて複数のスレッドを生成し、同時に(または非常に短い時間間隔で切り替えながら)実行させることができます。

複数のスレッドを使う主なメリットは以下の通りです。

  • パフォーマンスの向上: マルチコアCPUの能力を最大限に活用し、計算量の多いタスクを複数のコアで分担して処理することで、全体の実行時間を短縮できます。
  • 応答性の向上: 時間のかかる処理(例: ファイルI/O、ネットワーク通信)を別スレッドに任せることで、メインスレッドがユーザーインターフェースの操作など、他のタスクをブロックすることなくスムーズに処理できます。
  • プログラミングのモデル化: 同時に発生するイベント(例: 複数のクライアントからのリクエスト処理)を、それぞれ独立したスレッドとしてモデル化することで、コードがシンプルになる場合があります。

Javaでスレッドを作成する方法はいくつかありますが、代表的なのは Thread クラスを継承するか、Runnable インターフェースを実装することです。

“`java
// Runnableインターフェースを実装する例
class MyRunnable implements Runnable {
@Override
public void run() {
// このスレッドで実行したい処理
System.out.println(Thread.currentThread().getName() + ” が実行中です。”);
}
}

// Threadクラスを継承する例
class MyThread extends Thread {
@Override
public void run() {
// このスレッドで実行したい処理
System.out.println(Thread.currentThread().getName() + ” が実行中です。”);
}
}

public class ThreadExample {
public static void main(String[] args) {
// Runnableを使ってスレッドを生成・実行
MyRunnable runnable = new MyRunnable();
Thread thread1 = new Thread(runnable, “Thread-1”);
thread1.start(); // スレッドを実行開始

    // Threadクラスを使ってスレッドを生成・実行
    MyThread thread2 = new MyThread();
    thread2.setName("Thread-2");
    thread2.start(); // スレッドを実行開始

    System.out.println("メインスレッドが終了します。");
}

}
“`

このコードを実行すると、「Thread-1 が実行中です。」と「Thread-2 が実行中です。」というメッセージが、メインスレッドのメッセージと異なる順序で表示される可能性があります。これは、各スレッドが独立して実行され、どのスレッドがいつCPUの実行時間を割り当てられるかはOSやJVMのスケジューラに依存するためです。

1.2 共有データとデータ競合(Race Condition)

複数のスレッドがそれぞれ独立して実行されること自体は問題ありません。問題となるのは、複数のスレッドが同じ「共有可能なリソース」に同時にアクセスし、そのリソースの状態を変更しようとする場合です。このようなリソースの典型的な例が、ヒープメモリ上に確保された共有オブジェクトのインスタンス変数です。

複数のスレッドが同時に共有データにアクセスし、期待通りの結果が得られない状態をデータ競合(Race Condition)と呼びます。データ競合が発生すると、プログラムの実行結果が実行ごとに変わったり、論理的に誤った状態になったりする可能性があります。これはデバッグが非常に困難な問題です。

最も古典的なデータ競合の例として、「カウンター」を考えてみましょう。複数のスレッドが一つのカウンター変数をインクリメント(1増やす)する処理を同時に実行する場合です。

単純なカウンタークラスを考えます。

“`java
class Counter {
private int count = 0;

public void increment() {
    count++; // count = count + 1;
}

public int getCount() {
    return count;
}

}
“`

ここで、複数のスレッドがこの Counter オブジェクトの increment() メソッドを呼び出すとどうなるでしょうか? 例えば、2つのスレッド(Thread AとThread B)が同時に increment() を呼び出す場合を考えます。count++ という一行のコードに見えますが、これはCPUのレベルでは通常、以下の3つのステップで実行されます。

  1. 現在の count の値をメモリからレジスタに読み込む。
  2. レジスタの値に1を加える。
  3. レジスタの新しい値をメモリに書き戻す。

データ競合が発生する可能性があるシナリオを見てみましょう。初期値 count = 0 とします。

時間 Thread Aの動作 Thread Bの動作 countの値 (メモリ)
T1 count の値を読み込む (0) 0
T2 レジスタで値をインクリメント (1) 0
T3 count の値を読み込む (0) 0
T4 count の値をメモリに書き戻す (1) 1
T5 レジスタで値をインクリメント (1) 1
T6 count の値をメモリに書き戻す (1) 1

このシナリオでは、Thread Aが値を読み込んだ後にThread Bが値を読み込んでしまいました。その結果、両方のスレッドが「countは0である」と認識し、それぞれが値を1にした後、最終的にcount1 になります。しかし、それぞれのスレッドが1回ずつインクリメントしたのですから、期待される結果は 2 です。これがデータ競合による結果の不整合です。

別のシナリオでは、期待通りの 2 になることもあります。

時間 Thread Aの動作 Thread Bの動作 countの値 (メモリ)
T1 count の値を読み込む (0) 0
T2 レジスタで値をインクリメント (1) 0
T3 count の値をメモリに書き戻す (1) 1
T4 count の値を読み込む (1) 1
T5 レジスタで値をインクリメント (2) 1
T6 count の値をメモリに書き戻す (2) 2

このシナリオでは、Thread Aがメモリに書き戻すまでThread Bは読み込みを開始しなかったため、最終的にcount2 になりました。

このように、データ競合が発生するコードは、実行するたびに結果が変わる非決定性を持っています。これはテストでバグを見つけにくく、運用環境で突然問題を引き起こす可能性があるため、マルチスレッドプログラミングにおいては絶対に避けなければなりません。

1.3 クリティカルセクション(Critical Section)

データ競合が発生する可能性がある、共有リソースにアクセス・操作するコードの領域をクリティカルセクション(Critical Section)と呼びます。上記のカウンターの例では、increment() メソッド全体がクリティカルセクションと言えます。

スレッドセーフなコードを書くためには、このクリティカルセクションへのアクセスを制御する必要があります。具体的には、ある時点で一つのスレッドだけがクリティカルセクションを実行できるようにする必要があります。このような排他的なアクセス制御を相互排他(Mutual Exclusion)と呼びます。

synchronized キーワードは、この相互排他を実現するためのJavaの基本的な仕組みです。

第2章: synchronized キーワードの基本

synchronized キーワードの主な目的は、前述の相互排他を実現することです。つまり、複数のスレッドが同時にクリティカルセクションに入り込み、共有データを破壊してしまうのを防ぎます。

synchronized は、特定のコードブロックやメソッドを「ロック」することで機能します。あるスレッドが synchronized されたコードを実行している間、そのコードに関連付けられたロックを取得します。他のスレッドが同じロックを取得しようとしても、ロックが解放されるまで待機させられます。これにより、複数のスレッドが同時に同じクリティカルセクションを実行することがなくなるのです。

synchronized キーワードは、主に以下の2つの方法で使用できます。

  1. synchronized メソッド: メソッド全体を同期化の対象とします。
  2. synchronized ブロック: メソッド内の一部のコードブロックを同期化の対象とします。

これらの使い方は、ロックの対象が異なります。

2.1 synchronized メソッド

メソッド宣言に synchronized キーワードを付けると、そのメソッド全体が同期化されます。

“`java
class SynchronizedCounter {
private int count = 0;

// インスタンスメソッドを同期化
public synchronized void increment() {
    count++;
    // または System.out.println(Thread.currentThread().getName() + " がインクリメントしました。count=" + count);
}

// staticメソッドを同期化
public static synchronized void staticIncrement() {
    // クラスレベルの共有データに対する操作など
    // staticCount++; // 例
}

public int getCount() {
    return count;
}

}
“`

  • インスタンスメソッド (public synchronized void increment()): このメソッドにアクセスするスレッドは、そのメソッドを呼び出すインスタンス自身 (this) に関連付けられた組み込みロック(Intrinsic Lock)を取得する必要があります。同じインスタンスに対して、複数のスレッドが同時に synchronized インスタンスメソッド(または後述の synchronized(this) ブロック)を実行することはできません。ただし、異なるインスタンスであれば、同時にそれぞれの synchronized メソッドを実行できます。
  • staticメソッド (public static synchronized void staticIncrement()): このメソッドにアクセスするスレッドは、そのメソッドが属するクラスオブジェクト (SynchronizedCounter.class) に関連付けられた組み込みロックを取得する必要があります。static synchronized メソッドは、そのクラスのどのインスタンスに対しても、あるいはインスタンスがなくても、同時に一つしか実行できません。これはクラスレベルの共有データ(例: static変数)を保護する場合に使用します。

上記の SynchronizedCounter クラスの increment() メソッドを使う場合、複数のスレッドが同じ SynchronizedCounter オブジェクトの increment() を呼び出しても、一度に一つのスレッドだけが実行できます。

2.2 synchronized ブロック

synchronized ブロックは、メソッド全体ではなく、特定のコードブロックのみを同期化の対象とします。これは、同期化が必要な処理がメソッドのごく一部である場合や、this 以外の特定のオブジェクトをロックとして使用したい場合に有効です。

構文は以下の通りです。

java
synchronized (ロックオブジェクト) {
// クリティカルセクション
// 共有リソースにアクセス・操作するコード
}

ここで指定する ロックオブジェクト は、任意のJavaオブジェクトです。このオブジェクトに関連付けられた組み込みロックが、同期化の対象となります。ブロック内のコードを実行するには、指定した ロックオブジェクト のロックを取得する必要があります。

例: カウンターを synchronized ブロックで保護する

“`java
class BlockSynchronizedCounter {
private int count = 0;
// 明示的にロック用のオブジェクトを用意するのが一般的
private final Object lock = new Object();

public void increment() {
    // count++ はクリティカルセクション
    synchronized (lock) {
        count++;
    }
    // 同期化が必要ない他の処理...
}

public int getCount() {
    return count;
}

}
“`

この例では、increment メソッド全体ではなく、count++ の行だけを synchronized(lock) ブロックで囲んでいます。複数のスレッドがこの increment メソッドを呼び出した場合、synchronized (lock) ブロックに入ることができるのは、lock オブジェクトのロックを取得できた一つのスレッドだけです。ロックが解放されると、待機していた他のスレッドの一つがロックを取得してブロックに入ります。

synchronized ブロックを使うメリットは、同期化の範囲(クリティカルセクション)を最小限に絞れることです。同期化の範囲が広いと、スレッドがロックを保持している時間が長くなり、他のスレッドの待ち時間が増加してプログラム全体の並行性が低下する可能性があります。必要な部分だけを同期化することで、性能への影響を抑えることができます。

また、synchronized ブロックは、this オブジェクトやクラスオブジェクト以外のオブジェクトをロックとして使用できるため、より柔軟な同期化制御が可能です。例えば、特定のデータ構造(例: ArrayList)を保護したい場合、そのデータ構造オブジェクト自体をロックとして使用できます。

“`java
import java.util.ArrayList;
import java.util.List;

class SharedList {
private List list = new ArrayList<>();
private final Object listLock = new Object(); // リスト保護用のロック

public void addItem(String item) {
    synchronized (listLock) { // リストへのアクセスを同期化
        list.add(item);
        System.out.println(Thread.currentThread().getName() + "が" + item + "を追加しました。");
    }
}

public int getSize() {
    synchronized (listLock) { // リストへのアクセスを同期化
        return list.size();
    }
}

}
“`

この例では、SharedList インスタンス全体ではなく、list オブジェクトへのアクセスを保護するために listLock という専用のオブジェクトを使用しています。このように特定のデータに関連付けられたオブジェクトをロックとして使用することは、ベストプラクティスの一つです。

第3章: synchronized の具体的な動作メカニズム – モニターとロック

synchronized キーワードがどのようにして相互排他を実現しているのか、その内部的なメカニズムを少し詳しく見てみましょう。Javaの synchronized は、モニター(Monitor) または組み込みロック(Intrinsic Lock) と呼ばれる仕組みに基づいています。

3.1 すべてのJavaオブジェクトが持つモニター

Javaでは、すべてのオブジェクト(インスタンス)とクラスオブジェクトは、それぞれ一つずつモニターを持っています。 このモニターが synchronized による同期化の対象となります。

synchronized (オブジェクト) の形式でブロックを使用する場合、指定した オブジェクト のモニターが使用されます。
synchronized インスタンスメソッドの場合、そのメソッドを呼び出したインスタンス (this) のモニターが使用されます。
synchronized staticメソッドの場合、そのメソッドが属するクラスオブジェクト (ClassName.class) のモニターが使用されます。

3.2 モニターの仕組み

各モニターは、概念的に以下の要素を持っています。

  1. エントリセット (Entry Set): ロックを取得しようとして、現在待機しているスレッドの集合です。
  2. オーナー (Owner): 現在そのモニターのロックを取得しているスレッドです。一度に一つのスレッドだけがオーナーになれます(ただし、後述の再入可能性を除く)。
  3. 待機セット (Wait Set): ロックを一度取得したが、特定の条件が満たされるのを待つために wait() メソッドを呼び出し、現在ロックを一時的に解放して待機しているスレッドの集合です。(これは wait(), notify(), notifyAll() と関連する機能で、後述します)

スレッドが synchronized ブロックやメソッドに入ろうとすると、そのスレッドは対応するオブジェクトのモニターのロックを取得しようと試みます。

  • ロックが取得可能(オーナーがいない)な場合: そのスレッドはロックを取得し、モニターのオーナーとなります。そして、synchronized ブロック/メソッド内のコードの実行を開始します。
  • ロックが既に他のスレッドに取得されている場合: そのスレッドはロックを取得できません。そのスレッドはエントリセットに追加され、ロックが解放されるまで「ブロックされた(Blocked)」状態で待機します。

スレッドが synchronized ブロック/メソッドの実行を完了(正常終了または例外発生)すると、そのスレッドは取得していたモニターのロックを解放します。ロックが解放されると、エントリセットで待機していたスレッドの中から一つ(または notifyAll() の場合はすべて)が起き上がり、再びロックの取得を試みます。ロックを取得できたスレッドが次のオーナーとなり、クリティカルセクションの実行を開始します。

この仕組みにより、synchronized された同じモニターに対しては、常に一つのスレッドだけがクリティカルセクションを実行することが保証され、相互排他が実現されます。

3.3 再入可能性 (Reentrancy)

Javaの組み込みロックは再入可能(Reentrant)です。これはどういうことでしょうか?

あるスレッドがすでに特定のオブジェクトのロックを取得しているとします。そのスレッドが、同じロックオブジェクトに対して再び synchronized された別のメソッドやブロックを呼び出しても、ロックを再度取得しようとしてブロックされることはありません。ロックの取得カウンターが増加するだけで、そのスレッドはそのまま実行を続けることができます。そして、そのスレッドがロックを保持しているすべての synchronized ブロック/メソッドから抜けるまで、ロックは解放されません。

例:

“`java
class ReentrantExample {
public synchronized void method1() {
System.out.println(Thread.currentThread().getName() + “: method1開始”);
method2(); // method1はすでにロックを取得している
System.out.println(Thread.currentThread().getName() + “: method1終了”);
}

public synchronized void method2() {
    System.out.println(Thread.currentThread().getName() + ": method2開始");
    // ここでも同じオブジェクト(this)のロックが必要だが、method1が保持しているのでブロックされない
    System.out.println(Thread.currentThread().getName() + ": method2終了");
}

}
“`

method1 を呼び出したスレッドは、まず ReentrantExample オブジェクトのロックを取得します。method1 の中で method2 を呼び出す際、method2synchronized されているため、同じオブジェクトのロックが必要です。しかし、呼び出し元のスレッドはすでにロックを保持しているため、ブロックされずにそのまま method2 に入ることができます。ロックのカウンターがインクリメントされます。method2 が終了するとカウンターがデクリメントされ、method1 が終了する際にカウンターが0に戻り、ロックが解放されます。

再入可能性は、デッドロック(後述)の発生を防ぐ上で非常に重要です。もし再入可能性がないと、スレッドが自身が既に取得しているロックを再度必要としただけでデッドロックが発生してしまう可能性があります。

第4章: synchronized のもう一つの効果 – 可視性 (Visibility)

synchronized キーワードの機能は相互排他だけではありません。Javaのマルチスレッドプログラミングにおいてもう一つ非常に重要な問題である可視性(Visibility)も保証します。

4.1 可視性の問題

Java仮想マシン(JVM)や基盤となるハードウェアは、プログラムの実行速度を向上させるために、様々な最適化を行います。その一つが、各スレッドが変数の値をメインメモリから読み込む代わりに、より高速なCPUキャッシュレジスタに一時的に保持して操作することです。

これにより、あるスレッドが共有変数の値を変更しても、別のスレッドがその変更をすぐには認識できないという問題が発生することがあります。別のスレッドは、古い値を保持しているキャッシュやレジスタから値を読んでしまう可能性があるからです。これが可視性の問題です。

例えば、以下のコードを考えます(synchronizedvolatile なし)。

“`java
class VisibilityExample {
private boolean flag = false;

public void setFlag() {
    flag = true;
    System.out.println("フラグを設定しました。");
}

public void runLoop() {
    // flagがtrueになるまで無限ループ
    while (!flag) {
        // 何もしない
    }
    System.out.println("ループを抜けました。");
}

}
“`

もし、setFlag() を呼ぶスレッドと runLoop() を呼ぶスレッドが別々に実行された場合、setFlag()flagtrue に変更しても、runLoop() を実行しているスレッドがその変更を認識できず、while (!flag) ループから抜けられない可能性があります。runLoop() スレッドが flag の古い値(false)をずっと自分のCPUキャッシュに保持し続けるかもしれないからです。

4.2 synchronized が保証する可視性

synchronized キーワードは、Javaメモリモデル(Java Memory Model: JMM)において、happens-before 関係を構築します。これは、特定のアクションの発生が、その後に発生する別のアクションから見て必ず可視であることを保証する概念です。

具体的には、synchronized は以下の2つの重要な可視性の保証を提供します。

  1. ロックの解放 (Monitor Exit) は、同じロックの取得 (Monitor Entry) よりも happens-before する: あるスレッドが synchronized ブロック/メソッドから正常に(または例外によって)終了し、ロックを解放する操作は、その後に別のスレッドが同じロックを取得する操作よりも happens-before します。
    • これは、「ロックを解放する前に、そのスレッドが行ったすべての書き込み(共有変数への変更など)は、メインメモリにフラッシュ(書き出し)される」ことを意味します。
  2. ロックの取得 (Monitor Entry) は、そのロックによって保護される後続の操作よりも happens-before する: あるスレッドが synchronized ブロック/メソッドに入り、ロックを取得する操作は、そのブロック/メソッド内でそのスレッドが行うすべての読み込みよりも happens-before します。
    • これは、「ロックを取得した後に、そのスレッドは共有変数の値を読む際に、古いキャッシュ値ではなく、メインメモリから最新の値を読み込む」ことを意味します。

したがって、複数のスレッドが同じオブジェクトのロックを使用して共有データにアクセスする場合、以下の流れが保証されます。

  1. Thread Aが synchronized ブロック/メソッドに入り、ロックを取得します。Thread Aはメインメモリから最新の共有変数の値を読み込みます。
  2. Thread Aが共有変数の値を変更します。
  3. Thread Aが synchronized ブロック/メソッドから出て、ロックを解放します。Thread Aが行った共有変数への変更はメインメモリに書き込まれます。
  4. 次にThread Bが同じ synchronized ブロック/メソッドに入り、ロックを取得します。Thread Bはロック取得時にメインメモリから共有変数の最新の値(Thread Aが書き込んだ値)を読み込みます。

このように、synchronized は相互排他だけでなく、共有変数に対する書き込みが他のスレッドから確実に読み取れる(見える)ことを保証する、重要な可視性の機能も持っています。クリティカルセクションを synchronized で保護することで、データ競合を防ぐと同時に、常に最新の共有データを使って処理を行えるようになるのです。

可視性の問題だけを解決したい場合は、volatile キーワードを使うという選択肢もありますが、volatile は相互排他は提供しません。synchronized は、相互排他と可視性の両方を必要とする場合に適しています。

第5章: wait(), notify(), notifyAll() との連携

synchronized キーワードは、Object クラスで定義されている wait(), notify(), notifyAll() メソッドと密接に関連しています。これらのメソッドは、スレッド間で通信を行い、特定の条件が満たされるまでスレッドを待機させたり、待機しているスレッドを再開させたりするために使用されます。

重要: wait(), notify(), notifyAll() メソッドは、必ず synchronized ブロックまたは synchronized メソッド内から呼び出さなければなりません。 これらのメソッドは、呼び出し元のスレッドが現在アクティブなオブジェクトのモニターを所有している(つまり、ロックを取得している)ことを前提としているからです。synchronized されていないコンテキストから呼び出すと、IllegalMonitorStateException がスローされます。

5.1 wait() メソッド

wait() メソッドを呼び出したスレッドは、以下の動作を行います。

  1. 現在取得しているロックを一時的に解放します。
  2. そのオブジェクトの待機セット(Wait Set)に移動し、そこで「目覚める」まで待機します。
  3. 他のスレッドによって notify() または notifyAll() が同じオブジェクトに対して呼び出され、そのスレッドが「目覚めた」後、再度ロックの取得を試みます。ロックが取得できたら、wait() が呼び出された場所の次の行から実行を再開します。

wait() を使うのは、スレッドがある処理を続行するために、特定の条件が満たされるのを待つ必要がある場合です。ロックを保持したまま待機すると、他のスレッドがロックを取得できず、条件を満たすための処理が進められなくなるため、wait() はロックを解放します。

5.2 notify() メソッド

notify() メソッドを呼び出したスレッドは、そのオブジェクトの待機セットで待機しているスレッドの中から、任意の一つを「目覚めさせ」ます。目覚めたスレッドは、ロックが解放されるのを待って、再びロックの取得を試みます。

5.3 notifyAll() メソッド

notifyAll() メソッドを呼び出したスレッドは、そのオブジェクトの待機セットで待機しているすべてのスレッドを「目覚めさせ」ます。目覚めたスレッドはすべて、ロックが解放されるのを待って、再びロックの取得を試みます。実際にロックを取得できるのは一度に一つのスレッドだけです。

5.4 wait()/notify() を使用する典型的なパターン

wait() / notify() (または notifyAll()) は、生産者-消費者問題のような、スレッド間で作業を分担し、特定の状態になるまで待機し、状態が変化したら知らせ合うようなシナリオでよく使用されます。

典型的な使用パターンは以下のようになります。

消費者スレッド:

java
synchronized (共有オブジェクト) {
while (条件が満たされていない場合) { // wait() は必ずループ内で呼び出す
共有オブジェクト.wait(); // ロックを解放し、待機セットで待機
}
// 条件が満たされたので処理を続行
// ... 共有オブジェクトを操作 ...
}

生産者スレッド:

java
synchronized (共有オブジェクト) {
// ... 共有オブジェクトの状態を変更し、条件を満たす ...
共有オブジェクト.notifyAll(); // または notify() で、待機しているスレッドに知らせる
// 共有オブジェクト.notify(); // notify()は特定の一つだけを再開
}

なぜ wait() はループ内で呼び出すべきか?

wait() から目覚めるのは、notify()notifyAll() による通知を受けた場合だけではありません。外部からの割り込みや、理由なく目覚める「スプリアスウェイクアップ (Spurious Wakeup)」が発生する可能性がJVMの仕様として許容されています。したがって、wait() から戻ってきた直後に再度条件を確認せず、そのまま処理を続行すると、まだ条件が満たされていないのに処理を開始してしまう可能性があります。これを防ぐために、wait() は必ず条件をチェックするループ(通常は while ループ)の中で呼び出すのが正しいパターンです。

wait(), notify(), notifyAll() は、synchronized によって提供されるロックと連携して、スレッド間の協調を可能にします。しかし、その正しい使用法は少し複雑であり、安易な使用はデッドロックや非効率な動作を引き起こす可能性があります。特に、どのオブジェクトのモニターをロックし、どのオブジェクトに対して wait/notify を呼び出すかを明確にすることが重要です(通常は、同期化の対象となっている同じオブジェクトに対してこれらのメソッドを呼び出します)。

第6章: synchronized の注意点と代替手段

synchronized はJavaの並行処理の基本ですが、使用上の注意点や、より高度な状況に適した代替手段も存在します。

6.1 synchronized の注意点

  • 性能オーバーヘッド: ロックの取得と解放にはコストがかかります。特に、複数のスレッドが頻繁に同じロックを奪い合う(ロックの競合 (Contention))場合、スレッドはロックが解放されるまでブロックされて待機する必要があるため、プログラム全体の実行性能が低下する可能性があります。クリティカルセクションは可能な限り小さく保つべきです。
  • デッドロック (Deadlock) の可能性: 複数のスレッドがそれぞれロックAとロックBを保持しており、互いに相手が持っているロックを待機している状態をデッドロックと呼びます。この状態に陥ると、どのスレッドも処理を進められなくなり、プログラムが停止してしまいます。synchronized を使って複数のロックを取得する場合、デッドロックが発生するリスクがあります。
    • 例: Thread 1がロックAを取得し、ロックBを待つ。同時にThread 2がロックBを取得し、ロックAを待つ。
    • デッドロックを防ぐ一般的な方法の一つは、複数のロックを取得する際に常に同じ順序でロックを取得するようにルールを設けることです。
  • モニタリングの難しさ: どのスレッドがどのロックを保持しているか、どのスレッドがどのロックで待機しているかなどを実行時に把握するのは、複雑なシステムでは困難になることがあります。
  • ロック対象の選択: synchronized(this) は手軽ですが、そのオブジェクトのパブリックなロックを使用することになるため、そのオブジェクトを使う他のクラスやメソッドが意図せずロックに関与してしまい、予期せぬ競合やデッドロックを引き起こすリスクがあります。特別な理由がない限り、同期化のためにはprivate で final な専用の Object インスタンスをロックオブジェクトとして使用することが推奨されます (private final Object lock = new Object();)。静的メソッドの場合は、クラスオブジェクト (ClassName.class) を使用します。
  • String リテラルやプリミティブラッパーのロック: synchronized ("someString") のようにStringリテラルをロックオブジェクトに使用するのは危険です。StringリテラルはJVMによってプールされるため、コード内の別の場所にある全く関係ない synchronized ("someString") ブロックと同じロックを共有してしまう可能性があります。プリミティブラッパー(Integer など)も同様です。ロックオブジェクトには、通常、新しく生成した専用の Object インスタンスを使用すべきです。

6.2 synchronized の代替手段

Java SE 5.0以降、java.util.concurrent (JUC) パッケージが導入され、より柔軟で高性能な並行処理ユーティリティが提供されています。多くの場合、これらの新しいAPIは synchronized よりも推奨されます。

主な代替手段としては以下のものがあります。

  • java.util.concurrent.locks.Lock インターフェース (特に ReentrantLock):
    • synchronized よりも柔軟なロック機構を提供します。
    • lock() / unlock() メソッドで明示的にロックの取得・解放を行います(finally ブロックでの解放が必須)。
    • ロックの取得を試みる (tryLock())、タイムアウト付きでロックを取得する、割り込み可能なロック取得などの機能があります。
    • Condition オブジェクトと組み合わせて、wait() / notify() よりも詳細なスレッド待機・通知メカニズムを提供します。
    • 特定の状況(例: 多数の読み取りと少数の書き込み)で性能が向上する可能性があります(ReentrantReadWriteLock など)。
  • アトミック変数 (java.util.concurrent.atomic パッケージ):
    • AtomicInteger, AtomicLong, AtomicReference など。
    • 単一の変数に対するインクリメント、デクリメント、比較・交換(Compare-And-Swap: CAS)などの基本的な操作を、ロックを使用せずにアトミック(不可分)に実行できます。
    • 単純なカウンターやフラグのような用途では、synchronized よりも高性能になることが多いです。
    • データ競合が発生しにくい、または単一操作のアトミック性だけで十分な場合に適しています。
  • 並行コレクション (java.util.concurrent パッケージ):
    • ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue など。
    • これらのコレクションクラスは、内部的にスレッドセーフになるように設計されています。
    • 必要に応じて内部で適切な同期化メカニズム(synchronizedLock、CASなど)を使用していますが、開発者は明示的に同期化コードを書く必要がありません。
    • 多くの場合、synchronized を使って ArrayListHashMap を保護するよりも、これらの並行コレクションを使った方が効率的で安全です。
  • Executor フレームワーク:
    • スレッドプールの管理や非同期タスクの実行を容易にする高レベルAPIです。
    • 低レベルなスレッド管理や同期化の多くをフレームワークが吸収してくれます。

これらの代替手段が登場した現在でも、synchronized はJavaにおける並行処理の基本的な概念であり、小規模な同期化や簡単なクリティカルセクションの保護には十分かつ簡潔な手段として広く使われています。JUCのAPIはより強力ですが、その分使い方も複雑になる場合があります。まずは synchronized をしっかりと理解することが、Javaの並行処理をマスターするための第一歩となります。

第7章: 実践的な例と演習

ここまで synchronized の基本的な使い方、メカニズム、可視性、wait/notify との連携、注意点、代替手段を見てきました。ここでは、これまで学んだことを踏まえて、より実践的な例を見ていきましょう。

例1: データ競合の再現と synchronized による解決

前に見たカウンターの例を、実際にマルチスレッドで実行し、データ競合が発生する様子を確認し、その後 synchronized で修正するコードを示します。

修正前のデータ競合が発生するコード:

“`java
class UnsynchronizedCounter {
private int count = 0;

public void increment() {
    count++; // ここでデータ競合が発生する可能性
}

public int getCount() {
    return count;
}

}

class CounterThread extends Thread {
private UnsynchronizedCounter counter;
private int incrementsPerThread;

public CounterThread(UnsynchronizedCounter counter, int incrementsPerThread, String name) {
    super(name);
    this.counter = counter;
    this.incrementsPerThread = incrementsPerThread;
}

@Override
public void run() {
    for (int i = 0; i < incrementsPerThread; i++) {
        counter.increment();
    }
    System.out.println(Thread.currentThread().getName() + " 終了.");
}

}

public class RaceConditionDemo {
public static void main(String[] args) throws InterruptedException {
UnsynchronizedCounter counter = new UnsynchronizedCounter();
int numberOfThreads = 1000;
int incrementsPerThread = 1000;
Thread[] threads = new Thread[numberOfThreads];

    for (int i = 0; i < numberOfThreads; i++) {
        threads[i] = new CounterThread(counter, incrementsPerThread, "Thread-" + i);
        threads[i].start();
    }

    // 全てのスレッドの終了を待つ
    for (int i = 0; i < numberOfThreads; i++) {
        threads[i].join();
    }

    // 最終的なカウンターの値を出力
    System.out.println("最終的なカウンターの値: " + counter.getCount());
    System.out.println("期待されるカウンターの値: " + (numberOfThreads * incrementsPerThread));
}

}
“`

このコードを実行すると、最終的なカウンターの値期待されるカウンターの値 (1,000,000) よりも小さくなることがほとんどです。これは、複数のスレッドが同時に counter.increment() にアクセスし、データ競合が発生しているためです。

synchronized メソッドによる修正:

UnsynchronizedCounter クラスの increment() メソッドに synchronized を付け加えるだけです。

“`java
class SynchronizedCounterMethod {
private int count = 0;

// メソッド全体を同期化
public synchronized void increment() {
    count++;
}

public int getCount() {
    return count;
}

}

// Mainクラスは上記 RaceConditionDemo の counter を SynchronizedCounterMethod に変更して実行
public class SynchronizedMethodDemo {
public static void main(String[] args) throws InterruptedException {
SynchronizedCounterMethod counter = new SynchronizedCounterMethod(); // ここを変更
int numberOfThreads = 1000;
int incrementsPerThread = 1000;
Thread[] threads = new Thread[numberOfThreads];

    for (int i = 0; i < numberOfThreads; i++) {
        // CounterThreadもSynchronizedCounterMethodを使うように修正
        threads[i] = new CounterThread(counter, incrementsPerThread, "Thread-" + i);
        threads[i].start();
    }

    // 全てのスレッドの終了を待つ
    for (int i = 0; i < numberOfThreads; i++) {
        threads[i].join();
    }

    // 最終的なカウンターの値を出力
    System.out.println("最終的なカウンターの値: " + counter.getCount());
    System.out.println("期待されるカウンターの値: " + (numberOfThreads * incrementsPerThread));
}

}

// CounterThread クラスは UnsynchronizedCounter ではなく、
// 引数で受け取るカウンター型に応じて修正が必要(ここでは簡単のため、Objectを受け取るか、
// 修正前のCounterThreadをSynchronizedCounterMethod版として別に定義する)
// 簡単化のため、CounterThreadクラスがGenericを受け取るか、別名で定義したとします。
// 例: class SynchronizedMethodCounterThread extends Thread { … }
// 上記の Main クラスでは、CounterThread を SynchronizedMethodCounterThread に読み替えてください。

// あるいは、CounterThread を以下のように修正し、汎用的にする:
class GenericCounterThread extends Thread {
private Object counter; // オブジェクトを受け取る
private Method incrementMethod; // incrementメソッドをリフレクションで取得するなどで対応
private int incrementsPerThread;

public GenericCounterThread(Object counter, Method incrementMethod, int incrementsPerThread, String name) {
    super(name);
    this.counter = counter;
    this.incrementMethod = incrementMethod;
    this.incrementsPerThread = incrementsPerThread;
}

@Override
public void run() {
    try {
        for (int i = 0; i < incrementsPerThread; i++) {
            incrementMethod.invoke(counter); // リフレクションで increment() を呼び出す
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    // System.out.println(Thread.currentThread().getName() + " 終了."); // 出力が多いと確認しにくいのでコメントアウト
}

}

// そして Main クラスを以下のように修正:
import java.lang.reflect.Method;

public class SynchronizedMethodDemo {
public static void main(String[] args) throws InterruptedException, NoSuchMethodException, IllegalAccessException {
SynchronizedCounterMethod counter = new SynchronizedCounterMethod();
int numberOfThreads = 1000;
int incrementsPerThread = 1000;
Thread[] threads = new Thread[numberOfThreads];

    // リフレクションで increment メソッドを取得
    Method incrementMethod = SynchronizedCounterMethod.class.getMethod("increment");

    for (int i = 0; i < numberOfThreads; i++) {
        threads[i] = new GenericCounterThread(counter, incrementMethod, incrementsPerThread, "Thread-" + i);
        threads[i].start();
    }

    // 全てのスレッドの終了を待つ
    for (int i = 0; i < numberOfThreads; i++) {
        threads[i].join();
    }

    // 最終的なカウンターの値を出力
    System.out.println("最終的なカウンターの値: " + counter.getCount());
    System.out.println("期待されるカウンターの値: " + (numberOfThreads * incrementsPerThread));
}

}
“`

上記の修正版を実行すると、最終的なカウンターの値期待されるカウンターの値 (1,000,000) と一致することが確認できるはずです。これは、increment() メソッドが synchronized されたことで、一度に一つのスレッドしかこのメソッドを実行できなくなり、データ競合が回避されたためです。

synchronized ブロックによる修正:

UnsynchronizedCounter クラスを synchronized ブロックで修正します。専用のロックオブジェクトを使用するのが推奨されます。

“`java
class SynchronizedCounterBlock {
private int count = 0;
private final Object lock = new Object(); // 専用のロックオブジェクト

public void increment() {
    // count++ の部分だけを同期化
    synchronized (lock) {
        count++;
    }
}

public int getCount() {
    return count;
}

}

// Mainクラスは上記 RaceConditionDemo と同様だが、
// CounterThread を SynchronizedCounterBlock に対応させる、あるいは GenericCounterThread を使用
public class SynchronizedBlockDemo {
public static void main(String[] args) throws InterruptedException, NoSuchMethodException, IllegalAccessException {
SynchronizedCounterBlock counter = new SynchronizedCounterBlock(); // ここを変更
int numberOfThreads = 1000;
int incrementsPerThread = 1000;
Thread[] threads = new Thread[numberOfThreads];

    // リフレクションで increment メソッドを取得
    Method incrementMethod = SynchronizedCounterBlock.class.getMethod("increment");


    for (int i = 0; i < numberOfThreads; i++) {
         // GenericCounterThread を使用
        threads[i] = new GenericCounterThread(counter, incrementMethod, incrementsPerThread, "Thread-" + i);
        threads[i].start();
    }

    // 全てのスレッドの終了を待つ
    for (int i = 0; i < numberOfThreads; i++) {
        threads[i].join();
    }

    // 最終的なカウンターの値を出力
    System.out.println("最終的なカウンターの値: " + counter.getCount());
    System.out.println("期待されるカウンターの値: " + (numberOfThreads * incrementsPerThread));
}

}
“`

こちらも同様に、期待される結果が得られるはずです。この例では synchronized ブロックの方がメソッド全体を同期化するよりもわずかに効率的かもしれません(ただし、この単純な例では差はほとんど分からないでしょう)。より複雑なメソッドで、共有データへのアクセスが一部に限られる場合は、ブロックによる同期化が有効です。

例2: wait() と notifyAll() を使った簡単な生産者-消費者

wait()notifyAll() を使って、一つのスレッドがデータを生成し(生産者)、別のスレッドがそれを消費する(消費者)簡単な例を示します。ここでは、共有リソースとして、上限のあるキューを考えます。キューが満杯なら生産者は待ち、空なら消費者は待ちます。

“`java
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;

class SharedQueue {
private final Queue queue = new LinkedList<>();
private final int capacity;
private final Object lock = new Object(); // 同期化用のロック

public SharedQueue(int capacity) {
    this.capacity = capacity;
}

// 生産者用のメソッド
public void produce(int item) throws InterruptedException {
    synchronized (lock) {
        // キューが満杯なら待機
        while (queue.size() == capacity) {
            System.out.println(Thread.currentThread().getName() + ": キュー満杯、待機します。");
            lock.wait(); // ロックを解放し待機
        }

        // キューに要素を追加
        queue.add(item);
        System.out.println(Thread.currentThread().getName() + ": " + item + " を生産しました。キューサイズ: " + queue.size());

        // 待機している可能性のある消費者に通知
        lock.notifyAll(); // notify() でも良いが、notifyAll()がより安全
    }
}

// 消費者用のメソッド
public int consume() throws InterruptedException {
    synchronized (lock) {
        // キューが空なら待機
        while (queue.isEmpty()) {
            System.out.println(Thread.currentThread().getName() + ": キュー空、待機します。");
            lock.wait(); // ロックを解放し待機
        }

        // キューから要素を取り出し
        int item = queue.remove();
        System.out.println(Thread.currentThread().getName() + ": " + item + " を消費しました。キューサイズ: " + queue.size());

        // 待機している可能性のある生産者に通知
        lock.notifyAll(); // notify() でも良いが、notifyAll()がより安全

        return item;
    }
}

}

class ProducerThread extends Thread {
private final SharedQueue queue;
private final int itemsToProduce;

public ProducerThread(SharedQueue queue, int itemsToProduce, String name) {
    super(name);
    this.queue = queue;
    this.itemsToProduce = itemsToProduce;
}

@Override
public void run() {
    Random random = new Random();
    for (int i = 0; i < itemsToProduce; i++) {
        int item = random.nextInt(100); // ランダムな整数を生産
        try {
            queue.produce(item);
            Thread.sleep(random.nextInt(50)); // 少し待機
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // 割り込みフラグを再設定
            break; // ループを終了
        }
    }
    System.out.println(Thread.currentThread().getName() + ": 生産完了.");
}

}

class ConsumerThread extends Thread {
private final SharedQueue queue;
private final int itemsToConsume;
private int consumedCount = 0;

public ConsumerThread(SharedQueue queue, int itemsToConsume, String name) {
    super(name);
    this.queue = queue;
    this.itemsToConsume = itemsToConsume;
}

@Override
public void run() {
    while (consumedCount < itemsToConsume) {
        try {
            queue.consume();
            consumedCount++;
            Thread.sleep(new Random().nextInt(100)); // 少し待機
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
    }
    System.out.println(Thread.currentThread().getName() + ": 消費完了. (合計 " + consumedCount + " 個)");
}

}

public class ProducerConsumerDemo {
public static void main(String[] args) throws InterruptedException {
SharedQueue queue = new SharedQueue(5); // 容量5のキュー

    ProducerThread producer1 = new ProducerThread(queue, 20, "Producer-1");
    ProducerThread producer2 = new ProducerThread(queue, 20, "Producer-2");
    ConsumerThread consumer1 = new ConsumerThread(queue, 20, "Consumer-1");
    ConsumerThread consumer2 = new ConsumerThread(queue, 20, "Consumer-2");

    producer1.start();
    producer2.start();
    consumer1.start();
    consumer2.start();

    // 全てのスレッドの終了を待つ
    producer1.join();
    producer2.join();
    consumer1.join();
    consumer2.join();

    System.out.println("デモ終了.");
}

}
“`

この例では、SharedQueue オブジェクトの lock を使って produce メソッドと consume メソッドを同期化しています。wait()notifyAll() を使うことで、キューの状態(満杯か空か)に応じて生産者スレッドと消費者スレッドが適切に待機・再開し、安全に協調して処理を進めていることが確認できます。

この例は、synchronized が単にデータ競合を防ぐだけでなく、スレッド間の複雑な協調動作を実現するための基盤としても機能することを示しています。

第8章: まとめ

この記事では、Javaにおける並行処理の基本である synchronized キーワードについて、その必要性から使い方、内部メカニズム、関連機能、注意点、そして代替手段までを詳細に解説しました。

重要なポイントをまとめます。

  • マルチスレッド環境では、複数のスレッドが共有データに同時にアクセスすることでデータ競合(Race Condition)が発生し、プログラムの正しさが損なわれる可能性があります。
  • synchronized キーワードは、相互排他(Mutual Exclusion)を実現し、クリティカルセクションへのアクセスを一度に一つのスレッドに制限することで、データ競合を防ぎます。
  • synchronized は、メソッドまたはコードブロックに対して使用できます。
    • synchronized メソッドは、インスタンスのロック(インスタンスメソッド)またはクラスのロック(staticメソッド)を使用します。
    • synchronized ブロックは、指定したオブジェクトのロックを使用し、より細かい範囲で同期化できます。
  • synchronized の内部では、すべてのJavaオブジェクトが持つ組み込みロック(モニター)が利用されます。スレッドはクリティカルセクションに入るためにこのロックを取得し、終了時に解放します。
  • Javaの組み込みロックは再入可能であり、スレッドが自身が既に保持しているロックを再度必要としてもデッドロックにはなりません。
  • synchronized は、相互排他だけでなく、スレッド間の可視性(Visibility)も保証します。ロックの解放は書き込みをメインメモリにフラッシュし、ロックの取得は最新の値をメインメモリから読み込むことを保証することで、共有データの変更が他のスレッドから正しく見えるようになります。
  • synchronized は、wait(), notify(), notifyAll() メソッドと組み合わせて使用することで、スレッド間の協調動作(条件が満たされるまで待機し、条件が満たされたら通知するなど)を実現するための基盤となります。これらのメソッドは必ず synchronized コンテキスト内から呼び出す必要があります。
  • synchronized には、性能オーバーヘッドやデッドロックの可能性といった注意点があります。クリティカルセクションは最小限に保ち、ロックオブジェクトの選択には注意が必要です。
  • 現代のJavaでは、java.util.concurrent パッケージに用意されている Lock、アトミック変数、並行コレクションなど、より高度で柔軟な代替手段が提供されています。しかし、synchronized はJava並行処理の基本的な概念として、依然として重要です。

synchronized を正しく理解し使用することは、マルチスレッド環境で発生する多くの問題を回避し、堅牢で信頼性の高いJavaアプリケーションを開発するための基本中の基本です。

最初は難しく感じるかもしれませんが、実際にコードを書いて動作を確認したり、様々なシナリオを想定したりすることで、徐々に理解が深まるはずです。この記事が、あなたのJava並行処理学習の確固たる土台となることを願っています。

次のステップとして、volatile キーワード、java.util.concurrent.locks パッケージ、Executor フレームワーク、スレッドプールなど、より高度なJava並行処理APIについて学んでいくことをお勧めします。

Happy Coding!


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール