Java 鎖詳解

語言: CN / TW / HK

highlight: atom-one-dark theme: vuepress


這是我參與11月更文挑戰的第23天,活動詳情檢視:2021最後一次更文挑戰

1. 公平鎖 vs 非公平鎖

公平鎖:是指多個執行緒按照申請鎖的順序來獲取鎖,執行緒直接進入佇列中排隊,佇列中的第一個執行緒才能獲得鎖。類似排隊打飯,先來後到。

非公平鎖:是指多個執行緒獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的執行緒比先申請的執行緒優先獲取鎖。

比較

公平鎖,就是很公平,在併發環境中,每個執行緒在獲取鎖時會先檢視此鎖維護的等待佇列,如果為空,或者當前執行緒是等待佇列的第一個,就佔有鎖,否則就會加入到等待佇列中,以後會按照 FIFO 的規則從佇列中取到自己。

非公平鎖,比較粗魯,上來就直接嘗試佔有鎖,如果嘗試失敗,就再採用類似公平鎖的方式。

公平鎖的優點是等待鎖的執行緒不會餓死。

非公平鎖的優點在於吞吐量比公平鎖大。但在高併發的情況下,有可能會造成優先順序反轉飢餓現象

內窺

併發包中 ReentrantLock 的建立可以指定建構函式的 boolean 型別來得到公平鎖或非公平鎖,預設為非公平鎖。

檢視 ReentrantLock,可以看到有一個繼承自 AbstractQueuedSynchronizer 的內部類 Sync,新增鎖和釋放鎖的大部分操作實際上都是在 Sync 中實現的。它有公平鎖 FairSync 和非公平鎖 NonfairSync 兩個子類。

```java public class ReentrantLock implements Lock, java.io.Serializable { private static final long serialVersionUID = 7373984872572414699L; private final Sync sync;

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

abstract static class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = -5179523762034025860L;

    abstract void lock();
    //......
}

static final class NonfairSync extends Sync {
    //......
}

static final class FairSync extends Sync {
    //......
}

} ```

兩個構造方法對比,可以看出公平鎖和非公平鎖的區別

  • 非公平鎖在呼叫 lock() 後,首先就會通過 CAS 進行一次搶鎖,如果這個時候恰巧鎖沒有被佔用,那麼直接就獲取到鎖返回了,否則按公平鎖的方式去排隊,進入到阻塞佇列等待喚醒
  • 公平鎖在獲取同步狀態(獲取鎖)時 tryAcquire() 多了一個限制條件:!hasQueuedPredecessors() ,用來判斷當前執行緒是否位於同步佇列中的第一個

Synchronized關鍵字,也是一種非公平鎖。

2. 樂觀鎖 VS 悲觀鎖

樂觀鎖與悲觀鎖是一種廣義上的概念,體現了看待執行緒同步的不同角度。在 Java 和資料庫中都有此概念對應的實際應用。

  • 悲觀鎖是一種悲觀思想,它總認為自己在使用資料的時候一定有別的執行緒來修改,所以悲觀鎖在持有資料的時候總會把資源或資料鎖住,這樣其他執行緒想要請求這個資源的時候就會阻塞,直到等到悲觀鎖把資源釋放為止。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。悲觀鎖的實現往往依靠資料庫本身的鎖功能實現。

    Java 中,synchronized 關鍵字和 Lock 的實現類都是悲觀鎖。

  • 而樂觀鎖認為自己在使用資料時不會有別的執行緒修改資料,所以不會新增鎖,只是在更新資料的時候去判斷之前有沒有別的執行緒更新了這個資料。如果這個資料沒有被更新,當前執行緒將自己修改的資料成功寫入。如果資料已經被其他執行緒更新,則根據不同的實現方式執行不同的操作(例如報錯或者自動重試)

    樂觀鎖的實現方案一般來說有兩種: 版本號機制CAS實現

    Java 中 java.util.concurrent.atomic 包下面的原子變數類的遞增操作就是通過 CAS 實現了樂觀鎖。

比較

悲觀鎖:比較適合寫入操作比較頻繁的場景,如果出現大量的讀取操作,每次讀取的時候都會進行加鎖,這樣會增加大量的鎖的開銷,降低了系統的吞吐量。

樂觀鎖:比較適合讀取操作比較頻繁的場景,如果出現大量的寫入操作,資料發生衝突的可能性就會增大,為了保證資料的一致性,應用層需要不斷的重新獲取資料,這樣會增加大量的查詢操作,降低了系統的吞吐量。

悲觀鎖比較適合強一致性的場景,但效率比較低,特別是讀的併發低。樂觀鎖則適用於讀多寫少,併發衝突少的場景。

樂觀鎖常見問題: - ABA 問題 - 迴圈時間長開銷大 - 只能保證一個共享變數的原子操作

3. 可重入鎖(遞迴鎖)

可重入鎖又名遞迴鎖,是指在同一個執行緒在外層方法獲取鎖的時候,再進入該執行緒的內層方法會自動獲取鎖(前提鎖物件得是同一個物件或者class),不會因為之前已經獲取過還沒釋放而阻塞。

也就是說,執行緒可以進入任何一個它已經擁有的鎖同步著的程式碼塊。

可重入鎖的最大作用是可一定程度避免死鎖ReentrackLockSynchronized 就是典型的可重入鎖。

```java public class Wget { public synchronized void doSomething() { System.out.println("方法1執行..."); doOthers(); }

public synchronized void doOthers() {
    System.out.println("方法2執行...");
}

} ```

在上面的程式碼中,類中的兩個方法都是被內建鎖 synchronized 修飾的,doSomething() 方法中呼叫 doOthers() 方法。因為內建鎖是可重入的,所以同一個執行緒在呼叫 doOthers() 時可以直接獲得當前物件的鎖,進入doOthers() 進行操作。

如果是一個不可重入鎖,那麼當前執行緒在呼叫 doOthers() 之前需要將執行 doSomething() 時獲取當前物件的鎖釋放掉,實際上該物件鎖已被當前執行緒所持有,且無法釋放。所以此時會出現死鎖。

自旋鎖

自旋鎖是指嘗試獲取鎖的執行緒不會立即阻塞,而是採用迴圈的方式去嘗試獲取鎖,這樣的好處是減少執行緒上下文切換的消耗,缺點是迴圈會消耗 CPU

```java public class SpinLockDemo {

AtomicReference<Thread> lock = new AtomicReference<>();

public void myLock(){
    Thread thread = Thread.currentThread();
    //如果不為空,自旋
    while (!lock.compareAndSet(null,thread)){

    }
}

public void myUnlock(){
    Thread thread = Thread.currentThread();
    //解鎖後,將鎖置為 null
    lock.compareAndSet(thread,null);
}

} ```

優缺點

缺點:

  1. 如果某個執行緒持有鎖的時間過長,就會導致其它等待獲取鎖的執行緒進入迴圈等待,消耗CPU。使用不當會造成CPU 使用率極高。
  2. 上面 Java 實現的自旋鎖不是公平的,即無法滿足等待時間最長的執行緒優先獲取鎖。不公平的鎖就會存在“執行緒飢餓”問題。

優點:

  1. 自旋鎖不會使執行緒狀態發生切換,一直處於使用者態,即執行緒一直都是 active 的;不會使執行緒進入阻塞狀態,減少了不必要的上下文切換,執行速度快
  2. 非自旋鎖在獲取不到鎖的時候會進入阻塞狀態,從而進入核心態,當獲取到鎖的時候需要從核心態恢復,需要執行緒上下文切換。 (執行緒被阻塞後便進入核心(Linux)排程狀態,這個會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能)

可重入的自旋鎖和不可重入的自旋鎖

上邊寫的自旋鎖,仔細分析一下可以看出,它是不支援重入的,即當一個執行緒第一次已經獲取到了該鎖,在鎖釋放之前又一次重新獲取該鎖,第二次就不能成功獲取到。由於不滿足 CAS,所以第二次獲取會進入 while 迴圈等待,而如果是可重入鎖,第二次也是應該能夠成功獲取到的。

而且,即使第二次能夠成功獲取,那麼當第一次釋放鎖的時候,第二次獲取到的鎖也會被釋放,而這是不合理的。

為了實現可重入鎖,我們需要引入一個計數器,用來記錄獲取鎖的執行緒數。

自旋鎖與互斥鎖

  • 自旋鎖與互斥鎖都是為了實現保護資源共享的機制。
  • 無論是自旋鎖還是互斥鎖,在任意時刻,都最多隻能有一個保持者。
  • 獲取互斥鎖的執行緒,如果鎖已經被佔用,則該執行緒將進入睡眠狀態;獲取自旋鎖的執行緒則不會睡眠,而是一直迴圈等待鎖釋放。

總結:

  • 自旋鎖:執行緒獲取鎖的時候,如果鎖被其他執行緒持有,則當前執行緒將迴圈等待,直到獲取到鎖。
  • 自旋鎖等待期間,執行緒的狀態不會改變,執行緒一直是使用者態並且是活動的(active)。
  • 自旋鎖如果持有鎖的時間太長,則會導致其它等待獲取鎖的執行緒耗盡CPU。
  • 自旋鎖本身無法保證公平性,同時也無法保證可重入性。
  • 基於自旋鎖,可以實現具備公平性和可重入性質的鎖。

4. 獨佔鎖(互斥鎖/寫鎖)、共享鎖(讀鎖)

獨佔鎖:指該鎖一次只能被一個執行緒所持有,對 ReentrantLock 和 Synchronized 而言都是獨佔鎖

共享鎖:指該鎖可被多個執行緒所持有

對 ReentrantReadWriteLock 其讀鎖是共享鎖,其寫鎖是獨佔鎖。

讀鎖的共享鎖可保證併發讀是非常高效的,讀寫、寫讀、寫寫的過程是互斥的。

無鎖 VS 偏向鎖 VS 輕量級鎖 VS 重量級鎖

這四種鎖是指鎖的狀態,專門針對 synchronized 的。在介紹這四種鎖狀態之前還需要介紹一些額外的知識。

首先為什麼 Synchronized 能實現執行緒同步?

在回答這個問題之前我們需要了解兩個重要的概念:“Java物件頭”、“Monitor”。

無鎖

無鎖沒有對資源進行鎖定,所有的執行緒都能訪問並修改同一個資源,但同時只有一個執行緒能修改成功。

無鎖的特點就是修改操作在迴圈內進行,執行緒會不斷的嘗試修改共享資源。如果沒有衝突就修改成功並退出,否則就會繼續迴圈嘗試。如果有多個執行緒修改同一個值,必定會有一個執行緒能修改成功,而其他修改失敗的執行緒會不斷重試直到修改成功。上面我們介紹的CAS原理及應用即是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的效能是非常高的。

偏向鎖

偏向鎖是指一段同步程式碼一直被一個執行緒所訪問,那麼該執行緒會自動獲取鎖,降低獲取鎖的代價。

在大多數情況下,鎖總是由同一執行緒多次獲得,不存在多執行緒競爭,所以出現了偏向鎖。其目標就是在只有一個執行緒執行同步程式碼塊時能夠提高效能。

當一個執行緒訪問同步程式碼塊並獲取鎖時,會在Mark Word裡儲存鎖偏向的執行緒ID。線上程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裡是否儲存著指向當前執行緒的偏向鎖。引入偏向鎖是為了在無多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。

偏向鎖只有遇到其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,執行緒不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有位元組碼正在執行),它會首先暫停擁有偏向鎖的執行緒,判斷鎖物件是否處於被鎖定狀態。撤銷偏向鎖後恢復到無鎖(標誌位為“01”)或輕量級鎖(標誌位為“00”)的狀態。

偏向鎖在JDK 6及以後的JVM裡是預設啟用的。可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程式預設會進入輕量級鎖狀態。

輕量級鎖

是指當鎖是偏向鎖的時候,被另外的執行緒所訪問,偏向鎖就會升級為輕量級鎖,其他執行緒會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高效能。

在程式碼進入同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝,然後拷貝物件頭中的Mark Word複製到鎖記錄中。

拷貝成功後,虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標,並將Lock Record裡的owner指標指向物件的Mark Word。

如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,表示此物件處於輕量級鎖定狀態。

如果輕量級鎖的更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行,否則說明多個執行緒競爭鎖。

若當前只有一個等待執行緒,則該執行緒通過自旋進行等待。但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級為重量級鎖。

重量級鎖

升級為重量級鎖時,鎖標誌的狀態值變為“10”,此時Mark Word中儲存的是指向重量級鎖的指標,此時等待鎖的執行緒都會進入阻塞狀態。

綜上,偏向鎖通過對比Mark Word解決加鎖問題,避免執行CAS操作。而輕量級鎖是通過用CAS操作和自旋來解決加鎖問題,避免執行緒阻塞和喚醒而影響效能。重量級鎖是將除了擁有鎖的執行緒以外的執行緒都阻塞。