1. 共有リソースとは
共有リソースには既に出会っているはずです。家族が住む家は、その家族の共有リソースです。オフィスの冷蔵庫は、同僚全員の共有リソースです。イメージは掴めたでしょう。
プログラミングにおける共有リソースとは、複数のスレッドが同時にアクセスし得る変数・オブジェクト・データ構造のことです。例えば次のようなものがあります:
- 処理済み注文数を数えるカウンタ変数。
- あるスレッドが追加し、別のスレッドが処理するリクエストのリスト。
- 複数のスレッドが書き込む開かれたファイル。
- プログラムのさまざまな部分が利用するデータベース接続。
Java では、複数のスレッドからアクセスされ得るあらゆるオブジェクトや変数は、潜在的に「共有リソース」になります。
共有リソースの例: グローバルカウンタ
public class Counter {
public int value = 0;
}
複数のスレッドがこのカウンタを増やすと、同じ変数 value にアクセスすることになります。これが共有リソースです。
2. 同時アクセスの問題
単一スレッドのプログラムは簡単です。1つのスレッド=1人の実行者が、レールの上を走る列車のようにコードを進みます。ところが複数スレッドが登場した途端、まさに「剣の舞」のような状態になります。スレッド同士が思いもしない箇所で互いの仕事に干渉し合うのです。
Race condition(競合状態)
Race condition とは、プログラムの結果がスレッドの実行順序や「混ざり方」に依存してしまう状況です。つまり同じプログラムを複数回実行しても、結果が毎回異なるかもしれません——これはバグというより、マルチスレッド特有の「性質」です。
古典的な例: 2つのスレッドがカウンタを増やす
シンプルな状況を再現してみましょう。共有カウンタがあり、2つのスレッドがその値を1000回ずつ増やします。
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // 危険な箇所!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("期待値: 2000");
System.out.println("実際の値: " + counter.value);
}
}
画面には何が表示される?
時には 2000、時には 1985、時には 1937...。なぜでしょうか。なぜなら counter.value++ は原子的ではないからです! この操作は3つのステップから成ります:
- counter.value の現在値を読み取る。
- それを 1 増やす。
- 書き戻す。
2つのスレッドが同じ値を同時に読み取り、それぞれが増やしてから書き込むと、最終的に増加が1回分「失われ」ます。これが lost update(更新の消失)です。
不整合なオブジェクト状態
複数のフィールドから成る複雑なオブジェクトで、スレッドが同時に異なるフィールドを変更すると、オブジェクトが「変な」不整合状態になり得ます。たとえば口座残高は減ったのに、履歴が更新されない——顧客はパニック、経理は困惑、という具合です。
3. なぜ同期が必要か
同期 とは、プログラムに「待った、このコードは同時に1つのスレッドだけが実行すること!」と指示する仕組みです。他のスレッドは待機します。トイレのドアにある「清掃中! 立入禁止!」の札のようなものです。一人が中にいる間、他の人は外で待つ(そして長引けば心の中でため息をつく)わけです。
データ整合性の保証
カウンタを常に正しく増やしたいなら、複数のスレッドが同時にその値を変更することを禁じる必要があります。
例: カウンタ増加の同期
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
ここで 2つのスレッドが increment() を呼び出すと、その時点で実行できるのは常にどちらか一方だけです。もう一方は、先に入ったスレッドが終わるまで待機します。
図: 同期時に何が起きるか
+-------------------+
| Thread 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Thread 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Thread 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Thread N |/
+-------------------+
すべてのスレッドは保護されたコード区間の実行待ちの列に並びます。同時に「クリティカルセクション」(synchronized ブロック)内に入れるのは常に1スレッドだけです。
4. 同期手法の概要
Java の同期は1つの方法だけではありません。共有リソースへの同時アクセスから守るための「道具箱」です。
キーワード synchronized
Java の基本的な同期手段です。使い方は2通りあります:
同期化されたメソッド
public synchronized void increment() {
value++;
}
同期化されたブロック
public void increment() {
synchronized (this) {
value++;
}
}
ここで this はロック対象のオブジェクトです。あるスレッドがこのブロックを実行している間、同じオブジェクトで同期された同種のブロックに入りたい他のスレッドは待機します。
java.util.concurrent の特化クラス
- Lock、ReentrantLock — synchronized のより柔軟な代替。
- ReadWriteLock — 読み取りと書き込みのロックを分離。
- Semaphore — 同時にコードを実行できるスレッド数を制限。
- CountDownLatch、CyclicBarrier など — スレッド間の協調に使用。
重要: 今日は基礎の紹介だけです。これらのクラスは後ほど詳しく扱います。
5. 実例: マルチスレッドカウンタのアプリ
あるサービスへのユーザーアクセス数を集計するとします。各スレッドが1人のユーザーに対応し、共有カウンタをインクリメントします。
同期なし
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("期待値: 30000");
System.out.println("実際の値: " + counter.value);
}
}
結果: ほとんどいつも 30000 より小さくなります。ときには大幅に小さく! なぜならスレッド同士が「割り込んで」しまうからです。
同期: バグを修正
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("期待値: 30000");
System.out.println("実際の値: " + counter.value);
}
}
結果: いつも 30000。やった、同期が効いています!
6. 役立つ注意点
可視化: race condition はどう見えるか
2つのスレッドがどのようにインクリメントを「失う」か、簡単な表で示してみましょう:
| ステップ | Thread 1 | Thread 2 | value の値 |
|---|---|---|---|
| 1 | value=0 を読み取る | 0 | |
| 2 | value=0 を読み取る | 0 | |
| 3 | 1 に増やす | 0 | |
| 4 | 1 に増やす | 0 | |
| 5 | 1 を書き込む | 1 | |
| 6 | 1 を書き込む | 1 |
同期が必要なとき
同期は常に必要というわけではありません。変数が自分だけの小さな世界で生きていて、1つのスレッドしか扱わないなら心配は要りません。しかし一度でも他のスレッドと共有したら、同期は避けて通れません。「大丈夫だろう」と思っても——油断は禁物。レースコンディションは厄介で、長いあいだ潜伏し、最悪のタイミングで突然表面化します。
今後の予告: 他の同期手段
今日は基本ツールである synchronized のみを紹介しました。次回以降は次の内容を扱います:
- オブジェクトモニタの仕組みとロックの種類。
- 静的な同期メソッド(static + synchronized)。
- volatile キーワードの動作と用途。
- 同期のための現代的なクラス群(Lock、Semaphore など)。
7. 共有リソースでよくあるミス
エラー No.1: マルチスレッドを無視する。
最もよくあるミスの1つは、変数が複数スレッドからアクセスされ得ることを考慮しないことです。今は単一スレッドのプログラムでも、後で誰かがスレッドを追加すれば、バグが「どこからともなく」現れます。
エラー No.2: 同期不足または過剰な同期。
共有リソースへのアクセスを同期しないと、レースコンディションやデータ不整合を招きます。かといって何でもかんでも同期すると、ロックだらけでプログラムは息苦しくなり、遅くなります。本当に必要なところだけを同期するよう心がけましょう。
エラー No.3: 誤ったオブジェクトで同期する。
異なるオブジェクト(例: ローカル変数や文字列リテラル)で同期しても、共有リソースは守られません。すべてのスレッドが同じオブジェクトで同期する必要があります。
エラー No.4: 非原子的な操作に原子性を期待する。
i++ は原子的ではありません! たとえ変数を volatile として宣言しても、インクリメント自体が原子的になるわけではありません。この種の操作には同期が必要です。
エラー No.5: 「運よく、今は動いているから大丈夫」。
レースコンディションはあなたの開発機では表面化しなくても、本番サーバーやユーザー環境では必ず顔を出します。マルチスレッドの世界で「たまたま動いている」に頼ってはいけません!
GO TO FULL VERSION