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つのステップで実行されます。
- 現在の
count
の値をメモリからレジスタに読み込む。 - レジスタの値に1を加える。
- レジスタの新しい値をメモリに書き戻す。
データ競合が発生する可能性があるシナリオを見てみましょう。初期値 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にした後、最終的にcount
は 1
になります。しかし、それぞれのスレッドが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は読み込みを開始しなかったため、最終的にcount
は 2
になりました。
このように、データ競合が発生するコードは、実行するたびに結果が変わる非決定性を持っています。これはテストでバグを見つけにくく、運用環境で突然問題を引き起こす可能性があるため、マルチスレッドプログラミングにおいては絶対に避けなければなりません。
1.3 クリティカルセクション(Critical Section)
データ競合が発生する可能性がある、共有リソースにアクセス・操作するコードの領域をクリティカルセクション(Critical Section)と呼びます。上記のカウンターの例では、increment()
メソッド全体がクリティカルセクションと言えます。
スレッドセーフなコードを書くためには、このクリティカルセクションへのアクセスを制御する必要があります。具体的には、ある時点で一つのスレッドだけがクリティカルセクションを実行できるようにする必要があります。このような排他的なアクセス制御を相互排他(Mutual Exclusion)と呼びます。
synchronized
キーワードは、この相互排他を実現するためのJavaの基本的な仕組みです。
第2章: synchronized キーワードの基本
synchronized
キーワードの主な目的は、前述の相互排他を実現することです。つまり、複数のスレッドが同時にクリティカルセクションに入り込み、共有データを破壊してしまうのを防ぎます。
synchronized
は、特定のコードブロックやメソッドを「ロック」することで機能します。あるスレッドが synchronized
されたコードを実行している間、そのコードに関連付けられたロックを取得します。他のスレッドが同じロックを取得しようとしても、ロックが解放されるまで待機させられます。これにより、複数のスレッドが同時に同じクリティカルセクションを実行することがなくなるのです。
synchronized
キーワードは、主に以下の2つの方法で使用できます。
- synchronized メソッド: メソッド全体を同期化の対象とします。
- 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
) に関連付けられた組み込みロックを取得する必要があります。staticsynchronized
メソッドは、そのクラスのどのインスタンスに対しても、あるいはインスタンスがなくても、同時に一つしか実行できません。これはクラスレベルの共有データ(例: 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
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 モニターの仕組み
各モニターは、概念的に以下の要素を持っています。
- エントリセット (Entry Set): ロックを取得しようとして、現在待機しているスレッドの集合です。
- オーナー (Owner): 現在そのモニターのロックを取得しているスレッドです。一度に一つのスレッドだけがオーナーになれます(ただし、後述の再入可能性を除く)。
- 待機セット (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
を呼び出す際、method2
も synchronized
されているため、同じオブジェクトのロックが必要です。しかし、呼び出し元のスレッドはすでにロックを保持しているため、ブロックされずにそのまま method2
に入ることができます。ロックのカウンターがインクリメントされます。method2
が終了するとカウンターがデクリメントされ、method1
が終了する際にカウンターが0に戻り、ロックが解放されます。
再入可能性は、デッドロック(後述)の発生を防ぐ上で非常に重要です。もし再入可能性がないと、スレッドが自身が既に取得しているロックを再度必要としただけでデッドロックが発生してしまう可能性があります。
第4章: synchronized のもう一つの効果 – 可視性 (Visibility)
synchronized
キーワードの機能は相互排他だけではありません。Javaのマルチスレッドプログラミングにおいてもう一つ非常に重要な問題である可視性(Visibility)も保証します。
4.1 可視性の問題
Java仮想マシン(JVM)や基盤となるハードウェアは、プログラムの実行速度を向上させるために、様々な最適化を行います。その一つが、各スレッドが変数の値をメインメモリから読み込む代わりに、より高速なCPUキャッシュやレジスタに一時的に保持して操作することです。
これにより、あるスレッドが共有変数の値を変更しても、別のスレッドがその変更をすぐには認識できないという問題が発生することがあります。別のスレッドは、古い値を保持しているキャッシュやレジスタから値を読んでしまう可能性があるからです。これが可視性の問題です。
例えば、以下のコードを考えます(synchronized
や volatile
なし)。
“`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()
が flag
を true
に変更しても、runLoop()
を実行しているスレッドがその変更を認識できず、while (!flag)
ループから抜けられない可能性があります。runLoop()
スレッドが flag
の古い値(false
)をずっと自分のCPUキャッシュに保持し続けるかもしれないからです。
4.2 synchronized が保証する可視性
synchronized
キーワードは、Javaメモリモデル(Java Memory Model: JMM)において、happens-before 関係を構築します。これは、特定のアクションの発生が、その後に発生する別のアクションから見て必ず可視であることを保証する概念です。
具体的には、synchronized
は以下の2つの重要な可視性の保証を提供します。
- ロックの解放 (Monitor Exit) は、同じロックの取得 (Monitor Entry) よりも happens-before する: あるスレッドが
synchronized
ブロック/メソッドから正常に(または例外によって)終了し、ロックを解放する操作は、その後に別のスレッドが同じロックを取得する操作よりも happens-before します。- これは、「ロックを解放する前に、そのスレッドが行ったすべての書き込み(共有変数への変更など)は、メインメモリにフラッシュ(書き出し)される」ことを意味します。
- ロックの取得 (Monitor Entry) は、そのロックによって保護される後続の操作よりも happens-before する: あるスレッドが
synchronized
ブロック/メソッドに入り、ロックを取得する操作は、そのブロック/メソッド内でそのスレッドが行うすべての読み込みよりも happens-before します。- これは、「ロックを取得した後に、そのスレッドは共有変数の値を読む際に、古いキャッシュ値ではなく、メインメモリから最新の値を読み込む」ことを意味します。
したがって、複数のスレッドが同じオブジェクトのロックを使用して共有データにアクセスする場合、以下の流れが保証されます。
- Thread Aが
synchronized
ブロック/メソッドに入り、ロックを取得します。Thread Aはメインメモリから最新の共有変数の値を読み込みます。 - Thread Aが共有変数の値を変更します。
- Thread Aが
synchronized
ブロック/メソッドから出て、ロックを解放します。Thread Aが行った共有変数への変更はメインメモリに書き込まれます。 - 次に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()
メソッドを呼び出したスレッドは、以下の動作を行います。
- 現在取得しているロックを一時的に解放します。
- そのオブジェクトの待機セット(Wait Set)に移動し、そこで「目覚める」まで待機します。
- 他のスレッドによって
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
など。- これらのコレクションクラスは、内部的にスレッドセーフになるように設計されています。
- 必要に応じて内部で適切な同期化メカニズム(
synchronized
、Lock
、CASなど)を使用していますが、開発者は明示的に同期化コードを書く必要がありません。 - 多くの場合、
synchronized
を使ってArrayList
やHashMap
を保護するよりも、これらの並行コレクションを使った方が効率的で安全です。
- 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
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!