JVM 原始碼分析(四):深入理解 park / unpark

張永恆發表於2021-01-15

前言

熟悉 Java 併發包的人一定對 LockSupport 的 park/unpark 方法不會感到陌生,它是 Lock(AQS)的基石,給 Lock(AQS)提供了掛起/恢復當前執行緒的能力。

LockSupport 的 park/unpark 方法本質上是對 Unsafe 的 park/unpark 方法的簡單封裝,而後者是 native 方法,對 Java 程式來說是一個黑箱操作,那麼要想了解它的底層實現,就必須深入 Java 虛擬機器的原始碼。

本篇將介紹 park/unpark 方法在 Hotsport 虛擬機器中的具體實現。

Parker 原始碼除錯與分析

在 Hotspot 原始碼中,unsafe.cpp 檔案專門用於為 Java Unsafe 類中的各種 native 方法提供具體實現。

其中 park 方法的實現程式碼如下:

unpark 方法的實現程式碼如下:

兩者的核心操作都是通過委託當前執行緒所關聯的 Parker 物件來完成的(每個執行緒都會關聯一個自己的 Parker 物件),於是,Parker 物件的 park/unpark 方法就成為了我們的焦點。

下面我將聯合 Java 程式與 Hotspot 原始碼一起除錯,觀察 Parker 物件的 park/unpark 方法的內部操作。

其中 Java 程式的程式碼如下:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("park開始");
        LockSupport.park();
        System.out.println("park結束");
    }, "t1");

    Thread t2 = new Thread(() -> {
        System.out.println("unpark開始");
        LockSupport.unpark(t1);
        System.out.println("unpark結束");
    }, "t2");

    Scanner scanner = new Scanner(System.in);
    String input;
    System.out.println("輸入“1”啟動t1執行緒,輸入“2”啟動t2執行緒,輸入“quit”退出");
    while (!(input = scanner.nextLine()).equals("quit")) {
        if (input.equals("1")) {
            if (t1.getState().equals(Thread.State.NEW)) {
                t1.start();
            }
        } else if (input.equals("2")) {
            if (t2.getState().equals(Thread.State.NEW)) {
                t2.start();
            }
        }
    }
}

我們採用遠端除錯的方式執行上面的 Java 程式,然後通過在控制檯輸入“1” 來啟動 t1 執行緒。當 t1 執行緒啟動後,LockSupport.park 方法就會得以執行。

如圖所示,當前 t1 執行緒停在了斷點處,即停在了 Parker::park 方法的第一條語句上。

我們來分析一下該方法主要做的事情。

它首先利用一個原子交換操作將計數器的值改為 0,同時檢查計數器的原值是否大於 0,如果大於 0,表示當前 Parker 物件的 unpark 方法先於 park 方法執行了(因為 unpark 方法會把計數器的值改為 1),那麼本次 park 方法將直接返回,表示取消本次操作。如果計數器的原值不大於 0,則繼續往下執行。

接著判斷當前執行緒是否被標記了中斷,如果是的話就直接返回,否則就通過 pthread_mutex_trylock 函式嘗試加 mutex 鎖,如果加鎖失敗也直接返回。(pthread_mutex_trylock 函式是一個系統呼叫,它會針對作業系統的一個互斥量進行加鎖,加鎖成功將返回 0)。

在我們的除錯中,以上所有條件判斷都不命中,於是執行緒順利地執行到了下圖所示的位置。

圖中斷點處的程式碼相當關鍵,它完成了對 pthread_cond_wait 函式的呼叫,該函式是 Linux 標準執行緒庫(libpthread.so)中的一個系統呼叫,它會使當前執行緒加入作業系統的條件等待佇列,同時釋放 mutex 鎖並使當前執行緒掛起。

Java 中的 waitawait 方法提供了和 pthread_cond_wait 函式同樣的功能,前者本質上是對後者的封裝。如果對 pthread_cond_wait 函式的具體實現感興趣,可以參考: https://code.woboq.org/userspace/glibc/nptl/pthread_cond_wait.c.html

由於 pthread_cond_wait 函式會使當前執行緒掛起,所以在我點選 "Step Over" 之後,執行緒阻塞在了 pthread_cond_wait 函式上,並等待被喚醒。

下圖顯示了通過 jstack 命令列印的執行緒堆疊資訊,可以看到 t1 執行緒已經處於 waiting (parking) 狀態。

至此,park 操作暫時告一段落。

接下來,我們通過在控制檯輸入“2” 來啟動 t2 執行緒。當 t2 執行緒啟動後,LockSupport.unpark(t1) 就會得以執行。

如圖所示,當前 t2 執行緒停在了斷點處,即停在了 Parker::unpark 方法的第二行程式碼上。

該方法做的事情相對簡單,它先是給當前執行緒加鎖,然後將計數器的值改為 1,接著判斷 Parker 物件所關聯的執行緒是否被 park,如果是,則通過 pthread_mutex_signal 函式喚醒該執行緒,最後釋放鎖。

pthread_mutex_signal 函式通常與 pthread_cond_wait 函式配套使用,其作用是喚醒作業系統中在某個條件變數上等待著的執行緒。

當 unpark 操作完成後,之前被 park 的執行緒將恢復至執行狀態(需要先拿到 mutex 鎖),然後從 pthread_cond_wait 方法中返回,接著執行剩餘程式碼。下圖顯示了Parker::park 方法的剩餘程式碼。

可以看到,當執行緒恢復執行後,計數器的值會再次被置為 0,然後執行緒會釋放鎖,並結束整個 park 操作。

park/unpark 原理總結

每個執行緒都會關聯一個 Parker 物件,每個 Parker 物件都各自維護了三個角色:計數器、互斥量、條件變數。

park 操作:

  1. 獲取當前執行緒關聯的 Parker 物件。
  2. 將計數器置為 0,同時檢查計數器的原值是否為 1,如果是則放棄後續操作。
  3. 在互斥量上加鎖。
  4. 在條件變數上阻塞,同時釋放鎖並等待被其他執行緒喚醒,當被喚醒後,將重新獲取鎖。
  5. 當執行緒恢復至執行狀態後,將計數器的值再次置為 0。
  6. 釋放鎖。

unpark 操作:

  1. 獲取目標執行緒關聯的 Parker 物件(注意目標執行緒不是當前執行緒)。
  2. 在互斥量上加鎖。
  3. 將計數器置為 1。
  4. 喚醒在條件變數上等待著的執行緒。
  5. 釋放鎖。

補充:jstack 命令和 kill 命令

jstack 命令會給 Java 虛擬機器程式傳送一個 SIGQUIT 訊號,當 Java 虛擬機器收到訊號後,會另起一個執行緒專門執行列印執行緒堆疊的任務。如圖,從 GDB 標籤頁中可以觀察到 SIGQUIT 訊號。

在 Linux 中使用 kill -3 命令也可以實現和 jstack 命令幾乎一樣的效果,這是因為 kill 命令本身就是一個用於給程式傳送訊號的工具,只不過預設傳送的是 SIGTERM 訊號(終止訊號),該訊號用於終止一個程式。可以通過 kill -l 命令檢視所有可用訊號,kill -3 表示傳送 SIGQUIT 訊號。

相關文章