スレッドセーフとは何ですか?それを実現する方法は?
1. 概要
Javaは、すぐに使用できるマルチスレッドをサポートしています。 これは、バイトコードを別々のワーカースレッドで同時に実行することにより、JVMがアプリケーションのパフォーマンスを向上させることができることを意味します。
マルチスレッドは強力な機能ですが、コストがかかります。 マルチスレッド環境では、スレッドセーフな方法で実装を作成する必要があります。 これは、異なるスレッドが誤った動作を公開したり、予測できない結果を生成したりすることなく、同じリソースにアクセスできることを意味します。 このプログラミング方法は「スレッドセーフ」として知られています。
このチュートリアルでは、それを実現するためのさまざまなアプローチを見ていきます。
2. ステートレス実装
ほとんどの場合、マルチスレッドアプリケーションのエラーは、複数のスレッド間で状態を誤って共有した結果です。
したがって、最初に検討するアプローチは、ステートレス実装を使用してスレッドセーフを実現することです。
このアプローチをよりよく理解するために、数値の階乗を計算する静的メソッドを持つ単純なユーティリティクラスを考えてみましょう。
public class MathUtils {
public static BigInteger factorial(int number) {
BigInteger f = new BigInteger("1");
for (int i = 2; i <= number; i++) {
f = f.multiply(BigInteger.valueOf(i));
}
return f;
}
}
factorial()メソッドは、ステートレスな決定論的関数です。特定の入力が与えられると、常に同じ出力を生成します。
メソッドは、外部状態に依存せず、状態をまったく維持しません。したがって、スレッドセーフであると見なされ、同時に複数のスレッドから安全に呼び出すことができます。
すべてのスレッドはfactorial()メソッドを安全に呼び出すことができ、相互に干渉したり、メソッドが他のスレッドに対して生成する出力を変更したりすることなく、期待される結果を得ることができます。
したがって、ステートレス実装は、スレッドセーフを実現するための最も簡単な方法です。
3. 不変の実装
異なるスレッド間で状態を共有する必要がある場合は、それらを不変にすることでスレッドセーフなクラスを作成できます。
不変性は強力で言語に依存しない概念であり、Javaで実現するのはかなり簡単です。
簡単に言うと、クラスインスタンスは、構築後に内部状態を変更できない場合、不変です。
Javaで不変クラスを作成する最も簡単な方法は、すべてのフィールドprivateおよびfinalを宣言し、セッターを提供しないことです。
public class MessageService {
private final String message;
public MessageService(String message) {
this.message = message;
}
// standard getter
}
MessageService オブジェクトは、構築後に状態を変更できないため、事実上不変です。 だから、それはスレッドセーフです。
さらに、 MessageService が実際には変更可能であるが、複数のスレッドが読み取り専用でアクセスできる場合は、スレッドセーフでもあります。
ご覧のとおり、不変性は、スレッドセーフを実現するためのもう1つの方法です。
4. スレッドローカルフィールド
オブジェクト指向プログラミング(OOP)では、オブジェクトは実際にはフィールドを介して状態を維持し、1つ以上のメソッドを介して動作を実装する必要があります。
実際に状態を維持する必要がある場合は、フィールドをスレッドローカルにすることで、スレッド間で状態を共有しないスレッドセーフクラスを作成できます。
Thread クラスでプライベートフィールドを定義するだけで、フィールドがスレッドローカルであるクラスを簡単に作成できます。
たとえば、integersのarrayを格納するThreadクラスを定義できます。
public class ThreadA extends Thread {
private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
@Override
public void run() {
numbers.forEach(System.out::println);
}
}
一方、別の1つは、文字列の配列を保持する場合があります。
public class ThreadB extends Thread {
private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");
@Override
public void run() {
letters.forEach(System.out::println);
}
}
どちらの実装でも、クラスには独自の状態がありますが、他のスレッドとは共有されません。 したがって、クラスはスレッドセーフです。
同様に、 ThreadLocal インスタンスをフィールドに割り当てることで、スレッドローカルフィールドを作成できます。
次のStateHolderクラスについて考えてみましょう。
public class StateHolder {
private final String state;
// standard constructors / getter
}
簡単にスレッドローカル変数にすることができます。
public class ThreadState {
public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {
@Override
protected StateHolder initialValue() {
return new StateHolder("active");
}
};
public static StateHolder getState() {
return statePerThread.get();
}
}
スレッドローカルフィールドは、通常のクラスフィールドとほとんど同じですが、セッター/ゲッターを介してアクセスする各スレッドは、フィールドの個別に初期化されたコピーを取得するため、各スレッドは独自の状態になります。
5. 同期されたコレクション
collectionsフレームワークに含まれている同期ラッパーのセットを使用して、スレッドセーフなコレクションを簡単に作成できます。
たとえば、次の同期ラッパーのいずれかを使用して、スレッドセーフなコレクションを作成できます。
Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();
同期されたコレクションは、各メソッドで組み込みロックを使用することに注意してください(組み込みロックについては後で説明します)。
つまり、メソッドには一度に1つのスレッドしかアクセスできませんが、他のスレッドは、メソッドが最初のスレッドによってロック解除されるまでブロックされます。
したがって、同期アクセスの基本的なロジックにより、同期にはパフォーマンスの低下があります。
6. コンカレントコレクション
同期されたコレクションの代わりに、同時コレクションを使用してスレッドセーフなコレクションを作成できます。
Javaは、 java.util.concurrent パッケージを提供します。このパッケージには、ConcurrentHashMapなどのいくつかの同時コレクションが含まれています。
Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");
同期された対応物とは異なり、同時コレクションは、データをセグメントに分割することでスレッドセーフを実現します。たとえば、 ConcurrentHashMap では、複数のスレッドが異なるマップセグメントのロックを取得できるため、複数のスレッドがマップに同時にアクセスできます。
同時コレクションは同期コレクションよりもはるかにパフォーマンスが高くなります。これは、同時スレッドアクセスに固有の利点があるためです。
同期された同時コレクションは、コレクション自体をスレッドセーフにするだけで、コンテンツは作成しないことに注意してください。
7. 原子オブジェクト
AtomicInteger 、 AtomicLong 、 AtomicBoolean など、Javaが提供するアトミッククラスのセットを使用してスレッドセーフを実現することもできます。およびAtomicReference。
アトミッククラスを使用すると、同期を使用せずにスレッドセーフなアトミック操作を実行できます。アトミック操作は、単一のマシンレベルの操作で実行されます。
これが解決する問題を理解するために、次のCounterクラスを見てみましょう。
public class Counter {
private int counter = 0;
public void incrementCounter() {
counter += 1;
}
public int getCounter() {
return counter;
}
}
競合状態で、2つのスレッドが incrementCounter()メソッドに同時にアクセスするとします。
理論的には、counterフィールドの最終値は2になります。 ただし、スレッドが同じコードブロックを同時に実行しており、インクリメントがアトミックではないため、結果については確信が持てません。
AtomicInteger オブジェクトを使用して、Counterクラスのスレッドセーフな実装を作成しましょう。
public class AtomicCounter {
private final AtomicInteger counter = new AtomicInteger();
public void incrementCounter() {
counter.incrementAndGet();
}
public int getCounter() {
return counter.get();
}
}
インクリメント++は複数の操作を必要としますが、 incrementAndGet はアトミックであるため、これはスレッドセーフです。
8. 同期されたメソッド
以前のアプローチはコレクションとプリミティブに非常に適していますが、それよりも高度な制御が必要になる場合があります。
したがって、スレッドセーフを実現するために使用できるもう1つの一般的なアプローチは、同期メソッドを実装することです。
簡単に言うと、一度に同期メソッドにアクセスできるのは1つのスレッドだけで、他のスレッドからのこのメソッドへのアクセスはブロックされます。他のスレッドは、最初のスレッドが終了するか、メソッドが例外をスローするまでブロックされたままになります。
別の方法でincrementCounter()のスレッドセーフバージョンを同期メソッドにすることで作成できます。
public synchronized void incrementCounter() {
counter += 1;
}
メソッドシグネチャの前にsynchronizedキーワードを付けることで、同期メソッドを作成しました。
一度に1つのスレッドが同期されたメソッドにアクセスできるため、1つのスレッドが incrementCounter()メソッドを実行し、次に他のスレッドが同じことを実行します。 重複する実行は一切発生しません。
同期メソッドは、「組み込みロック」または「監視ロック」の使用に依存しています。組み込みロックは、特定のクラスインスタンスに関連付けられた暗黙の内部エンティティです。
マルチスレッドのコンテキストでは、 monitor という用語は、指定されたメソッドまたはステートメントのセットへの排他的アクセスを強制するため、関連するオブジェクトに対してロックが実行する役割への単なる参照です。
インスタンスメソッド、静的メソッド、およびステートメント(同期されたステートメント)に同期を実装できます。
9. 同期されたステートメント
メソッドのセグメントをスレッドセーフにする必要があるだけの場合、メソッド全体を同期するのはやり過ぎかもしれません。
このユースケースを例示するために、 initialCounter()メソッドをリファクタリングしてみましょう。
public void incrementCounter() {
// additional unsynced operations
synchronized(this) {
counter += 1;
}
}
この例は簡単ですが、同期ステートメントを作成する方法を示しています。 メソッドが同期を必要としないいくつかの追加操作を実行すると仮定すると、 synchronized ブロック内にラップすることによって、関連する状態変更セクションを同期するだけです。
同期メソッドとは異なり、同期ステートメントは、組み込みロックを提供するオブジェクト、通常はthis参照を指定する必要があります。
同期にはコストがかかるため、このオプションでは、メソッドの関連部分のみを同期できます。
9.1. ロックとしての他のオブジェクト
this の代わりに別のオブジェクトをモニターロックとして利用することで、Counterクラスのスレッドセーフな実装をわずかに改善できます。
これにより、マルチスレッド環境で共有リソースへの調整されたアクセスが提供されるだけでなく、外部エンティティを使用してリソースへの排他的アクセスが強制されます。
public class ObjectLockCounter {
private int counter = 0;
private final Object lock = new Object();
public void incrementCounter() {
synchronized(lock) {
counter += 1;
}
}
// standard getter
}
プレーンなObjectインスタンスを使用して、相互排除を強制します。 この実装は、ロックレベルでのセキュリティを促進するため、わずかに優れています。
このを組み込みロックに使用する場合、攻撃者は、組み込みロックを取得してサービス拒否(DoS)状態をトリガーすることにより、デッドロックを引き起こす可能性があります。
逆に、他のオブジェクトを使用する場合、そのプライベートエンティティに外部からアクセスすることはできません。これにより、攻撃者がロックを取得してデッドロックを引き起こすことが困難になります。
9.2. 警告
任意のJavaオブジェクトを組み込みロックとして使用できますが、ロックの目的でStringsを使用することは避けてください。
public class Class1 {
private static final String LOCK = "Lock";
// uses the LOCK as the intrinsic lock
}
public class Class2 {
private static final String LOCK = "Lock";
// uses the LOCK as the intrinsic lock
}
一見すると、これら2つのクラスは2つの異なるオブジェクトをロックとして使用しているように見えます。 ただし、文字列インターンのため、これら2つの「ロック」値は実際には文字列プール上の同じオブジェクトを参照する場合があります。つまり、Class1およびClass2同じロックを共有しています!
これにより、並行コンテキストで予期しない動作が発生する可能性があります。
Strings に加えて、キャッシュ可能または再利用可能なオブジェクトを組み込みロックとして使用することは避けてください。たとえば、 Integer.valueOf()メソッドは少数をキャッシュします。 したがって、 Integer.valueOf(1)を呼び出すと、クラスが異なっていても同じオブジェクトが返されます。
10. 揮発性フィールド
同期されたメソッドとブロックは、スレッド間の可変の可視性の問題に対処するのに便利です。 それでも、通常のクラスフィールドの値はCPUによってキャッシュされる可能性があります。 したがって、特定のフィールドへの結果的な更新は、同期されている場合でも、他のスレッドには表示されない場合があります。
この状況を防ぐために、volatileクラスフィールドを使用できます。
public class Counter {
private volatile int counter;
// standard constructors / getter
}
volatileキーワードを使用して、JVMとコンパイラにカウンタ変数をメインメモリに格納するように指示します。このようにして、JVMがcounterの値を読み取るたびに確認します。 ]変数の場合、CPUキャッシュからではなく、メインメモリから実際に読み取ります。 同様に、JVMが counter 変数に書き込むたびに、値がメインメモリに書き込まれます。
さらに、揮発性変数を使用すると、特定のスレッドに表示されるすべての変数がメインメモリからも読み取られるようになります。
次の例を考えてみましょう。
public class User {
private String name;
private volatile int age;
// standard constructors / getters
}
この場合、JVMは age volatile 変数をメインメモリに書き込むたびに、不揮発性name変数をメインメモリに次のように書き込みます。良い。 これにより、両方の変数の最新の値がメインメモリに確実に保存されるため、変数の結果として生じる更新は、他のスレッドに自動的に表示されます。
同様に、スレッドが volatile 変数の値を読み取る場合、スレッドに表示されるすべての変数もメインメモリから読み取られます。
volatile 変数が提供するこの拡張保証は、完全揮発性可視性保証として知られています。
11. リエントラントロック
Javaは、 Lock 実装の改善されたセットを提供します。その動作は、上記の組み込みロックよりもわずかに洗練されています。
組み込みロックの場合、ロック取得モデルはかなり厳密です:1つのスレッドがロックを取得し、メソッドまたはコードブロックを実行し、最後にロックを解放して、他のスレッドがロックを取得してメソッドにアクセスできるようにします。
キューに入れられたスレッドをチェックし、最も長く待機しているスレッドに優先的にアクセスできるようにする基本的なメカニズムはありません。
ReentrantLock インスタンスを使用すると、まさにそれを実行できます。キューに入れられたスレッドが特定の種類のリソース不足に陥るのを防ぎます:
public class ReentrantLockCounter {
private int counter;
private final ReentrantLock reLock = new ReentrantLock(true);
public void incrementCounter() {
reLock.lock();
try {
counter += 1;
} finally {
reLock.unlock();
}
}
// standard constructors / getter
}
ReentrantLock コンストラクターは、オプションの fairness booleanパラメーターを取ります。 true に設定され、複数のスレッドがロックを取得しようとすると、 JVMは最も長い待機スレッドを優先し、ロックへのアクセスを許可します。
12. 読み取り/書き込みロック
スレッドセーフを実現するために使用できるもう1つの強力なメカニズムは、ReadWriteLock実装の使用です。
ReadWriteLock ロックは、実際には、関連付けられたロックのペアを使用します。1つは読み取り専用操作用で、もう1つは書き込み操作用です。
結果として、
ReadWriteLockロックを使用する方法は次のとおりです。
public class ReentrantReadWriteLockCounter {
private int counter;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public void incrementCounter() {
writeLock.lock();
try {
counter += 1;
} finally {
writeLock.unlock();
}
}
public int getCounter() {
readLock.lock();
try {
return counter;
} finally {
readLock.unlock();
}
}
// standard constructors
}
13. 結論
この記事では、 Javaでのスレッドセーフとは何かを学び、それを実現するためのさまざまなアプローチを詳しく調べました。
いつものように、この記事に示されているすべてのコードサンプルは、GitHubでから入手できます。