この記事から得られる知識
- マルチスレッド環境におけるアトミック操作の重要性
synchronized
やvolatile
との違いとatomic
パッケージの優位性- ロックフリーを実現するCAS (Compare-And-Swap) 操作の基本的な仕組み
AtomicInteger
,AtomicLong
,AtomicReference
など主要なアトミッククラスの具体的な使い方- CASの潜在的な問題である「ABA問題」と、
AtomicStampedReference
による解決策 - 高競合環境下で高いパフォーマンスを発揮する
LongAdder
やLongAccumulator
の利用シーンと仕組み - Java 9以降で導入された、より低レベルで柔軟な
VarHandle
の概要
はじめに:なぜアトミック操作が必要なのか?
現代のアプリケーション開発において、マルチスレッドプログラミングはパフォーマンスを最大限に引き出すための重要な技術です。しかし、複数のスレッドが同時に共有データにアクセスすると、競合状態 (Race Condition) と呼ばれる問題が発生し、データの不整合や予期せぬ動作を引き起こす可能性があります。
例えば、複数のスレッドが共有のカウンター変数をインクリメントする `i++` という単純な操作を考えてみましょう。この操作は、実は単一の命令ではありません。
- 現在の値をメモリから読み込む
- 値を1増やす
- 新しい値をメモリに書き込む
という3つのステップに分かれています。もしスレッドAが1のステップを実行した直後に、スレッドBが1から3までの全ステップを実行してしまうと、スレッドAがその後に行う2と3のステップは、スレッドBの更新を上書きしてしまい、結果としてインクリメントが1回分しか反映されないという事態が発生します。
このような問題を解決するために、一連の操作が他のスレッドによって中断されることなく、ひとまとまりの処理として実行されることを保証するアトミック性(不可分性)が不可欠になります。Javaでは、このアトミック性を保証するための様々な仕組みが提供されています。
synchronizedやvolatileとの違い
アトミック性を保証する最も一般的な方法は `synchronized` キーワードを使うことです。`synchronized` は、メソッドやブロックをロックすることで、一度に一つのスレッドしかそのコード領域を実行できないようにします。これによりデータの整合性は保たれますが、ロックの取得と解放にはコストがかかり、競合が発生するとスレッドがブロックされてしまい、パフォーマンスの低下につながる可能性があります。
一方、`volatile` キーワードは、変数の可視性を保証します。つまり、あるスレッドがある `volatile` 変数を変更すると、その変更は即座に他のスレッドから見えるようになります。しかし、`volatile` は `i++` のような複合操作のアトミック性を保証するものではありません。
そこで登場するのが `java.util.concurrent.atomic` パッケージです。このパッケージは、ロックを使用せずに(ロックフリー)、スレッドセーフな操作を実現するためのクラス群を提供します。これにより、`synchronized` のような重い排他制御を避けつつ、高いパフォーマンスで安全なデータ更新が可能になります。
ロックフリーの心臓部:CAS (Compare-And-Swap) 操作
`atomic` パッケージのクラス群の多くは、CAS (Compare-And-Swap) と呼ばれる仕組みに基づいています。CASは、ハードウェアレベルでサポートされているアトミックな命令で、その名の通り「比較してから交換する」操作を不可分に行います。
CAS操作は、以下の3つのオペランド(引数)を取ります。
- V: 操作対象のメモリ上の値
- A: 期待される現在の値(古い値)
- B: 新しい値
その動作は、「もしメモリ上の値 V が、期待値 A と等しいならば、V を新しい値 B に更新する。そうでなければ何もしない」というものです。この一連の「比較と更新」がアトミックに行われるため、途中で他のスレッドが割り込むことはありません。操作が成功したか失敗したかは、通常、boolean値で返されます。
多くの `atomic` クラスでは、このCAS操作をループ処理の中で利用します。
// 擬似コード
do { // 1. 現在の値を取得する oldValue = get(); // 2. 新しい値を計算する newValue = calculateNewValue(oldValue); // 3. CAS操作で更新を試みる
} while (!compareAndSet(oldValue, newValue)); // 失敗したらリトライ
もし、ステップ3のCAS操作が実行される前に他のスレッドが値を更新していた場合、`compareAndSet` は失敗(falseを返す)します。その場合、ループは再度、最新の値を取得するところから処理をやり直します。これにより、ロックでスレッドを待たせることなく、常に最新のデータに基づいて更新を試みることができます。このような方法はオプティミスティックロック(楽観的ロック)とも呼ばれます。
主要なアトミッククラスの使い方
`java.util.concurrent.atomic` パッケージには、様々な用途に応じたクラスが用意されています。ここでは主要なものをいくつか紹介します。
基本的なプリミティブ型ラッパー
`AtomicInteger`, `AtomicLong`, `AtomicBoolean` は、それぞれ `int`, `long`, `boolean` 型の値をアトミックに操作するためのクラスです。これらは、スレッドセーフなカウンターやフラグとして非常によく利用されます。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterExample { public static void main(String[] args) throws InterruptedException { // AtomicIntegerを使用してカウンタを初期化 AtomicInteger atomicCounter = new AtomicInteger(0); ExecutorService executor = Executors.newFixedThreadPool(10); // 10個のスレッドがそれぞれ1000回インクリメント処理を行う for (int i = 0; i < 10; i++) { executor.submit(() -> { for (int j = 0; j < 1000; j++) { // アトミックなインクリメント atomicCounter.incrementAndGet(); } }); } executor.shutdown(); executor.awaitTermination(1, TimeUnit.MINUTES); // 最終的な結果は必ず10000になる System.out.println("Final Count: " + atomicCounter.get()); // Final Count: 10000 }
}
上記の例では、`incrementAndGet()` メソッドがアトミックなため、`synchronized` を使わなくても複数のスレッドからのインクリメントが正しくカウントされます。
主なメソッド
メソッド | 説明 |
---|---|
get() | 現在の値を取得します。 |
set(newValue) | 値をアトミックに設定します。 |
getAndSet(newValue) | 新しい値を設定し、設定前の古い値を返します。 |
compareAndSet(expect, update) | 現在の値が `expect` と同じであれば、値を `update` に設定します。成功すれば `true` を返します。CAS操作の基本となるメソッドです。 |
incrementAndGet() | 値を1増やし、増やした後の新しい値を返します。 |
getAndIncrement() | 値を1増やし、増やす前の古い値を返します。 |
decrementAndGet() | 値を1減らし、減らした後の新しい値を返します。 |
getAndDecrement() | 値を1減らし、減らす前の古い値を返します。 |
addAndGet(delta) | 指定された値 `delta` を加え、加算後の新しい値を返します。 |
getAndAdd(delta) | 指定された値 `delta` を加え、加算前の古い値を返します。 |
オブジェクト参照のアトミックな更新 (`AtomicReference`)
`AtomicReference` は、オブジェクトの参照をアトミックに更新するためのクラスです。プリミティブ型だけでなく、任意のオブジェクトをスレッドセーフに扱いたい場合に使用します。
重要な注意点として、`AtomicReference` は参照そのもののアトミック性を保証するだけで、参照しているオブジェクトの内部状態までをスレッドセーフにするわけではありません。そのため、参照するオブジェクトはイミュータブル(不変)にするか、あるいはそのオブジェクト自体がスレッドセーフである必要があります。
// 状態を表すイミュータブルなクラス
class Status { private final String state; public Status(String state) { this.state = state; } public String getState() { return state; }
}
public class AtomicReferenceExample { private final AtomicReference<Status> statusRef = new AtomicReference<>(new Status("INITIAL")); public void updateStatus(String newState) { Status currentStatus; Status newStatus; do { currentStatus = statusRef.get(); // 新しい状態オブジェクトを作成 newStatus = new Status(newState); // 現在の状態が変更されていなければ、新しい状態に更新 } while (!statusRef.compareAndSet(currentStatus, newStatus)); } public String getCurrentState() { return statusRef.get().getState(); }
}
配列要素のアトミックな更新
`AtomicIntegerArray`, `AtomicLongArray`, `AtomicReferenceArray` は、配列の各要素をアトミックに操作するためのクラスです。配列全体をロックするのではなく、特定のインデックスの要素だけをアトミックに更新したい場合に便利です。
これらのクラスは、コンストラクタに渡された元の配列をコピーして内部に保持します。そのため、元の配列への変更は `Atomic*Array` には反映されません。
import java.util.concurrent.atomic.AtomicIntegerArray;
public class AtomicArrayExample { // 5つのページに対するアクセス数を保持するカウンタ配列 private final AtomicIntegerArray accessCounters = new AtomicIntegerArray(5); public void incrementAccessCount(int pageIndex) { if (pageIndex >= 0 && pageIndex < accessCounters.length()) { // 指定されたインデックスの要素をアトミックにインクリメント accessCounters.incrementAndGet(pageIndex); } } public int getAccessCount(int pageIndex) { if (pageIndex >= 0 && pageIndex < accessCounters.length()) { return accessCounters.get(pageIndex); } return -1; }
}
CASの落とし穴:ABA問題とその対策
CASは非常に強力ですが、見落としがちな問題点が存在します。それがABA問題です。
ABA問題は、あるスレッドがCAS操作を行おうとしている間に、別のスレッドが値を A → B → A と変更してしまうケースで発生します。
- スレッド1が共有変数の値「A」を読み取る。
- スレッド1は、読み取った「A」を元に新しい値を計算する。(この間にコンテキストスイッチが発生)
- スレッド2が介入し、共有変数の値を「A」から「B」に変更する。
- スレッド2はさらに処理を続け、共有変数の値を「B」から元の「A」に戻す。
- スレッド1の処理が再開され、CAS操作を実行する。この時、共有変数の値は「A」なので、期待値「A」との比較に成功してしまい、更新が実行される。
スレッド1は値が変更されていないと判断しましたが、実際には一度別の値に変更されています。多くのシナリオではこれは問題になりませんが、例えば、一度リストから削除されたノードが再利用されて同じメモリアドレスでリストに追加されるような場合、深刻なバグを引き起こす可能性があります。
対策:バージョン管理による解決策 (`AtomicStampedReference`)
このABA問題を解決するのが `AtomicStampedReference` です。このクラスは、オブジェクト参照に加えて「スタンプ」と呼ばれる整数のバージョン情報を一緒に管理します。
`compareAndSet` メソッドは、参照とスタンプの両方が期待値と一致した場合にのみ更新を成功させます。
import java.util.concurrent.atomic.AtomicStampedReference;
public class AbaSolutionExample { // 値 "A" と初期スタンプ 0 で初期化 private final AtomicStampedReference<String> stampedRef = new AtomicStampedReference<>("A", 0); public void updateValue(String expectedValue, String newValue) { int[] stampHolder = new int; String currentValue = stampedRef.get(stampHolder); int currentStamp = stampHolder; // 新しいスタンプを生成 (現在のスタンプ + 1) int newStamp = currentStamp + 1; // 値とスタンプの両方を比較して更新 if (stampedRef.compareAndSet(expectedValue, newValue, currentStamp, newStamp)) { System.out.println("Update successful!"); } else { System.out.println("Update failed. Value or stamp was changed."); } }
}
値が A → B → A と変更された場合、スタンプ(バージョン)も 0 → 1 → 2 のように変化します。そのため、古いスタンプを持つスレッドのCAS操作は失敗し、ABA問題を確実に防ぐことができます。
同様のクラスとして `AtomicMarkableReference` もあります。こちらはバージョン番号の代わりに `boolean` 型のマークを管理し、「変更されたか否か」だけを追跡したい場合に利用できます。
高競合下でのパフォーマンス向上 (`LongAdder`)
`AtomicLong` は非常に便利ですが、非常に多くのスレッドが一斉に更新をかける高競合な状況では、パフォーマンスが低下する可能性があります。なぜなら、多くのスレッドが同時にCAS操作を行うと、ほとんどのスレッドが失敗し、成功するまでループでリトライを繰り返すことになるからです。これによりCPUリソースが浪費されてしまいます。
この問題を解決するためにJava 8で導入されたのが `LongAdder` と `DoubleAdder` です。
`LongAdder` の基本的なアイデアは、競合の分散です。内部的には、単一のlong値を持つのではなく、値の配列(セルの配列)を持っています。スレッドが値を加算しようとするとき、`LongAdder` はスレッドをハッシュ化し、異なるセルに更新を割り振ろうとします。これにより、複数のスレッドが同時に異なるセルを更新できるようになり、CASの衝突が劇的に減少します。
合計値が必要な場合は `sum()` メソッドを呼び出します。このメソッドは、ベースとなる値と全てのセルの値を合計して返します。ただし、`sum()` の計算中に他のスレッドが値を更新している可能性があるため、このメソッドが返す値は厳密な意味でアトミックではありません。しかし、統計情報の収集など、ある時点での正確なスナップショットが不要なケースでは非常に高速かつ効果的です。
LongAdder vs AtomicLong
- 低競合時: `AtomicLong` の方がわずかに高速な場合があります。
- 高競合時: `LongAdder` の方がスループットが大幅に向上します。
- 機能: `AtomicLong` は豊富なCASベースの操作を提供しますが、`LongAdder` は加算系の操作に特化しています (`add()`, `increment()`, `decrement()`)。
- メモリ: `LongAdder` は内部的にセルの配列を保持するため、`AtomicLong` よりも多くのメモリを消費します。
結論として、多数のスレッドから頻繁に更新されるカウンターのような用途では、`LongAdder` が最適な選択肢となります。
より汎用的な `LongAccumulator`
`LongAdder` は加算に特化していましたが、Java 8ではさらに汎用的な `LongAccumulator` も導入されました。これは、コンストラクタで指定された二項演算子 (`LongBinaryOperator`) を使って値を累積します。例えば、最大値や最小値を保持したり、乗算を行ったりといったカスタムの累積処理を実装できます。
Java 9以降の進化: `VarHandle`
Java 9では `java.lang.invoke.VarHandle` という、より低レベルで強力なAPIが導入されました。`VarHandle` は、フィールドや配列要素への参照をオブジェクトとして表現し、それに対してCAS操作、volatileアクセス(メモリフェンス)、順序付けられたアクセスなど、様々なメモリ操作を統一的な方法で実行できるようにするものです。
実は、`atomic` パッケージのクラスの多くは、Java 9以降、内部実装としてこの `VarHandle` を使用するように変更されています。これにより、従来 `sun.misc.Unsafe` という非公開APIに依存していた部分が、公式にサポートされた標準APIで置き換えられ、より安全で移植性の高い実装になりました。
アプリケーション開発者が直接 `VarHandle` を使う機会は少ないかもしれませんが、ライブラリやフレームワークの開発者にとっては、よりきめ細かく、かつ高性能な並行処理を実装するための強力なツールとなります。`atomic` パッケージの背後にある技術的な進化として、その存在を知っておくことは有益です。
まとめ
`java.util.concurrent.atomic` パッケージは、Javaにおける高性能な並行プログラミングを実現するための必須ツールキットです。ロックによるパフォーマンス低下を回避し、スケーラブルなアプリケーションを構築する上で中心的な役割を果たします。
クラスの使い分けまとめ
- 基本的なカウンターやフラグ: `AtomicInteger`, `AtomicLong`, `AtomicBoolean`
- オブジェクト参照の更新: `AtomicReference` (イミュータブルなオブジェクトと組み合わせるのがベスト)
- 配列要素の更新: `AtomicIntegerArray`, `AtomicLongArray`, `AtomicReferenceArray`
- ABA問題を避けたい場合: `AtomicStampedReference` (バージョン管理付き)
- 高競合なカウンター: `LongAdder` (統計収集などに最適)
- カスタムの累積処理: `LongAccumulator`
これらのクラスを適切に使い分けることで、アプリケーションの安全性とパフォーマンスを両立させることができます。マルチスレッドプログラミングに挑戦する際は、まずこの `atomic` パッケージの理解から始めることを強くお勧めします。