Java Language
Javaの落とし穴 - スレッドと同時実行性
サーチ…
落とし穴:wait()/ notify()の誤った使用
メソッドobject.wait()
、 object.notify()
およびobject.notifyAll()
は、非常に特殊な方法で使用するためのものです。 ( http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307を参照 )
「失われた通知」問題
一般的な初心者の間違いの1つは、無条件にobject.wait()
呼び出すことobject.wait()
private final Object lock = new Object();
public void myConsumer() {
synchronized (lock) {
lock.wait(); // DON'T DO THIS!!
}
doSomething();
}
これが間違っている理由は、他のスレッドがlock.notify()
またはlock.notifyAll()
を呼び出すことに依存するが、 lock.wait()
というコンシューマスレッドの前に他のスレッドがその呼び出しを行っていないことを保証するものはないからです。
lock.notify()
とlock.notifyAll()
他のスレッドが既に通知を待っていない場合は、まったく何もしません。この例のmyConsumer()
を呼び出すスレッドは、通知をキャッチするには遅すぎる場合、永久にハングします。
「不正な監視状態」のバグ
ロックを保持せずにオブジェクトに対してwait()
またはnotify()
を呼び出すと、JVMはIllegalMonitorStateException
をスローしIllegalMonitorStateException
。
public void myConsumer() {
lock.wait(); // throws exception
consume();
}
public void myProducer() {
produce();
lock.notify(); // throws exception
}
( wait()
/ notify()
の設計では、システム競合状態を避けるためにロックが保持されている必要があります。ロックなしでwait()
またはnotify()
を呼び出すことができた場合、これらのプリミティブの主なユースケース:条件が発生するのを待つ)
待機/通知が低すぎる
wait()
とnotify()
問題を避ける最も良い方法は、それらを使用しないことです。ほとんどの同期の問題は、 java.utils.concurrent
パッケージで使用可能な上位レベルの同期オブジェクト(キュー、バリア、セマフォなど)を使用することで解決できます。
落とし穴 - java.lang.Threadを拡張
Thread
クラスのjavadocは、 Thread
を定義して使用する2つの方法を示しています。
カスタムスレッドクラスを使用する:
class PrimeThread extends Thread {
long minPrime;
PrimeThread(long minPrime) {
this.minPrime = minPrime;
}
public void run() {
// compute primes larger than minPrime
. . .
}
}
PrimeThread p = new PrimeThread(143);
p.start();
実行可能Runnable
使用:
class PrimeRun implements Runnable {
long minPrime;
PrimeRun(long minPrime) {
this.minPrime = minPrime;
}
public void run() {
// compute primes larger than minPrime
. . .
}
}
PrimeRun p = new PrimeRun(143);
new Thread(p).start();
(ソース: java.lang.Thread
javadoc
カスタムスレッドクラスのアプローチは機能しますが、それにはいくつかの問題があります:
古典的なスレッドプール、エグゼキュータ、またはForkJoinフレームワークを使用するコンテキストで
PrimeThread
を使用するのは面倒です。 (PrimeThread
間接的にRunnable
実装するので不可能ではありませんが、Runnable
としてカスタムThread
クラスを使用することは確かにPrimeThread
で、クラスの他の側面によっては実行可能ではないかもしれません)。他の方法では間違いの可能性が増えます。あなたが宣言した場合たとえば、
PrimeThread.start()
に委譲することなく、Thread.start()
あなたは、現在のスレッドで実行されていた「スレッド」で終わるでしょう。
スレッドロジックをRunnable
に置くアプローチは、これらの問題を回避します。実際、匿名クラス(Java 1.1以降)を使用してRunnable
を実装すると、結果は上記の例よりも簡潔で読みやすくなります。
final long minPrime = ...
new Thread(new Runnable() {
public void run() {
// compute primes larger than minPrime
. . .
}
}.start();
ラムダ式(Java 8以降)では、上の例はよりエレガントになります。
final long minPrime = ...
new Thread(() -> {
// compute primes larger than minPrime
. . .
}).start();
落とし穴 - スレッドが多すぎると、アプリケーションが遅くなります。
マルチスレッドに慣れていない人の多くは、スレッドを使用するとアプリケーションが自動的に高速化すると考えています。実際、それよりもはるかに複雑です。しかし確実に言えることは、どのコンピュータでも同時に実行できるスレッドの数に制限があることです。
- コンピュータには固定数のコア (またはハイパースレッド )があります。
- 実行するには、Javaスレッドをコアまたはハイパースレッドにスケジューリングする必要があります。
- 実行可能なJavaスレッドが(使用可能な)コア/ハイパースレッドよりも多い場合、それらのうちのいくつかは待機する必要があります。
これは、Javaスレッドをより多く作成するだけでは、アプリケーションをより速く高速にすることができないことを示しています。しかし、他にも考慮すべき点があります。
各スレッドは、スレッドスタック用にオフヒープメモリ領域を必要とします。標準(デフォルト)のスレッドスタックサイズは512Kバイトまたは1Mバイトです。スレッド数が多い場合は、メモリ使用量が大きくなる可能性があります。
各アクティブなスレッドは、ヒープ内のいくつかのオブジェクトを参照します。これにより、 到達可能なオブジェクトのワーキングセットが増加し、ガベージコレクションと物理メモリの使用に影響を与えます。
スレッド間の切り替えのオーバーヘッドは自明ではありません。これは、通常、スレッドのスケジューリングを決定するためにOSカーネル空間に切り替えることを必要とする。
スレッド同期とスレッド間シグナリング(wait()、notify()/ notifyAllなど)のオーバヘッドは重要な意味を持つ可能性があります。
アプリケーションの詳細に応じて、これらの要因は一般にスレッド数に「スィートスポット」があることを意味します。それ以上のスレッドを追加することで、パフォーマンスの向上が最小限に抑えられ、パフォーマンスが低下する可能性があります。
新しいタスクごとにアプリケーションを作成すると、予期しない作業負荷の増加(高い要求率など)が致命的な動作につながる可能性があります。
これに対処するより良い方法は、(静的または動的に)サイズを制御できる有界スレッドプールを使用することです。あまりにも多くの作業が必要な場合、アプリケーションは要求をキューに入れる必要があります。 ExecutorService
を使用すると、スレッドプール管理とタスクキューイングが処理されます。
落とし穴 - スレッド作成は比較的高価です
次の2つのマイクロベンチマークを考えてみましょう。
最初のベンチマークは単にスレッドを作成、開始、結合するだけです。スレッドのRunnable
は動作しません。
public class ThreadTest {
public static void main(String[] args) throws Exception {
while (true) {
long start = System.nanoTime();
for (int i = 0; i < 100_000; i++) {
Thread t = new Thread(new Runnable() {
public void run() {
}});
t.start();
t.join();
}
long end = System.nanoTime();
System.out.println((end - start) / 100_000.0);
}
}
}
$ java ThreadTest
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C
64ビットのJava 8 u101を搭載したLinuxを実行する典型的な最新のPCでは、このベンチマークは、33.6〜33.9マイクロ秒のスレッドを作成、開始、結合するのにかかる平均時間を示しています。
2番目のベンチマークは最初のものと同等ですが、 ExecutorService
を使用してタスクを送信し、 Future
を使用してタスクの最後にランデブーを使用します。
import java.util.concurrent.*;
public class ExecutorTest {
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
while (true) {
long start = System.nanoTime();
for (int i = 0; i < 100_000; i++) {
Future<?> future = exec.submit(new Runnable() {
public void run() {
}
});
future.get();
}
long end = System.nanoTime();
System.out.println((end - start) / 100_000.0);
}
}
}
$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C
ご覧のように、平均は5.3〜5.6マイクロ秒です。
実際の時間はさまざまな要素に依存しますが、これら2つの結果の差は重要です。新しいスレッドを作成するよりも、スレッドプールを使用してスレッドをリサイクルする方がはるかに高速です。
落とし穴:共有変数には適切な同期が必要です
この例を考えてみましょう。
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (!stop) {
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
tt.stop = true; // Tell child thread to stop.
}
}
このプログラムの目的は、スレッドを開始し、1000ミリ秒間実行させ、 stop
フラグを設定してスレッドを停止させることです。
それは意図したとおりに動作しますか?
たぶんそうだけどたぶん違う。
main
メソッドが復帰したときに、アプリケーションが停止するとは限りません。別のスレッドが作成され、そのスレッドがデーモンスレッドとしてマークされていない場合、アプリケーションはメインスレッドが終了した後も引き続き実行されます。この例では、アプリケーションが子スレッドが終了するまで実行を継続することを示しています。これは、 tt.stop
がtrue
設定されている場合に発生しtrue
。
しかし、それは実際に厳密には真実ではありません。実際には、子スレッドは、値true
stop
を観測した後でstop
しtrue
。それは起こるだろうか?たぶんそうだけどたぶん違う。
Java言語仕様では、ソースコード内のステートメントの順番どおりに、スレッド内で行われたメモリの読み込みと書き込みがそのスレッドから見えることが保証されています。しかし、一般的に、あるスレッドが書き込みを行い、別のスレッド(後で)が読み込んだ場合、これは保証されません。可視性を保証するには、書き込みとそれに続く読み込みとの間に一連の先起こりの関係が存在する必要があります。上記の例では、 stop
フラグへの更新のためのチェーンは存在しないため、子スレッドがstop
変更をtrue
することは保証されません。
(著者への注記:詳細な技術的詳細に入るには、Javaメモリモデルに別のトピックが必要です。)
どのように問題を解決するのですか?
この場合、 stop
更新が確実に表示されるようにするには、2つの簡単な方法があります。
stop
をvolatile
すると宣言します。すなわち、private volatile boolean stop = false;
volatile
変数の場合、JLSは、あるスレッドの書き込みと後で第2のスレッドの読み込みとの間に起こりうる関係があることを指定します。ミューテックスを使用して、次のように同期します。
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (true) {
synchronize (this) {
if (stop) {
break;
}
}
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
synchronize (tt) {
tt.stop = true; // Tell child thread to stop.
}
}
}
相互排除が存在することを保証することに加えて、JLSが起こり、前に 1つのスレッドでミューテックスを解除し、第二のスレッドで同じミューテックスを獲得との間に関係があることを指定します。
しかし、割り当てアトミックではありませんか?
はい、そうです!
しかし、その事実は、更新の影響がすべてのスレッドに同時に表示されることを意味するものではありません。 直前の関係の適切な連鎖だけがそれを保証するでしょう。
なぜ彼らはこれをやったのですか?
初めてJavaでマルチスレッドプログラミングを行うプログラマは、メモリモデルが挑戦的であると感じています。プログラムは直感的に動作します。なぜなら、書き込みが均一に見えるというのは当然の予想だからです。ではなぜJavaデザイナーがこのようにメモリモデルを設計するのですか?
実際には、パフォーマンスと使いやすさの間に妥協点があります(プログラマーにとって)。
最新のコンピュータアーキテクチャは、個々のレジスタセットを有する複数のプロセッサ(コア)からなる。メインメモリは、すべてのプロセッサまたはプロセッサグループにアクセスできます。現代のコンピュータハードウェアのもう1つの特性は、レジスタへのアクセスが、メインメモリへのアクセスよりも通常はアクセスが数倍高速であることである。コアの数が増えるにつれて、メインメモリへの読み書きがシステムの主要なパフォーマンスボトルネックになることは容易にわかります。
この不一致は、プロセッサコアとメインメモリとの間に1つ以上のレベルのメモリキャッシングを実装することによって対処されます。各コアは、キャッシュを介してメモリセルにアクセスします。通常、メインメモリのリードはキャッシュミスが発生した場合にのみ発生し、メインメモリライトはキャッシュラインをフラッシュする必要がある場合にのみ発生します。各コアのメモリ位置がキャッシュに収まるアプリケーションの場合、コアの速度はもはや主メモリの速度/帯域幅によって制限されません。
しかし、これは、複数のコアが共有変数を読み書きしているときに新しい問題を引き起こします。変数の最新バージョンは、1つのコアのキャッシュに置かれることがあります。そのコアがキャッシュ・ラインをメイン・メモリにフラッシュしない限り、他のコアはキャッシュされた古いバージョンのコピーを無効にしますが、一部のバージョンでは古いバージョンの変数が見えます。しかし、キャッシュが毎回メモリにフラッシュされると(メインメモリの帯域幅を不必要に消費する)キャッシュ書き込み(別のコアによる読み出しがあった場合)が発生します。
ハードウェア命令セットレベルで使用される標準的な解決策は、キャッシュ無効化およびキャッシュライトスルーのための命令を提供し、それらをいつ使用するかを決定するためにコンパイラに任せることである。
Javaに戻る。メモリモデルは、Javaコンパイラが実際には必要でない場所でキャッシュの無効化とライトスルー命令を発行する必要がないように設計されています。プログラマは、メモリの可視性が必要であることを示すために、適切な同期メカニズム(プリミティブmutex、 volatile
、より高いレベルの並行性クラスなど)を使用することを前提としています。 先験的な関係が存在しない場合、Javaコンパイラはキャッシュ操作(またはそれに類似したもの)が必要ないと自由に仮定します。
これは、マルチスレッドアプリケーションにとって大きなパフォーマンス上の利点がありますが、正しいマルチスレッドアプリケーションを記述することは簡単ではないという欠点があります。プログラマは 、自分が何をしているのかを理解しなければなりません。
なぜこれを再現できないのですか?
このような問題を再現することが難しい理由はいくつかあります。
上で説明したように、メモリの可視性の問題を適切に処理できないという結果は、通常 、コンパイルされたアプリケーションがメモリキャッシュを正しく処理しないことになります。しかし、上記のように、メモリキャッシュはしばしばフラッシュされます。
ハードウェアプラットフォームを変更すると、メモリキャッシュの特性が変わることがあります。これは、アプリケーションが正しく同期しない場合、異なる動作につながります。
あなたは偶然同期の影響を観察しているかもしれません。たとえば、トレースプリントを追加すると、通常、キャッシュフラッシュを引き起こすI / Oストリームのシーンの後ろで同期が発生します。したがって、痕跡を追加すると、アプリケーションの動作が異なることがよくあります。
デバッガでアプリケーションを実行すると、JITコンパイラによって異なる方法でコンパイルされます。ブレークポイントとシングルステッピングはこれを悪化させます。これらのエフェクトは、アプリケーションの動作を変更することがよくあります。
これらのことは、不十分な同期化に起因するバグを解決することが特に困難である。