如何理解Java中眼花繚亂的各種並行鎖?

2022-11-14 21:00:34

在網際網路公司面試中,很多小夥伴都被問到過關於鎖的問題。
今天,我給大家一次性把Java並行鎖的全家桶徹底講明白。包括互斥鎖、讀寫鎖、重入鎖、公平鎖、悲觀鎖、自旋鎖、偏向鎖等等等等。視訊有點長,大家一定要全部看完,保證你會醍醐灌頂。

1、鎖的由來

在並行程式設計中,經常會遇到兩個以上的執行緒存取同一個共用變數,當同時對共用變數進行讀寫操作時,就會產生資料不一致的情況。

隨著執行緒並行技術的發展,在多執行緒環境中,對執行緒存取資源的限制也越來越多。為了保證資源獲取的有序性和佔用性,都是通過並行鎖來控制的。

2、鎖的應用場景

下面,我根據個人經驗以及並行場景下執行緒的處理邏輯,總結為以下7個場景,不同場景使用不同的鎖。

1)某個執行緒是否鎖住同步資源的情況
如果要鎖住同步資源則使用悲觀鎖,不鎖住同步資源使用樂觀鎖。
所謂悲觀鎖,就是每次拿資料的時候都認為會有別人修改,所以在讀資料的時候都會上鎖,其他執行緒資料就會阻塞,直到拿到鎖。

舉個例子,假設廁所只有一個坑位,悲觀鎖就是上廁所會第一時間把門反鎖上,這樣其他人上廁所只能在門外等候,這就是阻塞。

而樂觀鎖就是開著門,當然在這個場景下一般也不會這麼做。所以,樂觀鎖,就是每次拿資料的時候都假設為別人不會修改,所以不會上鎖;只是在更新資料的時候去判斷之前有沒有別的執行緒更新了這個資料。如果這個資料沒有被更新,當前執行緒將自己修改的資料成功寫入。如果資料已經被其他執行緒更新了,要麼報錯,要麼自動重試。

樂觀鎖與悲觀鎖是一種廣義上的概念,沒有誰優誰劣。樂觀鎖適用於寫少讀多的場景,因為不用上鎖、釋放鎖,省去了鎖的開銷,從而提升了吞吐量。
而悲觀鎖適用於寫多讀少的場景,因為執行緒間競爭激勵,如果使用樂觀鎖會導致執行緒不斷進行重試,這樣反而還降低了效能。

2)多個執行緒是否共用一把鎖的情況
如果在並行情況下,多個執行緒共用一把鎖就是使用共用鎖,如果不能共用一把鎖就是排它鎖或者叫獨佔鎖、獨享鎖。
共用鎖是指鎖可被多個執行緒所持有。如果一個執行緒對資料加上共用鎖後,那麼其他執行緒只能對資料再加共用鎖,不能加獨佔鎖。獲得共用鎖的執行緒只能讀資料,不能修改資料。

在 JDK 中 ReentrantReadWriteLock 就是一種共用鎖。
而獨佔鎖是指鎖一次只能被一個執行緒所持有。如果一個執行緒對資料加上排他鎖後,那麼其他執行緒不能再對該資料加任何型別的鎖。獲得獨佔鎖的執行緒即能讀資料又能修改資料。

JDK中的synchronized和J.U.C(java.util.concurrent)包中Lock的實現類都是獨佔鎖。
另外,互斥鎖是獨佔鎖的一種常規實現,是指某一資源同時只允許一個存取者對其進行存取,具有唯一性和排它性。

互斥鎖一次只能一個執行緒擁有互斥鎖,其他執行緒只有等待。
而讀寫鎖是共用鎖的一種具體實現。讀寫鎖管理一組鎖,一個是唯讀的鎖,一個是寫鎖。
讀鎖可以在沒有寫鎖的時候被多個執行緒同時持有,而寫鎖是獨佔的。寫鎖的優先順序要高於讀鎖,一個獲得了讀鎖的執行緒必須能看到前一個釋放的寫鎖所更新的內容。
讀寫鎖相比於互斥鎖並行程度更高,每次只有一個寫執行緒,但是同時可以有多個執行緒並行讀。

在 JDK 中定義了一個讀寫鎖的介面ReadWriteLock,如原始碼所示:

public interface ReadWriteLock {    
  /* 獲取讀鎖 */   
  Lock readLock();     
  /* 獲取寫鎖 */    
  Lock writeLock();
} 

ReentrantReadWriteLock 實現了ReadWriteLock介面,ReentrantReadWriteLock 支援鎖降級不支援鎖升級,可以由寫鎖降為讀鎖。

3)多個執行緒競爭時是否要排隊的情況
多個執行緒競爭排隊獲取鎖的情況,使用公平鎖,如果,使用非公平鎖。
所謂公平鎖是指多個執行緒按照申請鎖的順序來獲取鎖,這裡類似排隊買票,先來的人先買,後來的人在隊尾排著,這是公平的。

在 Java 中可以通過建構函式初始化公平鎖,如程式碼所示:

/**
  * 建立一個可重入鎖,
  * true 表示公平鎖,
  * false 表示非公平鎖。
  * 預設非公平鎖
 */
Lock lock = new ReentrantLock(true); 

非公平鎖是指多個執行緒獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的執行緒比先申請的執行緒優先獲取鎖,在高並行環境下,有可能造成優先順序翻轉,或者某個執行緒一直得不到鎖的飢餓狀態。

在 Java 中 synchronized 關鍵字是非公平鎖,ReentrantLock預設也是非公平鎖,如程式碼所示:

/*** 建立一個可重入鎖,true 表示公平鎖,false 表示非公平鎖。預設非公平鎖*/
Lock lock = new ReentrantLock(false);

4)一個執行緒中的多個流程,是否獲取同一把鎖的情況
如果一個執行緒中的多個流程能獲取同一把鎖,就使用可重入鎖,如果執行緒的多個流程不能獲取通一把鎖,就是用不可重入鎖。
可重入鎖又稱為遞迴鎖,是指同一個執行緒在外層方法獲取了鎖,在進入內層方法會自動獲取鎖。

對於Java ReentrantLock而言, 他的名字就可以看出是一個可重入鎖。對於synchronized而言,也是一個可重入鎖。可重入鎖的一個好處是可一定程度避免死鎖。以 synchronized 為例,來看這樣一段程式碼:

public synchronized void mehtodA() throws Exception{ 
  // Do some magic tings 
  mehtodB();
} 
public synchronized void mehtodB() throws Exception{ 
  // Do some magic tings
} 

在這段程式碼中 methodA() 呼叫 methodB(),如果一個執行緒呼叫methodA() 已經獲取了鎖再去呼叫 methodB() 就不需要再次獲取鎖了,這就是可重入鎖的特性。如果是不可重入鎖的話,mehtodB() 可能不會被當前執行緒執行,可能造成死鎖。

5)某個執行緒鎖住同步資源失敗,是否不阻塞的情況
如果某個執行緒鎖住同步資源失敗,但是希望這個執行緒不阻塞,就可以使用自旋鎖或者自適應自旋鎖。
自旋鎖是指執行緒在沒有獲得鎖時不是被直接掛起,而是執行一個忙迴圈,這個忙迴圈就是所謂的自旋。

自旋鎖的目的是為了減少執行緒被掛起的機率,因為執行緒的掛起和喚醒也都是耗資源的操作。
如果鎖被另一個執行緒佔用的時間比較長,即使自旋了之後當前執行緒還是會被掛起,忙迴圈就會變成浪費系統資源的操作,反而降低了整體效能。因此自旋鎖是不適應鎖佔用時間長的並行情況的。
在 Java 中,AtomicInteger 類就有自旋的操作,來看這樣一段程式碼:

public final int getAndAddInt(Object o, long offset, int delta) {    
  int v;    
  do {        
    v = getIntVolatile(o, offset);    
  } while (!compareAndSwapInt(o, offset, v, v + delta));    
  return v;
} 

迴圈條件呼叫compareAndSwapInt()方法,被稱為CAS操作,如果失敗就會一直迴圈獲取當前 value 值然後重試,這個過程叫自旋。
在JDK1.6引入了自適應自旋,這個就比較智慧了,自旋時間不再固定,由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定。如果虛擬機器器認為這次自旋也很有可能再次成功那就會次序較多的時間,如果自旋很少成功,那以後可能就直接省略掉自旋過程,避免浪費處理器資源。

6)執行緒競爭同步資源時,細節流程是否發生變化的情況
JDK1.6 為了提升效能減少獲得鎖和釋放鎖所帶來的消耗,引入了4種鎖的狀態:無鎖、偏向鎖、輕量級鎖和重量級鎖,它會隨著多執行緒的競爭情況逐漸升級,但不能降級。

如果多個執行緒中,只有一個執行緒能修改資源成功,其他資源只是重試,不鎖住資源,稱為無鎖狀態,其實就是樂觀鎖。
第一個執行緒存取加鎖的資源自動獲取鎖,不存在多執行緒競爭的情況,資源偏向於第一個存取鎖的執行緒,每次存取執行緒不需要重複獲取鎖,這種狀態稱為偏向鎖。偏向鎖的實現是通過控制物件Mark Word的標誌位來實現的,如果當前是可偏向狀態,需要進一步判斷物件頭儲存的執行緒 ID 是否與當前執行緒 ID 一致,如果一致直接進入。
當執行緒競爭變得比較激烈時,偏向鎖就會升級為輕量級鎖,輕量級鎖認為雖然競爭是存在的,但是理想情況下競爭的程度很低,通過自旋方式等待上一個執行緒釋放鎖。
但如果執行緒並行進一步加劇,執行緒的自旋超過了一定次數,或者一個執行緒持有鎖,一個執行緒在自旋,又來了第三個執行緒存取的時候,輕量級鎖就會膨脹為重量級鎖,重量級鎖會使除了當時擁有鎖的執行緒以外的所有執行緒都阻塞。
升級到重量級鎖其實就是互斥鎖了,一個執行緒拿到鎖,其餘執行緒都會處於阻塞等待狀態。
在 Java 中,synchronized 關鍵字內部實現原理就是這樣一個鎖升級的過程。

7)最後,就是鎖再設計和鎖優化的一些情況
先來看分段鎖,它是一種鎖的再次設計,並不是具體的一種鎖。
分段鎖設計目的是將鎖的粒度進一步細化,當操作不需要更新整個陣列的時候,就僅僅針對陣列中的一項進行加鎖操作。

在 Java 語言中 CurrentHashMap 底層使用分段鎖Segment,來支援多執行緒並行操作。
另外,就是鎖優化,包括鎖消除、鎖粗化。
鎖粗化就是將多個同步塊的數量減少,並將單個同步塊的作用範圍擴大,本質上就是將多次上鎖、解鎖的請求合併為一次同步請求。
舉個例子,一個迴圈體中有一個程式碼同步塊,每次迴圈都會執行加鎖解鎖操作。如程式碼所示:

private static final Object LOCK = new Object(); 
for(int i = 0;i < 100; i++) {    
  synchronized(LOCK){        
    // do some magic things    
  }
} 

經過鎖粗化後,就變成下面這個樣子:

 synchronized(LOCK){     
   for(int i = 0;i < 100; i++) {       
     // do some magic things    
   }
 } 

鎖消除是指虛擬機器器編譯器在執行時檢測到了共用資料沒有競爭的鎖,從而將這些鎖進行消除。舉個例子讓大家更好理解,來看這樣一段程式碼:

public String test(String s1, String s2){    
  StringBuffer stringBuffer = new StringBuffer();    
  stringBuffer.append(s1);    
  stringBuffer.append(s2);    
  return stringBuffer.toString();
} 

上面程式碼中的test() 方法,主要作用是將字串 s1 和字串 s2 串聯起來。
test() 方法中的三個變數s1, s2,和StringBuffer都是區域性變數,區域性變數是儲存在棧上的,而棧又是執行緒私有的,所以就算有多個執行緒存取 test() 方法也是執行緒安全的。
我們都知道 StringBuffer 是執行緒安全的類,因為append()方法是同步方法,如原始碼所示:

 // append 是同步方法
 public synchronized StringBuffer append(String str) {    
  toStringCache = null;    
  super.append(str);    
  return this;
} 

但是 test() 方法本來就是執行緒安全的,為了提升效率,虛擬機器器自動幫我們消除了這些同步鎖,這個過程就被稱為鎖消除。

3、總結

好了,前面講了這麼多,相信大家已經理解了各種眼花繚亂的鎖。最後,我用一張腦圖完整地總結了各種鎖的應用場景。大家可以在面試前拿出來看看,只要是被問到鎖相關的問題,相信你一定能夠吊打面試官了。

腦圖分享連結:https://www.processon.com/view/link/633412ea07912955b20d7938
最後,分享幾個關於鎖的高頻面試題,看看大家能不能回答出來

1、ReentrantLock與synchronized 的區別
2、synchronized和volatile的區別
3、synchronized和lock的區別
4、什麼是死鎖以及如何避免死鎖問題
如果你回答不出來,可以去我的主頁看看,這些面試題在往期的視訊中都有分享過。

我是被程式設計耽誤的文藝Tom,如果我的分享對你有幫助,請動動手指一鍵三連分享給更多的人。關注我,面試不再難!