1. なぜ i++ はマルチスレッドで動かないのか?
定番の課題から始めましょう。処理済みリクエスト数やダウンロードされたファイル数などを表すカウンター変数があります。複数のスレッドでこのカウンターを増やしたい。単に i++ と書くだけでは何が問題になるでしょうか?
例: インクリメントでのデータ競合
public class Counter {
public int count = 0;
public void increment() {
count++; // アトミックではない!
}
}
2つのスレッドが同時に increment() を呼ぶとします。両方のスレッドが古い値を読み取り、両方がそれを 1 増やし、そして両方が…同じ新しい値を書き戻します。その結果、どちらか一方のインクリメントが「失われ」ます。これを何度も繰り返すと、最終値は期待より小さくなります。
なぜこうなるのでしょうか?
操作 i++ は実際には3つのステップから成ります:
- 変数の値を読み取る(例: 5)。
- その値を 1 増やす。
- 新しい値をメモリに書き戻す。
ところがマルチスレッド環境では、これらのステップの間に別のスレッドが変数を変更してしまう可能性があります。結果は「データ競合」(race condition)です。
アトミック操作とは?
アトミック操作とは、途中で分割されず、完全に実行されるかまったく実行されないかのどちらかであり、他のスレッドがその途中に割り込めない操作のことです。
Java には、そのような操作をプリミティブや参照に対して提供するクラス群があります。これらはパッケージ java.util.concurrent.atomic にあります。代表的なもの:
- AtomicInteger — 整数のアトミック型。
- AtomicLong — アトミックな long。
- AtomicBoolean — アトミックな boolean。
- AtomicReference<T> — 任意型オブジェクトへのアトミック参照。
2. AtomicInteger: スレッドセーフなカウンター
宣言と基本的な使い方
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // アトミックに増加
}
public int get() {
return count.get();
}
}
ここでは incrementAndGet() が「増やして新しい値を返す」を不可分な 1 回の操作として実行します。たとえ 100 本のスレッドが同時にこのメソッドを呼んでも、インクリメントが失われることはありません。
便利なメソッド:
| メソッド | 説明 |
|---|---|
|
現在の値を取得する |
|
値を設定する |
|
1 増やして新しい値を返す |
|
現在の値を返してから 1 増やす |
|
delta を加えて新しい値を返す |
|
現在値が expect と等しければ update を設定する(CAS) |
例: マルチスレッド・カウンター
たとえば、チャットで処理したメッセージ数を数えるクラスがあるとします。
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("メッセージ総数: " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
内部: AtomicInteger はどう動く?
内部的には AtomicInteger は CPU の特別な命令である CAS(Compare-And-Swap、比較して置換)を使います。これは変数の現在値を期待値と比較し、一致すれば新しい値を書き込みます。別スレッドが先に変更していた場合は操作が実行されず、再試行されます。
動作の流れ:
1. 現在値を読む(例: 5)
2. 期待値(5)と比較する
3. 一致すれば新しい値(6)を書き込む
4. 一致しなければ再試行する
すべてが非常に高速かつロックなし(lock‑free)で行われます。したがって、とくにスレッド数が多い場合は、アトミック系クラスのほうが synchronized より高速なことがよくあります。
3. AtomicReference: オブジェクトへのアトミック参照
AtomicReference<T> は任意のオブジェクトを保持できる汎用的なアトミックコンテナです。複数スレッドから安全に参照を更新できます。
例: 参照のスレッドセーフな更新
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
compareAndSet の利用
最も重要な操作は compareAndSet(expected, newValue) です。直前に読んだ時点から値が変わっていない場合にだけ更新できます。
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("更新に成功しました!");
} else {
System.out.println("すでに誰かが値を変更しました。もう一度お試しください.");
}
}
これは非ブロッキングアルゴリズムの基盤であり、余計なロックを避けたいキューやスタック、キャッシュなどで重要です。
4. アプリケーションでの使用例
例1: メッセージのマルチスレッド・カウンター
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... メッセージの処理 ...
int count = messageCount.incrementAndGet();
System.out.println("新しいメッセージ: " + message + ". メッセージ総数: " + count);
}
}
例2: 最後のメッセージ参照の安全な更新
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... 処理 ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
同時更新による「取りこぼし」を避けるため、最後のメッセージが変わっていない場合にだけ参照を更新したいときは、compareAndSet を使いましょう。
5. 制約と落とし穴
アトミッククラスが万能でないとき
アトミック変数はインクリメント、値の設定、チェックして置換といった単純な操作に向いています。しかし 複数 の変数を同時に更新したい場合、アトミック性は保証されません。たとえば 2 つのカウンターを 1 つの操作として同時に増やしたいなら、synchronized などの同期機構が必要です。
誤った使い方の例
// アトミックではない!
if (ref.get() == null) {
ref.set("Hello");
}
get() と set(...) の間に別スレッドが値を変更してしまう可能性があり、条件はもはや正しくありません。こうしたケースでは compareAndSet を使いましょう。
アトミッククラス ≠ スレッドセーフなオブジェクト
AtomicReference が指すオブジェクト自体がスレッドセーフでない場合、参照の差し替えはアトミックでも、オブジェクトのフィールド変更はアトミックでもスレッドセーフでもありません。たとえば AtomicReference<List<String>> に通常の ArrayList を入れても、そのリスト自体が thread‑safe になるわけではありません。
6. さらに進んだアトミッククラス
パッケージ java.util.concurrent.atomic には他にも有用なクラスがあります:
- AtomicLong、AtomicBoolean — long と boolean 用。
- AtomicIntegerArray、AtomicReferenceArray — 配列に対するアトミック操作。
- LongAdder、LongAccumulator — 高負荷なカウンター向け。
LongAdder と LongAccumulator
スレッド数が非常に多く、通常の AtomicInteger が「ボトルネック」(すべてのスレッドが 1 つの変数を取り合う)になるなら、LongAdder を使いましょう。これはカウンターを複数の内部セルに分散し、値を取得する時に合算するため、高い競合下で有利になります。
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. アトミック変数でよくある誤り
誤り1: 複雑な操作までアトミックだと思い込む。
値に対して複数の処理を行う場合、アトミッククラスだけでは不十分です — ステップの合間に別スレッドがデータを変更し得ます。複合操作には compareAndSet か同期を使いましょう。
誤り2: 入れ子のオブジェクトの thread‑safety を無視する。
AtomicReference に通常のオブジェクトを入れても、そのメソッドやフィールドがスレッドセーフになるわけではありません。アトミックなのは参照の差し替えだけです。
誤り3: 不要なのにアトミッククラスを使う。
単一スレッドのコードではアトミック型は過剰で、追加のチェックが入る分だけ通常の変数よりわずかに遅くなります。
誤り4: 早すぎる最適化。
ときには synchronized を使うほうが簡単で信頼できます。特に複雑で複数の変数にまたがるロジックでは、常に lock‑free の実装が最適とは限りません。
誤り5: ABA 問題を忘れる。
まれですが重要なケースとして、値が A から B へ、そして再び A に戻ると、compareAndSet は「変わっていない」とみなしてしまいます。こうしたシナリオには AtomicStampedReference(または AtomicMarkableReference)のような専用クラスを使いましょう。
GO TO FULL VERSION