1. Thread Dump 與執行緒狀態分析
Thread Dump(執行緒傾印)是某一時刻應用中所有執行緒狀態的快照。就像替所有執行緒拍一張大合照:誰在忙、誰卡住了、誰在等誰。Thread Dump 是你定位 deadlock、livelock 與各種詭異卡頓的關鍵工具。
如何取得 Thread Dump?
透過終端機(jstack):
若你已知 Java 行程的 PID,執行:
jstack <PID>
該指令會在主控台輸出所有執行緒的狀態,包含各自處於何種狀態以及持有哪些監視器(鎖定)。
透過 IDE(IntelliJ IDEA):
在選單「Run」 → 「Show Running List」 → 選擇行程 → 「Thread Dump」。
透過 VisualVM 或 JConsole:
打開行程,找到「Threads」分頁並擷取狀態快照。
Thread Dump 範例
傾印片段:
"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001e0c7800 nid=0x1a48 waiting for monitor entry [0x000000001f00f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.DeadlockDemo.lambda$main$0(DeadlockDemo.java:25)
- waiting to lock <0x00000000d6d6baf8> (a java.lang.Object)
- locked <0x00000000d6d6bb08> (a java.lang.Object)
可以看到,執行緒「Thread-1」被阻塞(BLOCKED),持有一個監視器,但在等待另一個。若你看到多個執行緒持有資源 A 並等待 B,而另一個執行緒持有 B 並等待 A —— 這就是典型的死鎖(deadlock)。
執行緒狀態
| 狀態 | 說明 |
|---|---|
| RUNNABLE | 執行緒正在執行或已就緒 |
| BLOCKED | 正在等待獲取監視器(鎖) |
| WAITING | 等待 notify()/notifyAll()(例如因呼叫 wait()) |
| TIMED_WAITING | 具逾時的等待(例如 sleep、wait(timeout)) |
| TERMINATED | 執行緒已終止 |
重要:RUNNABLE 並不總是表示執行緒此刻正在執行——它僅代表已準備好執行(JVM 的排程器可能不會立刻啟動它)。
如何判斷是否發生死鎖?
在傾印中會看到多個處於 BLOCKED 狀態的執行緒,每個都在等待由同一組其他執行緒持有的監視器。
在傾印的末尾,jstack 通常會輸出:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00000000d6d6baf8 (object 0x00000000d6d6baf8, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00000000d6d6bb08 (object 0x00000000d6d6bb08, a java.lang.Object),
which is held by "Thread-1"
如果執行緒長時間停留在 BLOCKED 或 WAITING —— 就值得深入調查。
2. 執行緒的監控與分析
VisualVM
VisualVM —— 隨多數 JDK 提供的免費工具。可連線至行程、查看執行緒狀態、擷取 Thread Dump、檢視 CPU 負載,以及活躍與「卡住」的執行緒。
Threads 分頁:可看到建立了多少執行緒、其狀態與活動歷史。
Thread Dump:按下「Thread Dump」按鈕可獲得與 jstack 類似的快照。
Java Mission Control 與 Flight Recorder
Java Mission Control (JMC):高階的即時 JVM 分析工具。可協助調查鎖定、執行時間、配置(allocation)、延遲等。
Java Flight Recorder (JFR):JVM 內建的效能分析器,會收集關於執行緒、鎖定、停頓等事件。
範例:監控鎖定
在 VisualVM 或 JMC 中你可以看到:
- 執行緒「A」在物件 X 上被阻塞。
- 執行緒「B」持有物件 X,但在等待物件 Y。
- 執行緒「C」持有物件 Y,但在等待物件 X。
這是典型的循環鎖定(deadlock)。
如何在實務上使用這些工具?
- 以 -XX:+FlightRecorder 啟動應用程式(或直接使用 JDK 11+)。
- 打開 JMC,連線到行程,開始錄製(start recording)。
- 分析「熱點」、長時間鎖定與執行緒間的競爭。
3. 日誌與追蹤
在多執行緒程式中,靠「肉眼」除錯只會帶來痛苦。請記錄進入/離開臨界區(synchronized 區塊)、對共享變數的操作、執行緒的等待與喚醒——如此才能弄清楚誰在何時取得或釋放資源。
如何記錄日誌?
- 使用標準工具:java.util.logging、SLF4J、Log4j。
- 記錄執行緒名稱: Thread.currentThread().getName()。
- 記錄時間與執行緒識別碼。
- 記錄鎖定/釋放鎖的事件。
日誌範例
synchronized(lock) {
System.out.println(Thread.currentThread().getName() + " 取得了 lock");
// 臨界區段
System.out.println(Thread.currentThread().getName() + " 離開 lock");
}
使用執行緒名稱
給執行緒取有意義的名稱!
Thread t = new Thread(runnable, "MyWorker-1");
使用記錄器進行追蹤的範例
import java.util.logging.Logger;
public class Example {
private static final Logger logger = Logger.getLogger(Example.class.getName());
public void doWork() {
logger.info(Thread.currentThread().getName() + " 開始工作");
synchronized (this) {
logger.info(Thread.currentThread().getName() + " 進入 synchronized");
// ...
}
logger.info(Thread.currentThread().getName() + " 結束工作");
}
}
4. 診斷最佳實務
將鎖定範圍最小化
讓鎖持有時間盡可能短。
反例:
synchronized(lock) {
// 長時間 I/O
// 複雜計算
// 存取資料庫
// …然後才處理共享資料
}
正例:
// 在 synchronized 之外:長時間 I/O、計算
synchronized(lock) {
// 只處理共享資料
}
使用執行緒名稱
有意義的執行緒名稱可節省分析傾印與日誌的時間。
為多執行緒撰寫測試
使用 JUnit + CountDownLatch 模擬併發情境。
CountDownLatch latch = new CountDownLatch(2);
Runnable task = () -> {
// ...
latch.countDown();
};
new Thread(task, "Worker-1").start();
new Thread(task, "Worker-2").start();
latch.await(); // 等待兩個執行緒都結束
對 try-finally 搭配 ReentrantLock
Lock lock = new ReentrantLock();
lock.lock();
try {
// 臨界區段
} finally {
lock.unlock();
}
如此即使發生例外也不會忘記釋放鎖。為避免死鎖,請使用具逾時的 tryLock()。
記錄為何需要同步
類似「這裡需要 synchronized,因為……」的註解,有助於日後理解設計意圖。
5. 實作:在測試程式中分析死鎖
具死鎖的程式碼範例
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread-1: 取得了 lockA");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockB) {
System.out.println("Thread-1: 取得了 lockB");
}
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread-2: 取得了 lockB");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockA) {
System.out.println("Thread-2: 取得了 lockA");
}
}
}, "Thread-2");
t1.start();
t2.start();
}
}
如何捕捉死鎖
- 執行程式 —— 它會卡住。
- 取得 thread dump(jstack 或透過 VisualVM)。
- 找到「Thread-1」與「Thread-2」—— 會看到各自持有一把鎖並等待另一把。
- 傾印末尾會有「Found one Java-level deadlock」段落。
如何修復
- 確保鎖的取得順序始終一致。
- 使用 ReentrantLock 搭配 tryLock() 與逾時:若無法取得所有鎖 —— 就釋放並重試。
6. 多執行緒診斷中的常見錯誤
錯誤 №1:不會閱讀 thread dump。 新手常被傾印嚇到:「這些奇怪的堆疊追蹤與狀態是什麼?」其實掌握幾個主要狀態並特別留意 BLOCKED/WAITING,即可大幅簡化分析。
錯誤 №2:忽略執行緒名稱。 沒有有意義的名稱,閱讀傾印就像大海撈針。別偷懶,記得命名!
錯誤 №3:synchronized 區塊過大。 若你將大片程式碼都同步化,執行緒更容易互相阻塞 —— 這會在傾印中以大量 BLOCKED 呈現。
錯誤 №4:混淆 RUNNABLE 與真正正在運行的執行緒。 RUNNABLE 並不一定「正在 CPU 上跑」。由 JVM 的排程器決定何時執行哪個執行緒。
錯誤 №5:不使用監控工具。 許多人不知道 VisualVM、JMC、Flight Recorder 而只用 println 苦撐。請善用工具 —— 它們會大幅減少痛苦。
錯誤 №6:未記錄關鍵操作。 沒有日誌,幾乎無法知道誰在何時取得/釋放鎖。
錯誤 №7:試圖用「肉眼」抓資料競爭。 競爭不一定容易重現 —— 請使用測試搭配 CountDownLatch,透過 Thread.yield() 製造競爭,並分析共享變數的狀態。
GO TO FULL VERSION