CodeGym /課程 /JAVA 25 SELF /多執行緒程式的診斷與除錯

多執行緒程式的診斷與除錯

JAVA 25 SELF
等級 53 , 課堂 4
開放

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」。

透過 VisualVMJConsole
打開行程,找到「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 具逾時的等待(例如 sleepwait(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"

如果執行緒長時間停留在 BLOCKEDWAITING —— 就值得深入調查。

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 內建的效能分析器,會收集關於執行緒、鎖定、停頓等事件。

範例:監控鎖定

VisualVMJMC 中你可以看到:

  • 執行緒「A」在物件 X 上被阻塞。
  • 執行緒「B」持有物件 X,但在等待物件 Y。
  • 執行緒「C」持有物件 Y,但在等待物件 X。

這是典型的循環鎖定(deadlock)。

如何在實務上使用這些工具?

  • -XX:+FlightRecorder 啟動應用程式(或直接使用 JDK 11+)。
  • 打開 JMC,連線到行程,開始錄製(start recording)。
  • 分析「熱點」、長時間鎖定與執行緒間的競爭。

3. 日誌與追蹤

在多執行緒程式中,靠「肉眼」除錯只會帶來痛苦。請記錄進入/離開臨界區(synchronized 區塊)、對共享變數的操作、執行緒的等待與喚醒——如此才能弄清楚誰在何時取得或釋放資源。

如何記錄日誌?

  • 使用標準工具:java.util.loggingSLF4JLog4j
  • 記錄執行緒名稱: 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();
    }
}

如何捕捉死鎖

  1. 執行程式 —— 它會卡住。
  2. 取得 thread dump(jstack 或透過 VisualVM)。
  3. 找到「Thread-1」與「Thread-2」—— 會看到各自持有一把鎖並等待另一把。
  4. 傾印末尾會有「Found one Java-level deadlock」段落。

如何修復

  • 確保鎖的取得順序始終一致。
  • 使用 ReentrantLock 搭配 tryLock() 與逾時:若無法取得所有鎖 —— 就釋放並重試。

6. 多執行緒診斷中的常見錯誤

錯誤 №1:不會閱讀 thread dump。 新手常被傾印嚇到:「這些奇怪的堆疊追蹤與狀態是什麼?」其實掌握幾個主要狀態並特別留意 BLOCKED/WAITING,即可大幅簡化分析。

錯誤 №2:忽略執行緒名稱。 沒有有意義的名稱,閱讀傾印就像大海撈針。別偷懶,記得命名!

錯誤 №3:synchronized 區塊過大。 若你將大片程式碼都同步化,執行緒更容易互相阻塞 —— 這會在傾印中以大量 BLOCKED 呈現。

錯誤 №4:混淆 RUNNABLE 與真正正在運行的執行緒。 RUNNABLE 並不一定「正在 CPU 上跑」。由 JVM 的排程器決定何時執行哪個執行緒。

錯誤 №5:不使用監控工具。 許多人不知道 VisualVMJMCFlight Recorder 而只用 println 苦撐。請善用工具 —— 它們會大幅減少痛苦。

錯誤 №6:未記錄關鍵操作。 沒有日誌,幾乎無法知道誰在何時取得/釋放鎖。

錯誤 №7:試圖用「肉眼」抓資料競爭。 競爭不一定容易重現 —— 請使用測試搭配 CountDownLatch,透過 Thread.yield() 製造競爭,並分析共享變數的狀態。

1
問卷/小測驗
多執行緒的問題,等級 53,課堂 4
未開放
多執行緒的問題
多執行緒的問題
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION