從 JDK 原始碼角度看 java 併發的公平性

超人汪小建發表於2017-04-13

Java為簡化開發者開發提供了很多併發的工具,包括各種同步器,有了JDK我們只要學會簡單使用類API即可。但這並不意味著不需要探索其具體的實現機制,本文從JDK原始碼角度簡單講講併發時執行緒競爭的公平性。

所謂公平性指所有執行緒對臨界資源申請訪問許可權的成功率都一樣,不會讓某些執行緒擁有優先權。我們知道CLH Node FIFO等待佇列是一個先進先出的佇列,那麼是否就可以說每條執行緒獲取鎖時就是公平的呢?關於公平性這裡分拆成三個點分別闡述:

  1. 準備入佇列的節點,此情況討論的是執行緒加入等待佇列時產生的競爭是否公平,執行緒在嘗試獲取鎖失敗後將被加入等待佇列,這時多個執行緒通過自旋將節點加入佇列,所有執行緒在自旋過程中是無法保證其公平性的,可能後來的執行緒比早到的先進入佇列,所以節點入佇列不具公平性。
  2. 等待佇列中的節點,情況①中成功加入佇列後即成為等待佇列中的節點,我們知道此佇列是一個先入先出佇列,那麼很簡單能得到,佇列中的所有節點是公平的,他們都按照順序等待自己被前驅節點喚醒並獲取鎖,所以等待佇列中的節點具有公平性。
  3. 闖入的節點,這種情況是指一個新執行緒到達共享資源邊界時不管等待佇列中是否存在其他等待節點它都將優先嚐試去獲取鎖,這種稱為可闖入策略。可闖入特性破壞了公平性,JDK的AQS對外體現的公平性主要由此體現,下面將對闖入特性展開分析。

AQS提供的基礎獲取鎖演算法是一種可闖入的演算法,即如果有新執行緒到來先進行一次獲取嘗試,不成功的情況下才將當前執行緒加入等待佇列。如圖2-5-9-6所示,等待佇列中節點執行緒按照順序一個接一個嘗試去獲取共享資源的使用權,某時刻頭結點執行緒準備嘗試獲取的同時另外一條執行緒闖入,此執行緒並非直接加入等待佇列的尾部,而是先跟頭結點執行緒競爭獲取資源,闖入執行緒如果成功獲取共享資源則直接執行,頭結點執行緒則繼續等待下一次嘗試,如此一來闖入執行緒成功插隊,後來的執行緒比早到的執行緒先執行,說明AQS基礎獲取演算法是不嚴格公平的。

從 JDK 原始碼角度看 java 併發的公平性

基礎獲取演算法邏輯簡化如下:首先嚐試獲取鎖,假如獲取失敗才建立節點並加入到等待佇列的尾部,接著通過不斷迴圈檢查是否輪到自己執行,當然此過程為了提高效能可能將執行緒先掛起,最終由前驅節點喚醒。

if(嘗試獲取鎖失敗) {
    建立node
    使用CAS方式把node插入到佇列尾部
    while(true){
    if(嘗試獲取鎖成功 並且 node的前驅節點為頭節點){
把當前節點設定為頭節點
    跳出迴圈
}else{
    使用CAS方式修改node前驅節點的waitStatus標識為signal
    if(修改成功)
        掛起當前執行緒 
}
}複製程式碼

為什麼要使用闖入策略?可闖入的策略通常可以提供更高的總吞吐量。由於一般同步器顆粒度比較小,也可以說共享資源的範圍較小,而執行緒從阻塞狀態到被喚醒所消耗的時間週期可能是通過共享資源時間週期的幾倍甚至幾十倍,如此一來執行緒喚醒過程中將存在一個很大的時間週期空窗期,導致資源沒有得到充分利用,為了提高吞吐量,引入這種闖入策略,它可以使在等待佇列頭結點從阻塞到被喚醒的時間段內闖入的執行緒直接獲取鎖並通過同步器,以便充分利用喚醒過程這一空窗期,大大增加了吞吐率。另外,闖入機制的實現對外提供一種競爭調節機制,即開發者可以在自定義同步器中定義闖入嘗試獲取的次數,假設次數為n則不斷重複獲取直到n次都獲取不成功才把執行緒加入等待佇列中,隨著次數n的增加可以增大成功闖入的機率。同時,這種闖入策略可能導致等待佇列中的執行緒飢餓,因為鎖可能一直被闖入的執行緒獲取,但由於一般持有同步器的時間很短暫而避免飢餓的發生,反之如果保護的程式碼體很長並且持有同步器的時間較長,這將大大增加等待佇列無限等待的風險。

在實際情況中還是要根據使用者需求制定策略,在一個公平性要求很高的場景,則可以把闖入策略去除掉以達到公平。在自定義同步器中可以通過AQS預留方法tryAcquire方法實現,只需判斷當前執行緒是否為等待佇列中頭結點對應的執行緒,若不是則直接返回false,嘗試獲取失敗。但前面這種公平性是相對Java語法語義層面上的公平性,在現實中JDK的實現會直接影響執行緒執行的順序。

歡迎關注:

從 JDK 原始碼角度看 java 併發的公平性

相關文章