1. デッドロックを深掘りする
デッドロック(「相互ブロック」)とは、2つ以上のスレッドが互いに相手を永遠に待ち続ける状況です。各スレッドは何らかの資源を保持し、別の資源を取得しようとしますが、その資源は隣のスレッドにすでに保持されています。結果として誰も先に進めず、プログラムは「フリーズ」します。狭い橋の上で車が向かい合ったまま動けないのと同じです。一方が後退しない限り、誰も通れません。
Java におけるデッドロックは例外(Exception)ではなく、プログラムの「ハング」です。スレッドは終了もエラー終了もしません。ただ互いを無限に待ち続けます。だからこそ厄介で、「ある条件がそろったときにだけ」現れるのです。
デッドロックが発生する4条件
- 1. 相互排他(Mutual Exclusion)
資源は同時に1つのスレッドにしか保持されない(例: オブジェクトモニター、ファイル)。 - 2. 保持して待つ(Hold and Wait)
スレッドは1つの資源を保持したまま、別の資源を取得しようとする。 - 3. 先取り不可(No Preemption)
資源はスレッドから「取り上げる」ことができない。解放できるのはそのスレッド自身だけ。 - 4. 循環待ち(Circular Wait)
閉じた連鎖があり、各スレッドが連鎖の次のスレッドが保持する資源を待っている。
デッドロックは、これらの条件がすべて同時に満たされたときにのみ起こります。 いずれか1つでも崩せば、相互ブロックは起こりません。
2. デッドロックのコード例
スキーム
- 2つの資源がある: lock1 と lock2(ただのオブジェクト)。
- スレッドAはまず lock1 を取り、その後 lock2 を取得しようとする。
- スレッドBはまず lock2 を取り、その後 lock1 を取得しようとする。
例(真似しないでください!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// スレッド 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("スレッド1: lock1 を取得した");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("スレッド1: lock2 の取得を試みている");
synchronized (lock2) {
System.out.println("スレッド1: lock2 を取得した");
}
}
});
// スレッド 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("スレッド2: lock2 を取得した");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("スレッド2: lock1 の取得を試みている");
synchronized (lock1) {
System.out.println("スレッド2: lock1 を取得した");
}
}
});
thread1.start();
thread2.start();
}
}
実行するとどうなるか?
- スレッド1は lock1 を、スレッド2は lock2 を取得する。
- 両者は 100 ミリ秒スリープして、最初のロックを取得し終える。
- その後、それぞれ相手が保持している2つ目のロックを取得しようとする。
- 互いに待ち続けて永遠に進まない — デッドロックが発生する。
コンソールには次のように表示されます:
スレッド1: lock1 を取得した
スレッド2: lock2 を取得した
スレッド1: lock2 の取得を試みている
スレッド2: lock1 の取得を試みている
なぜデッドロックが起きるのか(詳細)
- 相互排他: 各ロックは同時に1つのスレッドだけが保持できる。
- 保持して待つ: スレッド1は lock1 を保持しつつ lock2 を待ち、スレッド2は lock2 を保持しつつ lock1 を待つ。
- 先取り不可: 外部からロックを「奪う」ことはできず、synchronized ブロックを抜けるしか解放できない。
- 循環待ち: 2つのスレッド間で待ちの輪が閉じている。
3. デッドロックを避け、防止する方法
資源を常に同じ順序で取得する
最重要ルール: 複数のスレッドが複数の資源を取得する必要がある場合は、常に同じ順序で行う。例: すべてのコード箇所で、まず lock1、次に lock2。
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // 一貫した順序 "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// 両方の資源を扱う
}
}
}
順序を統一すれば循環待ちは起こらず、デッドロックの条件が崩れます。
タイムアウト付き tryLock の利用(ReentrantLock)
必要な資源が事前に分からない場合は、ReentrantLock と tryLock を使って、無限に待たないようにします。
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// クリティカルセクション
} finally {
lock2.unlock();
}
} else {
System.out.println("lock2 を取得できなかったためロールバックします");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("lock1 を取得できなかったためロールバックします");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
利点: いったん取りやめて後で再試行できる。 欠点: コードは複雑になるが、デッドロックは避けられる。
ロックの保持時間を最小化する
ロックはできるだけ短く保持しましょう。synchronized/lock の内部では必要最小限だけを実行し、それ以外はクリティカルセクションの外に出します。
ネストしたロックを避ける
synchronized/lock の入れ子が少ないほど、相互ブロックのリスクは下がります。可能なら関連資源に対して1つのロックを使いましょう。
スレッドセーフな既存の構造を使う
標準ライブラリには既に解決策が多数あります。ConcurrentHashMap のようなコレクションや、java.util.concurrent のクラス群を使えば、デッドロックのリスクを減らし、コードも簡潔になります。
4. デッドロックの診断
Thread Dump と jstack
Thread Dump は JVM 内のすべてのスレッド状態のスナップショットです。
コンソールからの取得:
jstack <pid>
ここで <pid> は Java プロセスの識別子(jps で確認可能)です。
IDE のデバッグパネルには通常「Thread Dump」ボタンがあります。
ダンプで確認すべき点:
- BLOCKED や WAITING のステータス。
- waiting to lock ... や locked ... のようなメッセージ。
- JVM のフレーズ: "Found one Java-level deadlock:" — 相互ブロックが検出されたときに出力される。
ダンプの一部サンプル:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
これがデッドロックです。スレッド同士が互いを保持しています。
VisualVM などのツール
- VisualVM — スレッド解析とデッドロック検出のための無料ツール。
- Java Mission Control / Flight Recorder — 高度なモニタリングとプロファイリング。
VisualVM ではスレッドツリーや状態を確認し、相互ブロックの通知を受け取れます。
表:「デッドロックに陥らないために」
| デッドロックの原因 | 回避方法 |
|---|---|
| 資源を異なる順序で取得する | 常に同じ順序で資源を取得する |
| 入れ子の synchronized | ネストを最小化し、可能なら1つのロックにする |
| ロックの長時間保持 | クリティカルセクション内の処理を減らす |
| 複数のロックを同時に使用 | tryLock のタイムアウトを使い、ロールバックできるようにする |
| スレッドセーフでないコレクション | コンカレントコレクション(ConcurrentHashMap など)を使う |
5. デッドロックに関する典型的なミス
誤り1: 資源を異なる順序で取得する。 最も多い原因は、スレッドごとにロックの取得順序が異なることです。「そのほうが速い」ように見えても、順序は統一してください。でなければ循環待ちは必至です。
誤り2: 不要な synchronized の入れ子。 余計な入れ子は相互ブロックのリスクを高め、コードも複雑になります。ロックモデルを簡素化しましょう。
誤り3: tryLock とタイムアウトを無視する。 synchronized でのロック取得はスレッドを無限に待たせます。確信がないなら、tryLock のタイムアウトとロールバックのロジックを使いましょう。
誤り4: ロック中に長い処理を行う。 ネットワークリクエスト、I/O、重い計算をロック下で行うと、問題の確率が一気に上がります。こうした処理はクリティカルセクションの外に出しましょう。
誤り5: Thread Dump を解析しない。 Thread Dump はデッドロック調査の最良の友です。jstack を使い、BLOCKED/WAITING 状態や「holding/waiting to lock」の連鎖を分析しましょう。
GO TO FULL VERSION