1. 序章

簡単に言えば、共有された可変状態は、並行性が関係している場合に非常に簡単に問題を引き起こします。 共有された可変オブジェクトへのアクセスが適切に管理されていない場合、アプリケーションはすぐに検出が困難な同時実行エラーを起こしやすくなります。

この記事では、同時アクセスを処理するためのロックの使用を再検討し、ロックに関連するいくつかの欠点を探り、最後に、代替手段としてアトミック変数を紹介します。

2. ロック

クラスを見てみましょう:

public class Counter {
    int counter; 
 
    public void increment() {
        counter++;
    }
}

シングルスレッド環境の場合、これは完全に機能します。 ただし、複数のスレッドの書き込みを許可するとすぐに、一貫性のない結果が得られ始めます。

これは、単純なインクリメント操作( counter ++ )が原因で、アトミック操作のように見えますが、実際には、値の取得、インクリメント、更新された値の書き戻しの3つの操作の組み合わせです。

2つのスレッドが同時に値を取得して更新しようとすると、更新が失われる可能性があります。

オブジェクトへのアクセスを管理する方法の1つは、ロックを使用することです。 これは、incrementメソッドシグネチャでsynchronizedキーワードを使用することで実現できます。 synchronized キーワードは、一度に1つのスレッドのみがメソッドに入ることができるようにします(ロックと同期の詳細については、 Javaの同期キーワードガイドを参照してください)。

public class SafeCounterWithLock {
    private volatile int counter;
 
    public synchronized void increment() {
        counter++;
    }
}

さらに、スレッド間の適切な参照の可視性を確保するために、volatileキーワードを追加する必要があります。

ロックを使用すると問題が解決します。 ただし、パフォーマンスは打撃を受けます。

複数のスレッドがロックを取得しようとすると、そのうちの1つが勝ち、残りのスレッドはブロックまたは一時停止されます。

スレッドを一時停止してから再開するプロセスは非常にコストがかかり、システムの全体的な効率に影響を与えます。

counter などの小さなプログラムでは、コンテキストの切り替えにかかる時間が実際のコード実行よりもはるかに長くなる可能性があるため、全体的な効率が大幅に低下します。

3. 不可分操作

同時環境用のノンブロッキングアルゴリズムの作成に焦点を当てた研究部門があります。 これらのアルゴリズムは、コンペアアンドスワップ(CAS)などの低レベルのアトミックマシン命令を利用して、データの整合性を確保します。

一般的なCAS操作は、次の3つのオペランドで機能します。

  1. 操作するメモリ位置(M)
  2. 変数の既存の期待値(A)
  3. 設定が必要な新しい値(B)

CAS操作は、Mの値をAにアトミックに更新しますが、Mの既存の値がAと一致する場合にのみ、それ以外の場合はアクションは実行されません。

どちらの場合も、Mの既存の値が返されます。 これは、値の取得、値の比較、および値の更新という3つのステップを1つのマシンレベルの操作に組み合わせたものです。

複数のスレッドがCASを介して同じ値を更新しようとすると、そのうちの1つが優先され、値が更新されます。 ただし、ロックの場合とは異なり、他のスレッドが中断されることはありません。 代わりに、値を更新できなかったことが通知されます。 その後、スレッドはさらに作業を進めることができ、コンテキストスイッチは完全に回避されます。

もう1つの結果は、コアプログラムロジックがより複雑になることです。 これは、CAS操作が成功しなかった場合のシナリオを処理する必要があるためです。 ユースケースに応じて、成功するまで何度も再試行することも、何もせずに先に進むこともできます。

4. Javaの原子変数

Javaで最も一般的に使用されるアトミック変数クラスは、 AtomicInteger AtomicLong AtomicBoolean 、およびAtomicReferenceです。 これらのクラスは、それぞれ int long boolean、、およびアトミックに更新可能なオブジェクト参照を表します。 これらのクラスによって公開される主なメソッドは次のとおりです。

  • get() –メモリから値を取得して、他のスレッドによって行われた変更を表示できるようにします。 volatile変数を読み取るのと同じです
  • set() –値をメモリに書き込み、変更が他のスレッドに表示されるようにします。 volatile変数を書き込むのと同じです
  • lazySet() –最終的に値をメモリに書き込み、後続の関連するメモリ操作で並べ替えられる場合があります。 ユースケースの1つは、ガベージコレクションのために参照を無効にすることです。これは、二度とアクセスされることはありません。 この場合、null volatile の書き込みを遅らせることで、パフォーマンスが向上します。
  • compareAndSet() –セクション3で説明したのと同じように、成功するとtrueを返し、それ以外の場合はfalseを返します。
  • weakCompareAndSet() –セクション3で説明したものと同じですが、順序付けの前に発生しないという意味で弱くなります。 これは、他の変数に対して行われた更新が必ずしも表示されない可能性があることを意味します。 Java 9 以降、このメソッドはすべてのアトミック実装で非推奨になり、 weakCompareAndSetPlain()が採用されました。 weakCompareAndSet()のメモリ効果は明白でしたが、その名前は揮発性メモリ効果を暗示していました。 この混乱を避けるために、彼らはこのメソッドを非推奨にし、 weakCompareAndSetPlain()または weakCompareAndSetVolatile()などの異なるメモリ効果を持つ4つのメソッドを追加しました。

AtomicIntegerで実装されたスレッドセーフカウンターを次の例に示します。

public class SafeCounterWithoutLock {
    private final AtomicInteger counter = new AtomicInteger(0);
    
    public int getValue() {
        return counter.get();
    }
    public void increment() {
        while(true) {
            int existingValue = getValue();
            int newValue = existingValue + 1;
            if(counter.compareAndSet(existingValue, newValue)) {
                return;
            }
        }
    }
}

ご覧のとおり、 compareAndSet 操作を再試行し、失敗したときに再試行します。これは、incrementalメソッドの呼び出しによって値が常に1ずつ増加することを保証するためです。

5. 結論

このクイックチュートリアルでは、ロックに関連する不利な点を回避できる同時実行を処理する別の方法について説明しました。 また、Javaのアトミック変数クラスによって公開される主なメソッドについても説明しました。

いつものように、例はすべてGitHub利用できます。

非ブロッキングアルゴリズムを内部的に使用するその他のクラスについては、ConcurrentMapのガイドを参照してください。