1. 程式人生 > >ReentrantLock 原始碼分析從入門到入土

ReentrantLock 原始碼分析從入門到入土

回答一個問題

在開始本篇文章的內容講述前,先來回答我一個問題,為什麼 JDK 提供一個 synchronized 關鍵字之後還要提供一個 Lock 鎖,這不是多此一舉嗎?難道 JDK 設計人員都是沙雕嗎?

我聽過一句話非常的經典,也是我認為是每個人都應該瞭解的一句話:你以為的並不是你以為的。明白什麼意思麼?不明白的話,加我微信我告訴你。

初識 ReentrantLock

ReentrantLock 位於 java.util.concurrent.locks 包下,它實現了 Lock 介面和 Serializable 介面。

ReentrantLock 是一把可重入鎖互斥鎖,它具有與 synchronized 關鍵字相同的含有隱式監視器鎖(monitor)的基本行為和語義,但是它比 synchronized 具有更多的方法和功能。

ReentrantLock 基本方法

構造方法

ReentrantLock 類中帶有兩個建構函式,一個是預設的建構函式,不帶任何引數;一個是帶有 fair 引數的建構函式

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

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

第二個建構函式也是判斷 ReentrantLock 是否是公平鎖的條件,如果 fair 為 true,則會建立一個公平鎖的實現,也就是 new FairSync()

,如果 fair 為 false,則會建立一個 非公平鎖的實現,也就是 new NonfairSync(),預設的情況下建立的是非公平鎖

// 建立的是公平鎖
private ReentrantLock lock = new ReentrantLock(true);

// 建立的是非公平鎖
private ReentrantLock lock = new ReentrantLock(false);

// 預設建立非公平鎖
private ReentrantLock lock = new ReentrantLock();

FairSync 和 NonfairSync 都是 ReentrantLock 的內部類,繼承於 Sync

類,下面來看一下它們的繼承結構,便於梳理。

abstract static class Sync extends AbstractQueuedSynchronizer {...}

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

在多執行緒嘗試加鎖時,如果是公平鎖,那麼鎖獲取的機會是相同的。否則,如果是非公平鎖,那麼 ReentrantLock 則不會保證每個鎖的訪問順序。

下面是一個公平鎖的實現

public class MyFairLock extends Thread{

    private ReentrantLock lock = new ReentrantLock(true);
    public void fairLock(){
        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName()  + "正在持有鎖");
        }finally {
            System.out.println(Thread.currentThread().getName()  + "釋放了鎖");
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        MyFairLock myFairLock = new MyFairLock();
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread().getName() + "啟動");
            myFairLock.fairLock();
        };
        Thread[] thread = new Thread[10];
        for(int i = 0;i < 10;i++){
            thread[i] = new Thread(runnable);
        }
        for(int i = 0;i < 10;i++){
            thread[i].start();
        }
    }
}

不信?不信你輸出試試啊!懶得輸出?就知道你懶得輸出,所以直接告訴你結論吧,結論就是自己試

試完了嗎?試完了我是不會讓你休息的,過來再試一下非公平鎖的測試和結論,知道怎麼試嗎?上面不是講過要給 ReentrantLock 傳遞一個引數的嗎?你想,傳 true 的時候是公平鎖,那麼反過來不就是非公平鎖了?其他程式碼還用改嗎?不需要了啊。

明白了吧,再來測試一下非公平鎖的流程,看看是不是你想要的結果。

公平鎖的加鎖(lock)流程詳解

通常情況下,使用多執行緒訪問公平鎖的效率會非常低(通常情況下會慢很多),但是 ReentrantLock 會保證每個執行緒都會公平的持有鎖,執行緒飢餓的次數比較小。鎖的公平性並不能保證執行緒排程的公平性。

此時如果你想了解更多的話,那麼我就從原始碼的角度跟你聊聊如何 ReentrantLock 是如何實現這兩種鎖的。

如上圖所示,公平鎖的加鎖流程要比非公平鎖的加鎖流程簡單,下面要聊一下具體的流程了,請小夥伴們備好板凳。

下面先看一張流程圖,這張圖是 acquire 方法的三條主要流程

首先是第一條路線,tryAcquire 方法,顧名思義嘗試獲取,也就是說可以成功獲取鎖,也可以獲取鎖失敗。

使用 ctrl+左鍵 點進去是呼叫 AQS 的方法,但是 ReentrantLock 實現了 AQS 介面,所以呼叫的是 ReentrantLock 的 tryAcquire 方法;

首先會取得當前執行緒,然後去讀取當前鎖的同步狀態,還記得鎖的四種狀態嗎?分別是 無鎖、偏向鎖、輕量級鎖和重量級鎖,如果你不是很明白的話,請參考博主這篇文章(不懂什麼是鎖?看看這篇你就明白了),如果判斷同步狀態是 0 的話,就證明是無鎖的,參考下面這幅圖( 1bit 表示的是是否偏向鎖 )

如果是無鎖(也就是沒有加鎖),說明是第一次上鎖,首先會先判斷一下佇列中是否有比當前執行緒等待時間更長的執行緒(hasQueuedPredecessors);然後通過 CAS 方法原子性的更新鎖的狀態,CAS 方法更新的要求涉及三個變數,currentValue(當前執行緒的值),expectedValue(期望更新的值),updateValue(更新的值),它們的更新如下

if(currentValue == expectedValue){
  currentValue = updateValue
}

CAS 通過 C 底層機制保證原子性,這個你不需要考慮它。如果既沒有排隊的執行緒而且使用 CAS 方法成功的把 0 -> 1 (偏向鎖),那麼當前執行緒就會獲得偏向鎖,記錄獲取鎖的執行緒為當前執行緒。

然後我們看 else if 邏輯,如果讀取的同步狀態是1,說明已經執行緒獲取到了鎖,那麼就先判斷當前執行緒是不是獲取鎖的執行緒,如果是的話,記錄一下獲取鎖的次數 + 1,也就是說,只有同步狀態為 0 的時候是無鎖狀態。如果當前執行緒不是獲取鎖的執行緒,直接返回 false。

acquire 方法會先檢視同步狀態是否獲取成功,如果成功則方法結束返回,也就是 !tryAcquire == false ,若失敗則先呼叫 addWaiter 方法再呼叫 acquireQueued 方法

然後看一下第二條路線 addWaiter

這裡首先把當前執行緒和 Node 的節點型別進行封裝,Node 節點的型別有兩種,EXCLUSIVESHARED ,前者為獨佔模式,後者為共享模式,具體的區別我們會在 AQS 原始碼討論,這裡讀者只需要知道即可。

首先會進行 tail 節點的判斷,有沒有尾節點,其實沒有頭節點也就相當於沒有尾節點,如果有尾節點,就會原子性的將當前節點插入同步佇列中,再執行 enq 入隊操作,入隊操作相當於原子性的把節點插入佇列中。

如果當前同步佇列尾節點為null,說明當前執行緒是第一個加入同步佇列進行等待的執行緒。

在看第三條路線 acquireQueued

主要會有兩個分支判斷,首先會進行無限迴圈中,迴圈中每次都會判斷給定當前節點的先驅節點,如果沒有先驅節點會直接丟擲空指標異常,直到返回 true。

然後判斷給定節點的先驅節點是不是頭節點,並且當前節點能否獲取獨佔式鎖,如果是頭節點並且成功獲取獨佔鎖後,佇列頭指標用指向當前節點,然後釋放前驅節點。如果沒有獲取到獨佔鎖,就會進入 shouldParkAfterFailedAcquireparkAndCheckInterrupt 方法中,我們貼出這兩個方法的原始碼

shouldParkAfterFailedAcquire 方法主要邏輯是使用compareAndSetWaitStatus(pred, ws, Node.SIGNAL)使用CAS將節點狀態由 INITIAL 設定成 SIGNAL,表示當前執行緒阻塞。當 compareAndSetWaitStatus 設定失敗則說明 shouldParkAfterFailedAcquire 方法返回 false,然後會在 acquireQueued 方法中死迴圈中會繼續重試,直至compareAndSetWaitStatus 設定節點狀態位為 SIGNAL 時 shouldParkAfterFailedAcquire 返回 true 時才會執行方法 parkAndCheckInterrupt 方法。(這塊在後面研究 AQS 會細講)

parkAndCheckInterrupt 該方法的關鍵是會呼叫 LookSupport.park 方法(關於LookSupport會在以後的文章進行討論),該方法是用來阻塞當前執行緒。

所以 acquireQueued 主要做了兩件事情:如果當前節點的前驅節點是頭節點,並且能夠獲取獨佔鎖,那麼當前執行緒能夠獲得鎖該方法執行結束退出

如果獲取鎖失敗的話,先將節點狀態設定成 SIGNAL,然後呼叫 LookSupport.park 方法使得當前執行緒阻塞。

如果 !tryAcquireacquireQueued 都為 true 的話,則打斷當前執行緒。

那麼它們的主要流程如下(注:只是加鎖流程,並不是 lock 所有流程)

非公平鎖的加鎖(lock)流程詳解

非公平鎖的加鎖步驟和公平鎖的步驟只有兩處不同,一處是非公平鎖在加鎖前會直接使用 CAS 操作設定同步狀態,如果設定成功,就會把當前執行緒設定為偏向鎖的執行緒;一處是 CAS 操作失敗執行 tryAcquire 方法,讀取執行緒同步狀態,如果未加鎖會使用 CAS 再次進行加鎖,不會等待 hasQueuedPredecessors 方法的執行,達到只要執行緒釋放鎖就會加鎖的目的。下面通過原始碼和流程圖來詳細理解

這是非公平鎖和公平鎖不同的兩處地方,下面是非公平鎖的加鎖流程圖

lockInterruptibly 以可中斷的方式獲取鎖

以下是 JavaDoc 官方解釋:

lockInterruptibly 的中文意思為如果沒有被打斷,則獲取鎖。如果沒有其他執行緒持有該鎖,則獲取該鎖並立即返回,將鎖保持計數設定為1。如果當前執行緒已經持有鎖,那麼此方法會立刻返回並且持有鎖的數量會 + 1。如果鎖是由另一個執行緒持有的,則出於執行緒排程目的,當前執行緒將被禁用,並處於休眠狀態,直到發生以下兩種情況之一

  • 鎖被當前執行緒持有
  • 一些其他執行緒打斷了當前執行緒

如果當前執行緒獲取了鎖,則鎖保持計數將設定為1。

如果當前執行緒發生瞭如下情況:

  • 在進入此方法時設定了其中斷狀態
  • 當獲取鎖的時候發生了中斷(Thread.interrupt)

那麼當前執行緒就會丟擲InterruptedException 並且當前執行緒的中斷狀態會清除。

下面看一下它的原始碼是怎麼寫的

首先會呼叫 acquireInterruptibly 這個方法,判斷當前執行緒是否被中斷,如果中斷丟擲異常,沒有中斷則判斷公平鎖/非公平鎖 是否已經獲取鎖,如果沒有獲取鎖(tryAcquire 返回 false)則呼叫 doAcquireInterruptibly 方法,這個方法和 acquireQueued 方法沒什麼區別,就是執行緒在等待狀態的過程中,如果執行緒被中斷,執行緒會丟擲異常。

下面是它的流程圖

tryLock 嘗試加鎖

僅僅當其他執行緒沒有獲取這把鎖的時候獲取這把鎖,tryLock 的原始碼和非公平鎖的加鎖流程基本一致,它的原始碼如下

tryLock 超時獲取鎖

ReentrantLock除了能以中斷的方式去獲取鎖,還可以以超時等待的方式去獲取鎖,所謂超時等待就是執行緒如果在超時時間內沒有獲取到鎖,那麼就會返回false,而不是一直死迴圈獲取。可以使用 tryLock 和 tryLock(timeout, unit)) 結合起來實現公平鎖,像這樣

if (lock.tryLock() || lock.tryLock(timeout, unit)) {...}

如果超過了指定時間,則返回值為 false。如果時間小於或者等於零,則該方法根本不會等待。

它的原始碼如下

首先需要了解一下 TimeUnit 工具類,TimeUnit 表示給定粒度單位的持續時間,並且提供了一些用於時分秒跨單位轉換的方法,通過使用這些方法進行定時和延遲操作。

toNanos 用於把 long 型表示的時間轉換成為納秒,然後判斷執行緒是否被打斷,如果沒有打斷,則以公平鎖/非公平鎖 的方式獲取鎖,如果能夠獲取返回true,獲取失敗則呼叫doAcquireNanos方法使用超時等待的方式獲取鎖。在超時等待獲取鎖的過程中,如果等待時間大於應等待時間,或者應等待時間設定不合理的話,返回 false。

這裡面以超時的方式獲取鎖也可以畫一張流程圖如下

unlock 解鎖流程

unlocklock 是一對情侶,它們分不開彼此,在呼叫 lock 後必須通過 unlock 進行解鎖。如果當前執行緒持有鎖,在呼叫 unlock 後,count 計數將減少。如果保持計數為0就會進行解鎖。如果當前執行緒沒有持有鎖,在呼叫 unlock 會丟擲 IllegalMonitorStateException 異常。下面是它的原始碼

在有了上面閱讀原始碼的經歷後,相信你會很快明白這段程式碼的意思,鎖的釋放不會區分公平鎖還是非公平鎖,主要的判斷邏輯就是 tryRelease 方法,getState 方法會取得同步鎖的重入次數,如果是獲取了偏向鎖,那麼可能會多次獲取,state 的值會大於 1,這時候 c 的值 > 0 ,返回 false,解鎖失敗。如果 state = 1,那麼 c = 0,再判斷當前執行緒是否是獨佔鎖的執行緒,釋放獨佔鎖,返回 true,當 head 指向的頭結點不為 null,並且該節點的狀態值不為0的話才會執行 unparkSuccessor 方法,再進行鎖的獲取。

ReentrantLock 其他方法

isHeldByCurrentThread & getHoldCount

在多執行緒同時訪問時,ReentrantLock 由最後一次成功鎖定的執行緒擁有,當這把鎖沒有被其他執行緒擁有時,執行緒呼叫 lock() 方法會立刻返回併成功獲取鎖。如果當前執行緒已經擁有鎖,這個方法會立刻返回。可以通過 isHeldByCurrentThreadgetHoldCount 來進行檢查。

首先來看 isHeldByCurrentThread 方法

public boolean isHeldByCurrentThread() {
  return sync.isHeldExclusively();
}

根據方法名可以略知一二,是否被當前執行緒持有,它用來詢問鎖是否被其他執行緒擁有,這個方法和 Thread.holdsLock(Object) 方法內建的監視器鎖相同,而 Thread.holdsLock(Object) 是 Thread 類的靜態方法,是一個 native 類,它表示的意思是如果當前執行緒在某個物件上持有 monitor lock(監視器鎖) 就會返回 true。這個類沒有實際作用,僅僅用來測試和除錯所用。例如

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert lock.isHeldByCurrentThread();
}

這個方法也可以確保重入鎖能夠表現出不可重入的行為

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert !lock.isHeldByCurrentThread();
  lock.lock();
  try {
    // 執行業務程式碼
  }finally {
    lock.unlock();
  }
}

如果當前執行緒持有鎖則 lock.isHeldByCurrentThread() 返回 true,否則返回 false。

我們在瞭解它的用法後,看一下它內部是怎樣實現的,它內部只是呼叫了一下 sync.isHeldExclusively(),sync 是 ReentrantLock 的一個靜態內部類,基於 AQS 實現,而 AQS 它是一種抽象佇列同步器,是許多併發實現類的基礎,例如 ReentrantLock/Semaphore/CountDownLatch。sync.isHeldExclusively() 方法如下

protected final boolean isHeldExclusively() {
  return getExclusiveOwnerThread() == Thread.currentThread();
}

此方法會在擁有鎖之前先去讀一下狀態,如果當前執行緒是鎖的擁有者,則不需要檢查。

getHoldCount()方法和isHeldByCurrentThread 都是用來檢查執行緒是否持有鎖的方法,不同之處在於 getHoldCount() 用來查詢當前執行緒持有鎖的數量,對於每個未通過解鎖操作匹配的鎖定操作,執行緒都會保持鎖定狀態,這個方法也通常用於除錯和測試,例如

private ReentrantLock lock = new ReentrantLock();

public void lock(){
  assert lock.getHoldCount() == 0;
  lock.lock();
  try {
    // 執行業務程式碼
  }finally {
    lock.unlock();
  }
}

這個方法會返回當前執行緒持有鎖的次數,如果當前執行緒沒有持有鎖,則返回0。

newCondition 建立 ConditionObject 物件

ReentrantLock 可以通過 newCondition 方法建立 ConditionObject 物件,而 ConditionObject 實現了 Condition 介面,關於 Condition 的用法我們後面再講。

isLocked 判斷是否鎖定

查詢是否有任意執行緒已經獲取鎖,這個方法用來監視系統狀態,而不是用來同步控制,很簡單,直接判斷 state 是否等於0。

isFair 判斷是否是公平鎖的例項

這個方法也比較簡單,直接使用 instanceof 判斷是不是 FairSync 內部類的例項

public final boolean isFair() {
  return sync instanceof FairSync;
}

getOwner 判斷鎖擁有者

判斷同步狀態是否為0,如果是0,則沒有執行緒擁有鎖,如果不是0,直接返回獲取鎖的執行緒。

final Thread getOwner() {
  return getState() == 0 ? null : getExclusiveOwnerThread();
}

hasQueuedThreads 是否有等待執行緒

判斷是否有執行緒正在等待獲取鎖,如果頭節點與尾節點不相等,說明有等待獲取鎖的執行緒。

public final boolean hasQueuedThreads() {
  return head != tail;
}

isQueued 判斷執行緒是否排隊

判斷給定的執行緒是否正在排隊,如果正在排隊,返回 true。這個方法會遍歷佇列,如果找到匹配的執行緒,返回true

public final boolean isQueued(Thread thread) {
  if (thread == null)
    throw new NullPointerException();
  for (Node p = tail; p != null; p = p.prev)
    if (p.thread == thread)
      return true;
  return false;
}

getQueueLength 獲取佇列長度

此方法會返回一個佇列長度的估計值,該值只是一個估計值,因為在此方法遍歷內部資料結構時,執行緒數可能會動態變化。 此方法設計用於監視系統狀態,而不用於同步控制。

public final int getQueueLength() {
  int n = 0;
  for (Node p = tail; p != null; p = p.prev) {
    if (p.thread != null)
      ++n;
  }
  return n;
}

getQueuedThreads 獲取排隊執行緒

返回一個包含可能正在等待獲取此鎖的執行緒的集合。 因為實際的執行緒集在構造此結果時可能會動態更改,所以返回的集合只是一個大概的列表集合。 返回的集合的元素沒有特定的順序。

public final Collection<Thread> getQueuedThreads() {
  ArrayList<Thread> list = new ArrayList<Thread>();
  for (Node p = tail; p != null; p = p.prev) {
    Thread t = p.thread;
    if (t != null)
      list.add(t);
  }
  return list;
}

回答上面那個問題

那麼你看完原始碼分析後,你能總結出 synchronizedlock 鎖的實現 ReentrantLock 有什麼異同嗎?

Synchronzied 和 Lock 的主要區別如下:

  • 存在層面:Syncronized 是Java 中的一個關鍵字,存在於 JVM 層面,Lock 是 Java 中的一個介面

  • 鎖的釋放條件:1. 獲取鎖的執行緒執行完同步程式碼後,自動釋放;2. 執行緒發生異常時,JVM會讓執行緒釋放鎖;Lock 必須在 finally 關鍵字中釋放鎖,不然容易造成執行緒死鎖

  • 鎖的獲取: 在 Syncronized 中,假設執行緒 A 獲得鎖,B 執行緒等待。如果 A 發生阻塞,那麼 B 會一直等待。在 Lock 中,會分情況而定,Lock 中有嘗試獲取鎖的方法,如果嘗試獲取到鎖,則不用一直等待

  • 鎖的狀態:Synchronized 無法判斷鎖的狀態,Lock 則可以判斷

  • 鎖的型別:Synchronized 是可重入,不可中斷,非公平鎖;Lock 鎖則是 可重入,可判斷,可公平鎖

  • 鎖的效能:Synchronized 適用於少量同步的情況下,效能開銷比較大。Lock 鎖適用於大量同步階段:

    Lock 鎖可以提高多個執行緒進行讀的效率(使用 readWriteLock)

  • 在競爭不是很激烈的情況下,Synchronized的效能要優於ReetrantLock,但是在資源競爭很激烈的情況下,Synchronized的效能會下降幾十倍,但是ReetrantLock的效能能維持常態;

  • ReetrantLock 提供了多樣化的同步,比如有時間限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等

還有什麼要說的嗎

面試官可能還會問你 ReentrantLock 的加鎖流程是怎樣的,其實如果你能把原始碼給他講出來的話,一定是高分。如果你記不住原始碼流程的話可以記住下面這個簡化版的加鎖流程

  • 如果 lock 加鎖設定成功,設定當前執行緒為獨佔鎖的執行緒;

  • 如果 lock 加鎖設定失敗,還會再嘗試獲取一次鎖數量,

    如果鎖數量為0,再基於 CAS 嘗試將 state(鎖數量)從0設定為1一次,如果設定成功,設定當前執行緒為獨佔鎖的執行緒;

    如果鎖數量不為0或者上邊的嘗試又失敗了,檢視當前執行緒是不是已經是獨佔鎖的執行緒了,如果是,則將當前的鎖數量+1;如果不是,則將該執行緒封裝在一個Node內,並加入到等待佇列中去。等待被其前一個執行緒節點喚醒。


文章參考:

【試驗局】ReentrantLock中非公平鎖與公平鎖的效能測試

第五章 ReentrantLock原始碼解析1--獲得非公平鎖與公平鎖lock()

https://juejin.im/post/5c95df97e51d4551d06d8e8e

【JUC】JDK1.8原始碼分析之ReentrantLock(三)

https://www.lagou.com/lgeduarticle/73019.html