ソース記事: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html 投稿者
Martin Mois この記事はJava Concurrency Fundamentalsコースの一部です。 このコースでは、並列処理の魅力を詳しく学びます。並列処理と並列コードの基本を学び、アトミック性、同期、スレッド セーフなどの概念に精通します。ここを見てください!
コンテンツ
1.
ライブネス 1.1
デッドロック 1.2
スターベーション 2.
wait() および Notify() によるオブジェクト監視 2.1
wait() および Notify() によるネストされた同期ブロック 2.2
同期ブロック内の条件 3.
マルチスレッドの設計 3.1
不変オブジェクト 3.2
API 設計 3.3
ローカルスレッドストレージ
目標を達成するために並列処理を使用するアプリケーションを開発する場合、異なるスレッドが互いにブロックする可能性がある状況に遭遇することがあります。この状況でアプリケーションの実行が予想よりも遅い場合は、予想どおりに実行されていないと言えます。このセクションでは、マルチスレッド アプリケーションの存続可能性を脅かす可能性がある問題について詳しく見ていきます。
デッドロックという用語はソフトウェア開発者の間ではよく知られており、ほとんどの一般ユーザーでも、常に正しい意味ではありませんが、時々この用語を使用します。厳密に言うと、この用語は、2 つ (またはそれ以上) のスレッドのそれぞれが、他のスレッドがそのスレッドによってロックされているリソースを解放するのを待っている一方で、最初のスレッド自体が 2 番目のスレッドがアクセスを待っているリソースをロックしていることを意味します。この問題については、次のコードを見てください
Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A
。
public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } }
上記のコードからわかるように、2 つのスレッドが開始され、2 つの静的リソースをロックしようとします。ただし、デッドロックの場合は両方のスレッドに異なるシーケンスが必要なので、Random オブジェクトのインスタンスを使用して、スレッドが最初にロックするリソースを選択します。ブール変数 b が true の場合、最初に resource1 がロックされ、次にスレッドは resource2 のロックを取得しようとします。b が false の場合、スレッドは resource2 をロックしてから resource1 を取得しようとします。このプログラムは、最初のデッドロックを達成するために長時間実行する必要はありません。プログラムを中断しないと、プログラムは永久にハングします。
[thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1.
この実行では、tread-1 は resource2 のロックを取得して resource1 のロックを待機していますが、tread-2 は resource1 のロックを取得して resource2 を待機しています。上記のコードのブール変数 b の値を true に設定すると、スレッド 1 とスレッド 2 がロックを要求する順序が常に同じになるため、デッドロックは発生しません。この状況では、2 つのスレッドのうちの 1 つが最初にロックを取得してから 2 番目のロックを要求しますが、もう 1 つのスレッドが最初のロックを待っているため、2 番目のスレッドはまだ使用可能です。一般に、デッドロックが発生するために必要な条件は次のとおりです。 - 共有実行: 常に 1 つのスレッドのみがアクセスできるリソースが存在します。- リソースの保持: 1 つのリソースを取得中に、スレッドはいくつかの一意のリソースに対して別のロックを取得しようとします。- プリエンプションなし: 1 つのスレッドが一定期間ロックを保持した場合にリソースを解放するメカニズムはありません。- 循環待機: 実行中に、2 つ (またはそれ以上) のスレッドがロックされているリソースを解放するのを互いに待機するスレッドの集合が発生します。条件のリストは長いように思えますが、適切に実行されているマルチスレッド アプリケーションでデッドロックの問題が発生することは珍しくありません。ただし、上記の条件の 1 つを削除できれば、それらを防ぐことができます。 - 共有実行: リソースを 1 人だけが使用する必要がある場合、この条件は多くの場合削除できません。しかし、これが理由である必要はありません。DBMS システムを使用する場合、更新が必要なテーブル行に悲観的ロックを使用する代わりに、
オプティミスティック ロックと呼ばれる手法を使用することが考えられます。- 別の排他的リソースを待機している間にリソースが保持されることを避ける方法は、アルゴリズムの開始時に必要なすべてのリソースをロックし、一度にすべてをロックすることが不可能な場合はすべてを解放することです。もちろん、これは常に可能であるとは限りません。おそらく、ロックが必要なリソースが事前に不明であるか、このアプローチは単にリソースの無駄につながります。- ロックをすぐに取得できない場合、起こり得るデッドロックを回避する方法は、タイムアウトを導入することです。たとえば、
ReentrantLockクラスSDK からは、ロックの有効期限を設定する機能が提供されます。- 上記の例からわかるように、リクエストの順序がスレッド間で異ならなければ、デッドロックは発生しません。すべてのブロッキング コードを、すべてのスレッドが通過する必要がある 1 つのメソッドに含めることができれば、これを制御するのは簡単です。より高度なアプリケーションでは、デッドロック検出システムの実装を検討することもできます。ここでは、各スレッドがロックの取得に成功したことを報告し、ロックの取得を試行しているという、スレッド監視のようなものを実装する必要があります。スレッドとロックが有向グラフとしてモデル化されている場合、2 つの異なるスレッドがリソースを保持しているときに、同時に他のロックされたリソースにアクセスしようとしている場合を検出できます。その後、ブロックしているスレッドに必要なリソースを強制的に解放させることができれば、デッドロック状況を自動的に解決できます。
スケジューラは、
RUNNABLE 状態のどのスレッドを次に実行するかを決定します。決定はスレッドの優先順位に基づいて行われます。したがって、優先度の低いスレッドは、優先度の高いスレッドに比べて CPU 時間が少なくなります。合理的な解決策のように見えても、悪用されると問題を引き起こす可能性があります。優先度の高いスレッドがほとんどの時間実行されている場合、優先度の低いスレッドは、作業を適切に実行するのに十分な時間が得られずに飢えているように見えます。したがって、やむを得ない理由がある場合にのみ、スレッドの優先順位を設定することをお勧めします。スレッド スターベーションの明らかではない例は、たとえば、finalize() メソッドによって示されます。これは、オブジェクトがガベージ コレクションされる前に Java 言語でコードを実行する方法を提供します。しかし、終了処理スレッドの優先順位を見ると、それが最高の優先順位で実行されていないことがわかります。その結果、オブジェクトの Finalize() メソッドがコードの残りの部分に比べて時間がかかりすぎると、スレッドの枯渇が発生します。実行時間に関する別の問題は、スレッドが同期ブロックをどの順序で通過するかが定義されていないという事実から発生します。多くの並列スレッドが同期ブロック内にフレーム化されたコードを走査すると、一部のスレッドがブロックに入る前に他のスレッドよりも長く待機する必要がある場合があります。理論的には、彼らは決してそこに到達できないかもしれません。この問題の解決策は、いわゆる「公平な」ブロッキングです。公平なロックでは、次に誰に渡すかを決定するときに、スレッドの待機時間が考慮されます。公平なロックの実装例は、Java SDK で入手できます: java.util.concurrent.locks.ReentrantLock。ブール値フラグを true に設定してコンストラクターを使用すると、ReentrantLock は最も長く待機していたスレッドにアクセスを許可します。これにより飢餓の解消が保証されますが、同時に優先順位の無視という問題が生じます。このため、このバリアで待機することが多い優先度の低いプロセスは、より頻繁に実行される可能性があります。最後に重要なことですが、ReentrantLock クラスはロックを待っているスレッドのみを考慮できます。十分な頻度で起動され、バリアに到達したスレッド。スレッドの優先順位が低すぎる場合、そのスレッドではこれが頻繁に発生しないため、優先順位の高いスレッドがロックを渡す頻度が高くなります。
マルチスレッド コンピューティングでは、プロデューサーがいくつかの作業を作成するのを待っているいくつかのワーカー スレッドが存在することが一般的な状況です。ただし、学習したように、特定の値をチェックしながらループ内で積極的に待機することは、CPU 時間の観点からは良い選択肢ではありません。この状況で Thread.sleep() メソッドを使用することも、到着後すぐに作業を開始したい場合には特に適していません。この目的のために、Java プログラミング言語には、このスキームで使用できる別の構造、wait() と Notice() があります。wait() メソッドは、java.lang.Object クラスのすべてのオブジェクトによって継承され、現在のスレッドを一時停止し、別のスレッドが notify() メソッドを使用してウェイクアップするまで待機するために使用できます。正しく動作するには、wait() メソッドを呼び出すスレッドが、以前に synchronized キーワードを使用して取得したロックを保持している必要があります。wait() が呼び出されると、ロックは解放され、スレッドはロックを保持している別のスレッドが同じオブジェクト インスタンス上で Notice() を呼び出すまで待機します。マルチスレッド アプリケーションでは、当然のことながら、オブジェクトに関する通知を待っている複数のスレッドが存在する可能性があります。したがって、スレッドを起動するには、notify() と NoticeAll() という 2 つの異なるメソッドがあります。最初のメソッドは待機中のスレッドの 1 つを起動しますが、notifyAll() メソッドはすべてのスレッドを起動します。ただし、synchronized キーワードと同様、notify() が呼び出されたときに次にどのスレッドが起動されるかを決定するルールがないことに注意してください。プロデューサとコンシューマの単純な例では、どちらのスレッドが起動されるかは気にしないので、これは問題ではありません。次のコードは、wait() と Notice() を使用して、コンシューマ スレッドがプロデューサ スレッドによって新しい作業がキューに入れられるのを待機させる方法を示しています。
package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } }
main() メソッドは、5 つのコンシューマー スレッドと 1 つのプロデューサー スレッドを開始し、それらが終了するのを待ちます。次に、プロデューサー スレッドは新しい値をキューに追加し、待機中のすべてのスレッドに何かが起こったことを通知します。コンシューマはキュー ロック (つまり、1 つのランダムなコンシューマ) を取得してからスリープ状態に入り、後でキューが再びいっぱいになるとロックが解除されます。プロデューサは作業を終了すると、すべてのコンシューマにウェイクアップするように通知します。最後のステップを実行しなかった場合、待機タイムアウトを設定しなかったため、コンシューマ スレッドは次の通知を永遠に待つことになります。代わりに、wait(long timeout) メソッドを使用して、少なくとも一定の時間が経過した後にウェイクアップすることができます。
前のセクションで述べたように、オブジェクトのモニターで wait() を呼び出しても、そのモニターのロックが解放されるだけです。同じスレッドによって保持されている他のロックは解放されません。容易に理解できるように、日常の作業では、wait() を呼び出したスレッドがさらにロックを保持することが発生することがあります。他のスレッドもこれらのロックを待機している場合、デッドロック状況が発生する可能性があります。次の例でロックを見てみましょう。
public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } }
前に学んだ ように、メソッド シグネチャに synchronized を追加することは、synchronized(this){} ブロックを作成することと同じです。上の例では、誤って synchronized キーワードをメソッドに追加し、キューをキュー オブジェクトのモニターと同期して、キューからの次の値を待機している間、このスレッドをスリープ状態に送りました。次に、現在のスレッドはキューのロックを解放しますが、キューのロックは解放しません。putInt() メソッドは、スリープ状態のスレッドに新しい値が追加されたことを通知します。しかし、偶然にも、このメソッドに synchronized キーワードも追加しました。2 番目のスレッドがスリープ状態になったので、ロックはまだ保持されています。したがって、ロックが 2 番目のスレッドによって保持されている間、最初のスレッドは putInt() メソッドに入ることができません。その結果、デッドロック状況が発生し、プログラムがフリーズしてしまいます。上記のコードを実行すると、プログラムの実行開始直後に実行されます。日常生活では、この状況はそれほど明白ではないかもしれません。スレッドが保持するロックは、実行時に発生するパラメータや条件に依存する可能性があり、問題の原因となっている同期ブロックは、コード内で wait() 呼び出しを配置した場所にそれほど近くない可能性があります。そのため、特に時間の経過や高負荷下で問題が発生する可能性があるため、そのような問題を見つけることが困難になります。
多くの場合、同期されたオブジェクトに対してアクションを実行する前に、何らかの条件が満たされていることを確認する必要があります。たとえば、行列ができている場合、それがいっぱいになるまで待ちたいとします。したがって、キューがいっぱいかどうかを確認するメソッドを作成できます。まだ空の場合は、現在のスレッドをウェイクアップされるまでスリープ状態に送信します。
public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; }
上記のコードは、wait() を呼び出す前にキューと同期し、少なくとも 1 つの要素がキューに表示されるまで while ループで待機します。2 番目の同期ブロックは、オブジェクト モニターとして再びキューを使用します。キューのpoll()メソッドを呼び出して値を取得します。デモンストレーションの目的で、ポーリングが null を返すと IllegalStateException がスローされます。これは、キューにフェッチする要素がない場合に発生します。この例を実行すると、IllegalStateException が頻繁にスローされることがわかります。キュー モニターを使用して正しく同期しましたが、例外がスローされました。その理由は、2 つの異なる同期ブロックがあるためです。最初の同期ブロックに到着した 2 つのスレッドがあると想像してください。最初のスレッドはブロックに入り、キューが空だったのでスリープ状態になりました。2番目のスレッドについても同様です。両方のスレッドが起動しているので (モニター用にもう一方のスレッドによって呼び出された NoticeAll() 呼び出しのおかげで)、両方ともプロデューサーによって追加されたキュー内の値 (項目) を確認します。そして二人は第二関門に到着した。ここで、最初のスレッドがキューに入り、キューから値を取得しました。2 番目のスレッドが入ったとき、キューはすでに空になっています。したがって、キューからの戻り値として null を受け取り、例外をスローします。このような状況を防ぐには、モニターの状態に依存するすべての操作を同じ同期ブロック内で実行する必要があります。
public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; }
ここでは、isEmpty() メソッドと同じ同期ブロック内で、poll() メソッドを実行します。同期ブロックのおかげで、特定の時点でこのモニターのメソッドを実行しているスレッドは 1 つだけであることがわかります。したがって、他のスレッドは isEmpty() と poll() の呼び出しの間にキューから要素を削除できません。翻訳の続きは
こちら。
GO TO FULL VERSION