1. キーワード synchronized: なぜ必要で、どう使うか
Java におけるキーワード synchronized は、トイレのドアに掛かった「使用中!」の札のようなものです。1つのスレッドが「クリティカルセクション」の中にいる間、他のスレッドは順番を待ちます。先に入っているスレッドが出てはじめて、次のスレッドが入って処理を実行できます。
構文: ブロックとメソッド
同期化ブロック
synchronized (object) {
// クリティカルセクション
}
- object は、いわば「鍵を掛けたい」任意のオブジェクトです。あるスレッドがこのブロックを実行している間、同じオブジェクトでこのブロックに入りたい他のスレッドは待機します.
同期化メソッド
public synchronized void increment() {
// クリティカルセクション
}
- ここでは「鍵」はそのオブジェクト自身(this)に掛かります。つまり、そのオブジェクトの同期化メソッドは同時に1つのスレッドしか実行できません。
静的な同期化メソッド
public static synchronized void foo() {
// クリティカルセクション
}
- ここではロックは特定のオブジェクトではなくクラス(ClassName.class)に対して行われます。
内部ではどう動くか
スレッドが同期化されたブロックやメソッドに入ると、そのオブジェクト(静的メソッドならクラス)の「モニター」を獲得します。モニターがすでに占有されていれば、そのスレッドは待機します。モニターが解放されると、次のスレッドが入ることができます。
2. 例: 同期あり/なしのカウンターのインクリメント
同期なし
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最終値: " + counter.getCount());
}
}
期待される値: 2000
実際の値: より小さくなることがあります(例えば、1995、1987 など)。実行のたびに「サプライズ」が起こり得ます。
なぜか?それは操作 count++ が原子的ではないからです。これは3つの処理(値の読み取り、増加、書き戻し)に分かれます。2つのスレッドが同時にそれを行うと、互いの結果を上書きしてしまうことがあります。
解決策: synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
これで、一度に1つのスレッドだけがメソッド increment() を実行できます。最終値は常に 2000 になります。
代替案: 同期化ブロック
public class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
}
結果は同じです。メソッド全体ではなく、必要な部分だけを同期化できます。
3. 「オブジェクトモニター」入門
モニターとは、Java の各オブジェクトに組み込まれている「鍵」のことです。synchronized(object) と書くと、スレッドはそのオブジェクトを「施錠」しようとします。鍵が空いていれば取得でき、空いていなければ順番を待ちます。スレッドがブロックを抜けると、鍵は解放されます。
重要: 異なるオブジェクトで同期化している場合、スレッド同士は待ち合わせをしません。したがって、どのオブジェクトで同期化するかを正しく選ぶことが非常に大切です。
静的な同期化メソッド
共有資源がオブジェクトではなく、クラスの全インスタンスに共通のもの(例: 静的変数)の場合があります。そのときはクラスレベルで同期化する必要があります。
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static int getCount() {
return count;
}
}
これは次と同等です:
public static void increment() {
synchronized (StaticCounter.class) {
count++;
}
}
モニターは特定のインスタンスではなく、クラスのオブジェクト(Class)に掛かります。
4. キーワード volatile: 何で、何のためか
スレッド間の可視性の問題
Java では、各スレッドが性能向上のために変数の値をキャッシュすることがあります。つまり、あるスレッドが変数を変更しても、別のスレッドは自分のローカルキャッシュから読み続けて変更に「気付かない」可能性があります。これは、スレッド間の通知に用いるフラグなどで特に問題になります。
volatile はどう動くか
変数を volatile として宣言すると、次のことを意味します:
- すべてのスレッドが、その変数の読み書きをキャッシュを経由せず常に主記憶に対して行います。
- 変数の変更はすべてのスレッドから即座に可視になります。
ただし注意: volatile 自体は原子性を提供しません(boolean、int などの単純なプリミティブの読み書きを除く)。代入より複雑な処理を行うなら、同期化が必要です。
例: 終了フラグ
public class Worker extends Thread {
private volatile boolean running = true;
public void run() {
while (running) {
// 何か有用な処理をする
}
System.out.println("スレッドが終了しました");
}
public void shutdown() {
running = false;
}
}
Worker w = new Worker();
w.start();
// ... しばらくして
w.shutdown();
volatile を付けないと、スレッドがフラグの変更に「気付かず」無限ループに陥ることがあります(特にマルチコア環境)。volatile を付ければ、期待通りに動作します。
5. volatile の制約: 非原子性
初心者がよく誤解するのは「volatile な int にすれば count++ を気にせず書ける」という点ですが、残念ながらそうではありません:
private volatile int count = 0;
public void increment() {
count++;
}
誤りです! 操作 count++ は依然として原子的ではありません。これは3段階(1)読み取り、(2)増加、(3)書き戻しに分かれます。2つのスレッドが同じ値を同時に読み取ると、両方が増加し、同じ結果を書き戻してしまい、インクリメントが1回分「失われ」ます。
結論: volatile は変更の可視性だけを提供し、複雑な操作における競合状態を防いではくれません。
6. いつ synchronized を使い、いつ volatile を使うか
- volatile: 単純なフラグ(例えば boolean)で、1つのスレッドが書き、別のスレッドが読むだけのとき。例: スレッドの終了、イベントの通知。
- synchronized: 複雑な操作の原子性を確保する必要があるとき(例: インクリメント、複数の変数の更新、データ構造の操作)。
覚えやすい比較表
| シナリオ | volatile | synchronized |
|---|---|---|
| スレッド間でシグナルを伝える | ✔ | ✔ |
| 原子的な操作(インクリメント) | ✖ | ✔ |
| クリティカルセクションでの複数ステップ | ✖ | ✔ |
| 変更の可視性のみ | ✔ | ✔ |
7. synchronized と volatile のよくある誤り
誤り1: 間違ったオブジェクトで同期化している。 ローカル変数で同期したり、各スレッドで異なるオブジェクトに対して同期していると、保護になりません。
Object lock = new Object();
synchronized (lock) {
// ...
}
各スレッドがそれぞれ自分用の lock を作ってしまうと意味がありません。全スレッドで共有する、単一の同期ポイント(共通のオブジェクト)が必要です。
誤り2: volatile に原子性を期待してしまう。 volatile が保証するのは可視性であって原子性ではありません。count++ のような操作は、同期なしでは依然として安全ではありません。
誤り3: 広すぎる範囲を同期化してしまう。 メソッド全体を同期化しているが、必要なのは1行だけ、という場合に他のスレッドを無駄にブロックしてしまい性能を落とします。可能な限り「クリティカルセクション」を小さくしましょう。
誤り4: 静的データに対して「静的な」同期化を忘れる。 静的変数を使っているのに this に対して同期しても意味がありません。静的データにはクラスレベルでの同期が必要です: synchronized(ClassName.class)。
誤り5: 文字列リテラルで同期化してしまう。 文字列での同期は危険です。同じリテラルは JVM によってインターンされるため、プログラムの異なる部分で意図せず同じロックを共有してしまう可能性があります。
GO TO FULL VERSION