1. 序章

このチュートリアルでは、Javaで最も一般的な同時実行の問題のいくつかを見ていきます。 また、それらを回避する方法とその主な原因についても学習します。

2. スレッドセーフオブジェクトの使用

2.1. オブジェクトの共有

スレッドは、主に同じオブジェクトへの共有アクセスによって通信します。 そのため、オブジェクトが変更されているときにオブジェクトから読み取ると、予期しない結果が生じる可能性があります。 また、オブジェクトを同時に変更すると、オブジェクトが破損した状態または一貫性のない状態のままになる可能性があります。

このような同時実行の問題を回避し、信頼できるコードを構築する主な方法は、不変オブジェクトを操作することです。 これは、複数のスレッドの干渉によって状態を変更できないためです。

ただし、不変オブジェクトを常に処理できるとは限りません。 このような場合、可変オブジェクトをスレッドセーフにする方法を見つける必要があります。

2.2. コレクションをスレッドセーフにする

他のオブジェクトと同様に、コレクションは内部で状態を維持します。 これは、複数のスレッドがコレクションを同時に変更することによって変更される可能性があります。 したがって、マルチスレッド環境でコレクションを安全に操作する1つの方法は、コレクションを同期することです

Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());

一般に、同期は相互排除を実現するのに役立ちます。 より具体的には、これらのコレクションには一度に1つのスレッドのみがアクセスできます。したがって、コレクションを不整合な状態のままにすることを回避できます。

2.3. スペシャリストマルチスレッドコレクション

次に、書き込みよりも読み取りが必要なシナリオを考えてみましょう。 同期されたコレクションを使用すると、アプリケーションのパフォーマンスに大きな影響を与える可能性があります。 2つのスレッドがコレクションを同時に読み取りたい場合、一方は他方が終了するまで待機する必要があります。

このため、Javaは、複数のスレッドから同時にアクセスできるCopyOnWriteArrayListConcurrentHashMapなどの同時コレクションを提供します。

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();

CopyOnWriteArrayList は、追加や削除などの変更操作のために、基になる配列の個別のコピーを作成することにより、スレッドセーフを実現します。 Collections.synchronizedListよりも書き込み操作のパフォーマンスは劣りますが、は、書き込みよりも大幅に多くの読み取りが必要な場合に、パフォーマンスが向上します。

ConcurrentHashMap は基本的にスレッドセーフであり、スレッドセーフではないMapCollections.synchronizedMapラッパーよりもパフォーマンスが高くなります。 これは実際にはスレッドセーフマップのスレッドセーフマップであり、子マップでさまざまなアクティビティを同時に実行できます。

2.4. スレッドセーフでないタイプでの作業

SimpleDateFormat などの組み込みオブジェクトを使用して、日付オブジェクトを解析およびフォーマットすることがよくあります。 SimpleDateFormat クラスは、操作の実行中に内部状態を変更します。

それらはスレッドセーフではないため、非常に注意する必要があります。 競合状態などが原因で、マルチスレッドアプリケーションでそれらの状態が不整合になる可能性があります。

では、どうすれば SimpleDateFormat を安全に使用できるでしょうか? いくつかのオプションがあります。

  • 使用するたびにSimpleDateFormatの新しいインスタンスを作成します
  • を使用して作成されるオブジェクトの数を制限します ThreadLocal 物体。 これにより、各スレッドがSimpleDateFormatの独自のインスタンスを持つことが保証されます。
  • synchronized キーワードまたはロックを使用して、複数のスレッドによる同時アクセスを同期します

SimpleDateFormatはこの一例にすぎません。 これらの手法は、スレッドセーフではないタイプであればどれでも使用できます。

3. 競合状態

競合状態は、2つ以上のスレッドが共有データにアクセスし、それらが同時にデータを変更しようとしたときに発生します。したがって、競合状態は実行時エラーまたは予期しない結果を引き起こす可能性があります。

3.1. 競合状態の例

次のコードを考えてみましょう。

class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

Counter クラスは、incrementメソッドを呼び出すたびにcounterに1が加算されるように設計されています。 ただし、 Counter オブジェクトが複数のスレッドから参照されている場合、スレッド間の干渉により、これが期待どおりに行われない可能性があります。

counter++ステートメントは次の3つのステップに分解できます。

  • counterの現在の値を取得します
  • 取得した値を1つインクリメントします
  • インクリメントされた値をcounterに保存します

ここで、thread1thread2の2つのスレッドが同時にincrementメソッドを呼び出すと仮定します。 それらのインターリーブされたアクションは、次のシーケンスに従う可能性があります。

  • thread1counterの現在の値を読み取ります。 0
  • thread2counterの現在の値を読み取ります。 0
  • thread1 は、取得した値をインクリメントします。 結果は1です
  • thread2 は、取得した値をインクリメントします。 結果は1です
  • thread1は結果をcounterに格納します。 結果は1になります
  • thread2は結果をcounterに格納します。 結果は1になります

counter の値は2と予想していましたが、1でした。

3.2. 同期ベースのソリューション

重要なコードを同期することで、不整合を修正できます。

class SynchronizedCounter {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public synchronized int getValue() {
        return counter;
    }
}

オブジェクトのsynchronizedメソッドを一度に使用できるのは1つのスレッドのみであるため、counterの読み取りと書き込みの一貫性が保たれます。

3.3. 組み込みのソリューション

上記のコードを組み込みのAtomicIntegerオブジェクトに置き換えることができます。 このクラスは、とりわけ、整数をインクリメントするためのアトミックメソッドを提供し、独自のコードを作成するよりも優れたソリューションです。 したがって、同期を必要とせずに、そのメソッドを直接呼び出すことができます。

AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet();

この場合、SDKが問題を解決します。 それ以外の場合は、カスタムのスレッドセーフクラスにクリティカルセクションをカプセル化して、独自のコードを作成することもできます。 このアプローチは、複雑さを最小限に抑え、コードの再利用性を最大化するのに役立ちます。

4. コレクション周辺の競合状態

4.1. 問題

私たちが陥る可能性のあるもう1つの落とし穴は、同期されたコレクションが実際よりも多くの保護を提供すると考えることです。

以下のコードを調べてみましょう。

List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
    list.add("foo");
}

リストのすべての操作は同期されますが、複数のメソッド呼び出しの組み合わせは同期されません。より具体的には、2つの操作の間で、別のスレッドがコレクションを変更して、望ましくない結果を引き起こす可能性があります。

たとえば、2つのスレッドが同時に if ブロックに入り、リストを更新し、各スレッドがfoo値をリストに追加することができます。

4.2. リストのソリューション

同期を使用して、一度に複数のスレッドからコードにアクセスされないように保護できます。

synchronized (list) {
    if (!list.contains("foo")) {
        list.add("foo");
    }
}

synchronized キーワードを関数に追加するのではなく、 list、に関するクリティカルセクションを作成しました。これにより、一度に1つのスレッドのみがこの操作を実行できます。

リストオブジェクトの他の操作でsynchronized(list)を使用して、一度に1つのスレッドのみがこの操作を実行できるという保証を提供できることに注意してください。物体。

4.3. ConcurrentHashMapの組み込みソリューション

ここで、同じ理由でマップを使用することを検討しましょう。つまり、エントリが存在しない場合にのみエントリを追加します。

ConcurrentHashMap は、このタイプの問題に対するより良い解決策を提供します。 そのアトミックputIfAbsentメソッドを使用できます。

Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar");

または、値を計算する場合は、そのアトミック computeIfAbsent メソッド

map.computeIfAbsent("foo", key -> key + "bar");

これらのメソッドはMapへのインターフェースの一部であり、挿入に関する条件付きロジックの記述を回避するための便利な方法を提供することに注意してください。 マルチスレッド呼び出しをアトミックにしようとするときに、それらは本当に私たちを助けてくれます。

5. メモリの一貫性の問題

メモリの一貫性の問題は、複数のスレッドが同じデータである必要があるものについて一貫性のないビューを持っている場合に発生します。

メインメモリに加えて、最近のほとんどのコンピュータアーキテクチャでは、キャッシュの階層(L1、L2、およびL3キャッシュ)を使用して、全体的なパフォーマンスを向上させています。 したがって、メインメモリと比較してより高速なアクセスを提供するため、どのスレッドも変数をキャッシュできます。

5.1. 問題

Counterの例を思い出してみましょう。

class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

thread1counterをインクリメントしてから、thread2がその値を読み取るシナリオを考えてみましょう。 次の一連のイベントが発生する可能性があります。

  • thread1は自身のキャッシュからカウンター値を読み取ります。 カウンターは0です
  • t hread1 はカウンターをインクリメントし、それを独自のキャッシュに書き戻します。 カウンターは1です
  • thread2 は、自身のキャッシュからカウンター値を読み取ります。 カウンターは0です

もちろん、予想される一連のイベントも発生する可能性があり、 t heread2 は正しい値(1)を読み取りますが、1つのスレッドによって変更が行われる保証はありません。毎回他のスレッドに表示されます。

5.2. ソリューション

メモリ整合性エラーを回避するために、発生前の関係を確立する必要があります。 この関係は、ある特定のステートメントによるメモリの更新が別の特定のステートメントに表示されることを保証するものにすぎません。

発生前の関係を作成するいくつかの戦略があります。 それらの1つは同期です。これは、すでに見てきました。

同期により、相互排除とメモリの一貫性の両方が保証されます。ただし、これにはパフォーマンスコストが伴います。

volatile キーワードを使用することで、メモリの整合性の問題を回避することもできます。 簡単に言えば、揮発性変数へのすべての変更は、他のスレッドに常に表示されます。

volatile を使用して、Counterの例を書き直してみましょう。

class SyncronizedCounter {
    private volatile int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

volatileは相互排除を保証しないため、インクリメント操作を同期する必要があることに注意してください。単純なアトミック変数アクセスを使用すると、同期コードを介してこれらの変数にアクセスするよりも効率的です。

5.3. 非アトミックlongおよびdouble

したがって、適切な同期なしで変数を読み取ると、古い値が表示される可能性があります。 F または長くて二重の値、非常に驚くべきことに、古い値に加えて完全にランダムな値を表示することさえ可能です。

JLS-17によると、JVMは64ビット操作を2つの別個の32ビット操作として扱う場合があります。 したがって、longまたはdouble値を読み取る場合、更新された32ビットと古い32ビットを読み取ることができます。 その結果、並行コンテキストでランダムに見えるlongまたはdouble値が観察される場合があります。

一方、揮発性のlongおよびdouble値の書き込みと読み取りは常にアトミックです。

6. 同期の誤用

同期メカニズムは、スレッドセーフを実現するための強力なツールです。 これは、内因性および外因性ロックの使用に依存しています。 また、すべてのオブジェクトには異なるロックがあり、一度に1つのスレッドのみがロックを取得できるという事実を覚えておきましょう。

ただし、注意を払わず、重要なコードに適切なロックを慎重に選択すると、予期しない動作が発生する可能性があります。

6.1. thisリファレンスでの同期

メソッドレベルの同期は、多くの並行性の問題に対する解決策として提供されます。 ただし、使いすぎると、他の同時実行の問題が発生する可能性もあります。 この同期アプローチは、 this 参照をロックとして使用します。これは、組み込みロックとも呼ばれます。

次の例では、メソッドレベルの同期をthis参照をロックとしてブロックレベルの同期に変換する方法を確認できます。

これらの方法は同等です。

public synchronized void foo() {
    //...
}
public void foo() {
    synchronized(this) {
      //...
    }
}

このようなメソッドがスレッドによって呼び出されると、他のスレッドが同時にオブジェクトにアクセスすることはできません。 これにより、すべてがシングルスレッドで実行されるため、同時実行のパフォーマンスが低下する可能性があります。 このアプローチは、オブジェクトが更新されるよりも頻繁に読み取られる場合に特に不適切です。

さらに、私たちのコードのクライアントもthisロックを取得する可能性があります。 最悪のシナリオでは、この操作はデッドロックにつながる可能性があります。

6.2. デッドロック

デッドロックは、2つ以上のスレッドが互いにブロックし、それぞれが他のスレッドによって保持されているリソースの取得を待機している状況を表します。

例を考えてみましょう:

public class DeadlockExample {

    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String args[]) {
        Thread threadA = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("ThreadA: Holding lock 1...");
                sleep();
                System.out.println("ThreadA: Waiting for lock 2...");

                synchronized (lock2) {
                    System.out.println("ThreadA: Holding lock 1 & 2...");
                }
            }
        });
        Thread threadB = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("ThreadB: Holding lock 2...");
                sleep();
                System.out.println("ThreadB: Waiting for lock 1...");

                synchronized (lock1) {
                    System.out.println("ThreadB: Holding lock 1 & 2...");
                }
            }
        });
        threadA.start();
        threadB.start();
    }
}

上記のコードでは、最初にthreadAlock1を取得し、threadBlock2を取得することがはっきりとわかります。 次に、threadAthreadBによって既に取得されているlock2を取得しようとし、threadBlock1を取得しようとします。 ]これはthreadAによってすでに取得されています。 したがって、どちらも先に進まず、デッドロック状態にあることを意味します。

スレッドの1つでロックの順序を変更することで、この問題を簡単に修正できます。

これは単なる一例であり、デッドロックにつながる可能性のある他の多くの例があることに注意してください。

7. 結論

この記事では、マルチスレッドアプリケーションで発生する可能性のある同時実行性の問題のいくつかの例を検討しました。

まず、不変またはスレッドセーフのオブジェクトまたは操作を選択する必要があることを学びました。

次に、競合状態のいくつかの例と、同期メカニズムを使用してそれらを回避する方法を確認しました。 さらに、記憶に関連する競合状態とその回避方法についても学びました。

同期メカニズムは多くの同時実行の問題を回避するのに役立ちますが、それを簡単に誤用して他の問題を引き起こす可能性があります。 このため、このメカニズムが適切に使用されていない場合に直面する可能性のあるいくつかの問題を検討しました。

いつものように、この記事で使用されているすべての例は、GitHubで入手できます。