Java併發之顯式鎖

程式設計師小牧之發表於2020-12-08

一.概述

在Java 5.0之前,在協調對共享物件的訪問時可以使用的機制只有synchronized和volatile,我們的選擇不多。
但在Java5.0 增加了一種新的機制:ReentratLock ,顯式鎖。 它並不是一種替代內建加鎖的方法,而是當內建加鎖機制不適用時,作為一種可選擇的高階功能。

二.具體學習

1.Lock與ReentrantLock

1)Lock是個介面,其中定義了一組抽象的加鎖操作:

public interface Lock{
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long timeout , TimeUnit unit) throws InterruptedException;
  void unlock();
  Condition newCondition();
}

從上面程式碼我們可以看出:與內建加鎖機制不同的是,Lock提供了一種無條件的,可輪詢的,定時的以及可中斷的鎖獲取操作,所有的加鎖和解鎖都是顯式的。
注意:在Lock的實現中必須提供與內部鎖相同的記憶體可見性語義,但在加鎖語義,排程演算法,順序保證以及效能特性等方面可以有所不同。

2)ReentrantLock是個類,它實現了Lock介面,並提供了與synchronized相同的互斥性和記憶體可見性。
在獲取ReentrantLock時,有著與進入同步程式碼塊相同的記憶體語義,在釋放ReentrantLock時,有著與退出同步程式碼塊相同的記憶體語義。
此外,與synchronized一樣,ReentrantLock提供了可重入的加鎖語義,ReentrantLock支援在Lock介面中定義的所有獲取鎖模式,與synchronized相比,它還為處理鎖的不可用性問題提供了更高的靈活性。

3)有些人可能會想:我覺得內建鎖就完全夠用了,幹嘛還要加個顯式鎖?
是的,在大多數情況下,內建鎖能很好的滿足我們。但是它在功能上存在一些的侷限,例如:內建鎖無法中斷一個正在等待獲取鎖的執行緒,或者無法在請求獲取一個鎖時無限地等待下去。

我們知道內建鎖必須在獲取該鎖地程式碼塊中釋放,這雖然簡化了編碼工作並且與異常處理操作是實現了很好的互動,但卻無法實現非阻塞結構的加鎖規則。

在某些情況下,使用一種更靈活的加鎖機制(顯式鎖)通常能提供更好的活躍性或效能。

4)如何使用顯式鎖?
下面程式碼展示瞭如何使用顯式鎖來保護物件狀態:

Lock lock = new ReentrantLock();
...
lock.lock();
try{
  //更新物件狀態
  //捕獲異常,並在必要時恢復不變性條件
}finally{
   lock.unlock();
}

注意:顯式鎖使用的關鍵時在最後在 finally 中釋放鎖。如果沒有釋放,將會造成嚴重的後果。

2.輪詢鎖和定時鎖
可定時的與可輪詢的鎖獲取模式是由tryLock方法實現的,與無條件的鎖獲取模式相比,它具有更加完善的錯誤恢復機制。
在內建鎖中,死鎖是一個嚴重的問題,恢復程式的唯一方法是重新啟動程式,而防止死鎖的唯一方法就是在構造程式的過程中避免出現不一致的鎖順序。 而可定時的與可輪詢的鎖提供了另一種可以避免死鎖發生的選擇。

1)如果一個執行緒不能獲得所需要的鎖,那麼可以使用可定時的或可輪詢的鎖獲取方式,從而使它重新獲得控制權,它會釋放已經獲得的鎖,然後重新嘗試獲取所有的鎖。

下面我們利用這個策略來解決前面的動態順序死鎖的問題(可以參看動態順序死鎖問題):
下面程式碼無需摳細節,只需知道大概的思路即可

public boolean transferMoney(Account fromAcct,
                            Account toAcct,
                            DollarAmount amount,
                            long timeout,
                            TiemUnit unit) throws InsufficientFundsException , InterruptedException{

  long fixedDelay = getFixedDelayComponentNanos(timeout,unit);
  long randMod = getRandomDelayModulusNanos(timeout,unit);
  long stopTime = System.nanoTime() + unit.toNanos(timeout);

  while(true){
      if(fromAcct.lock.tryLock()){//嘗試獲得fromAcct鎖
        try{
           if(toAcct.lock.tryLock()){//嘗試獲得toAcct鎖
             try{
               if(fromAcct.getBalance().compareTo(amount)<0) 
                     throw new InsufficientFundsException();
               else{
                  fromAcct.debit(amount);
                  toAcct.credit(amount);
                  return true;
               }
             }finally{
                 toAcct.lock.unlock();//釋放toAcct鎖
             }
           }
        }finally{
          fromAcct.lock.unlock();//釋放fromAcct鎖
        }
      }
      
      if(System.nanoTime()<stopTime)//如果超出了時間限制
         return false;//返回失敗
      NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);//睡眠隨機的時間
  }
}

上面程式碼使用tryLock來獲取兩個鎖,如果不能同時獲得,那麼就回退並重新嘗試,在休眠時間中包括固定部分和隨機部分,從而降低活鎖的可能性。如果在指定時間內不能獲得所需要的鎖,那麼返回失敗狀態。

2)在實現具有時間限制的操作時,定時鎖同樣非常有用。 當在帶有時間限制的操作中呼叫了一個阻塞方法時,它能根據剩餘時間來提供一個時限,如果操作不能在指定的時間內給出結果,那麼就會讓程式提前結束。 當使用內建鎖時,在開始請求鎖後,這個操作將無法取消,因此內建鎖很難實現帶有時間限制的操作。

下面程式碼試圖在Lock保護的共享通訊線路上傳送一條訊息,如果不能在指定時間內完成,程式碼就會失敗。定時的tryLock能夠在這種帶有時間限制的操作中實現獨佔加鎖行為:

public boolean trySendOnSharedLine(String message,long timeout,TimeUnit unit) 
                                                             throws InterruptedException{
   long nanosToLock = unit.toNanos(timeout)- estimatedNanosToSend(message);
   if(!lock.tryLock(nanosToLock,NANOSECONDS))
       return false;
   try{
       return sendOnSharedLine(message);
   }finally{
     lock.unlock();
   }                                                         
}

3.可中斷的顯式鎖獲取操作
如下:

public boolean sendOnSharedLine(String message) throws InterruptedException{
    lock.lockInterruptibly();//獲取可中斷的顯式鎖
    try{
      return candellableSendOnSharedLine(message);
    }finally{
      lock.unlock();
    }
}

private boolean cancellableSendOnSharedLine(String messgae) throws InterruptedException{...}

正如定時的鎖獲取操作能在帶有時間限制的操作中使用獨佔鎖,可中斷的鎖獲取操作同樣能在可取消的操作中使用加鎖。
lockInterruptibly方法能夠在獲得鎖的同時保持對中斷的響應,並且由於它包含在Lock中,因此無須建立其他型別的不可中斷阻塞機制。

4.非塊結構的加鎖
在內建鎖中,鎖的獲取和釋放等操作都是基於程式碼塊的(釋放鎖的操作總是與獲取鎖的操作處於同一個程式碼塊,而不考慮控制權如何退出該程式碼塊)。內建鎖的這種鎖釋放操作簡化了對程式的分析,避免了可能的編碼錯誤,但有時候我們需要更靈活的加鎖規則來實現更加複雜的功能,這就是顯式鎖的非塊式結構的加鎖。

5.效能考慮因素
當時,把ReentrantLock新增到Java5.0時,它能比內建鎖提供更好的競爭效能。
對於同步原語來說,競爭效能是可伸縮性的關鍵要素:如果有越多的資源被耗費在鎖的管理和排程上,那麼應用程式得到的資源就越少。 鎖的實現方式越好,將需要越少的系統呼叫和上下文切換,並且在共享記憶體匯流排上的記憶體同步通訊量也越少,而一些耗時的操作將佔用應用程式的計算資源。

但Java 6 使用了改進後的演算法來管理內建鎖,與在ReentrantLock中使用的演算法類似,該演算法有效地提高了可伸縮性。並且達到了與顯式鎖幾乎相同的競爭效能。

所以說:效能是一個不斷變化的指標,如果在昨天的測試基準中發現X比Y更快,那麼在今天就可能已經過時了。

6.公平性
在ReentrantLock的建構函式中提供了兩種公平性選擇:建立一個非公平的鎖或者一個公平的鎖。
1)在公平的鎖上,執行緒將按照它們發出請求的順序來獲得鎖,但在非公平的鎖上,則允許 “插隊” :當一個執行緒請求非公平的鎖時,如果在發出請求的同時該鎖的狀態變為可用,那麼這個執行緒將跳過佇列中所有等待執行緒並獲得這個鎖。

2)非公平的ReentrantLock並不提倡 “插隊行為 ” ,但它無法防止某個執行緒在合適的時候進行 “插隊”。

3)在公平的鎖中,如果有另一個執行緒持有這個鎖或者有其他執行緒在佇列中等待這個鎖,那麼新發出請求的執行緒將被放入佇列。在非公平的鎖中,只有當鎖被某個執行緒持有時,新發出請求的執行緒才會被放入佇列中。

注意:在大多數情況下,非公平鎖的效能要高於公平鎖的效能。所以不必要的話,不要為公平性付出代價

在競爭激烈的情況下,非公平鎖的效能高於公平鎖的效能的一個原因是:在恢復一個被掛起的執行緒與該執行緒真正開始執行之間存在著嚴重的延遲。 假設執行緒A持有一個鎖,並且執行緒B請求這個鎖。由於這個鎖已被執行緒A持有,因此B將被掛起。當A釋放鎖時,B將被喚醒,因此會再此嘗試獲取鎖,與此同時,如果C也請求這個鎖,那麼C很可能會在B完全喚醒之前獲得,使用以及釋放這個鎖。這樣的情況是一種 “雙贏” 的局面:B獲得鎖的時刻並沒有推遲,C更早地獲得了鎖,並且吞吐量也獲得了提高。

注意:當持有鎖的時間相對較長,或者請求鎖的平均時間間隔較長,那麼應該使用公平鎖。

與預設的ReentrantLock一樣,內建加鎖並不會提供確定的公平性保證,但在大多數情況下,在鎖實現上實現統計上的公平性保證已經足夠了。Java語言規範並沒有要求JVM以公平的方式來實現內建鎖,而在各種JVM中也沒有這樣做。ReentrantLock並沒有進一步降低鎖的公平性,而只是使一些已經存在的內容更明顯。

7.如何看待synchronized和ReentrantLock
與顯式鎖相比,內建鎖仍然具有很大的優勢。內建鎖為許多開發人員所熟悉,並且簡潔緊湊,而且在許多現有的程式中都已經使用了內建鎖(如果將這兩種機制混合使用,那麼不僅容易令人困惑,也容易發生錯誤)。

ReentrantLock的危險性比同步機制要高,如果忘記在finally塊中呼叫unlock,那麼雖然程式碼表面上能正常執行,但實際上非常危險。
所以:僅當內建鎖不能滿足需求時,才考慮使用顯式鎖。

注意:在一些內建鎖無法滿足需求的情況下,ReentrantLock可以作為一種高階工具,當需要一些高階功能時才應該使用ReentrantLock,這些功能包括:可定時的,可輪詢的,可中斷的鎖獲取操作,公平佇列,非塊結構的鎖。
否則,還是應該使用synchronized

相關文章