打通 Java 任督二脈 —— 併發資料結構的基石

碼洞發表於2018-12-11

每一個 Java 的高階程式設計師在體驗過多執行緒程式開發之後,都需要問自己一個問題,Java 內建的鎖是如何實現的?最常用的最簡單的鎖要數 ReentrantLock,使用它加鎖時如果沒有立即加成功,就會阻塞當前的執行緒等待其它執行緒釋放鎖之後再重新嘗試加鎖,那執行緒是如何實現阻塞自己的?其它執行緒釋放鎖之後又是如果喚醒當前執行緒的?當前執行緒是如何得出自己沒有加鎖成功這一結論的?本篇內容將會從根源上回答上面提到的所有問題

執行緒阻塞原語

Java 的執行緒阻塞和喚醒是通過 Unsafe 類的 park 和 unpark 方法做到的。

public class Unsafe {
  ...
  public native void park(boolean isAbsolute, long time);
  public native void unpark(Thread t);
  ...
}
複製程式碼

這兩個方法都是 native 方法,它們本身是由 C 語言來實現的核心功能。park 的意思是停車,讓當前執行的執行緒 Thread.currentThread() 休眠,unpark 的意思是解除停車,喚醒指定執行緒。這兩個方法在底層是使用作業系統提供的訊號量機制來實現的。具體實現過程要深究 C 程式碼,這裡暫時不去具體分析。park 方法的兩個引數用來控制休眠多長時間,第一個引數 isAbsolute 表示第二個引數是絕對時間還是相對時間,單位是毫秒。

執行緒從啟動開始就會一直跑,除了作業系統的任務排程策略外,它只有在呼叫 park 的時候才會暫停執行。鎖可以暫停執行緒的奧祕所在正是因為鎖在底層呼叫了 park 方法。

parkBlocker

執行緒物件 Thread 裡面有一個重要的屬性 parkBlocker,它儲存當前執行緒因為什麼而 park。就好比停車場上停了很多車,這些車主都是來參加一場拍賣會的,等拍下自己想要的物品後,就把車開走。那麼這裡的 parkBlocker 大約就是指這場「拍賣會」。它是一系列衝突執行緒的管理者協調者,哪個執行緒該休眠該喚醒都是由它來控制的。

class Thread {
  ...
  volatile Object parkBlocker;
  ...
}
複製程式碼

當執行緒被 unpark 喚醒後,這個屬性會被置為 null。Unsafe.park 和 unpark 並不會幫我們設定 parkBlocker 屬性,負責管理這個屬性的工具類是 LockSupport,它對 Unsafe 這兩個方法進行了簡單的包裝。

class LockSupport {
  ...
  public static void park(Object blocker) {
     Thread t = Thread.currentThread();
     setBlocker(t, blocker);
     U.park(false, 0L);
     setBlocker(t, null); // 醒來後置null
  }

  public static void unpark(Thread thread) {
     if (thread != null)
        U.unpark(thread);
     }
  }
  ...
}
複製程式碼

Java 的鎖資料結構正是通過呼叫 LockSupport 來實現休眠與喚醒的。執行緒物件裡面的 parkBlocker 欄位的值就是下面我們要講的「排隊管理器」。

排隊管理器

當多個執行緒爭用同一把鎖時,必須有排隊機制將那些沒能拿到鎖的執行緒串在一起。當鎖釋放時,鎖管理器就會挑選一個合適的執行緒來佔有這個剛剛釋放的鎖。每一把鎖內部都會有這樣一個佇列管理器,管理器裡面會維護一個等待的執行緒佇列。ReentrantLock 裡面的佇列管理器是 AbstractQueuedSynchronizer,它內部的等待佇列是一個雙向列表結構,列表中的每個節點的結構如下。

class AbstractQueuedSynchronizer {
  volatile Node head;  // 隊頭執行緒將優先獲得鎖
  volatile Node tail;  // 搶鎖失敗的執行緒追加到隊尾
  volatile int state; // 鎖計數
}

class Node {
  Node prev;
  Node next;
  Thread thread; // 每個節點一個執行緒
  
  // 下面這兩個特殊欄位可以先不去理解
  Node nextWaiter; // 請求的是共享鎖還是獨佔鎖
  int waitStatus; // 精細狀態描述字
}
複製程式碼

加鎖不成功時,當前的執行緒就會把自己納入到等待連結串列的尾部,然後呼叫 LockSupport.park 將自己休眠。其它執行緒解鎖時,會從連結串列的表頭取一個節點,呼叫 LockSupport.unpark 喚醒它。

圖片

AbstractQueuedSynchronizer 類是一個抽象類,它是所有的鎖佇列管理器的父類,JDK 中的各種形式的鎖其內部的佇列管理器都繼承了這個類,它是 Java 併發世界的核心基石。比如 ReentrantLock、ReadWriteLock、CountDownLatch、Semaphone、ThreadPoolExecutor 內部的佇列管理器都是它的子類。這個抽象類暴露了一些抽象方法,每一種鎖都需要對這個管理器進行定製。而 JDK 內建的所有併發資料結構都是在這些鎖的保護下完成的,它是JDK 多執行緒高樓大廈的地基。

圖片

鎖管理器維護的只是一個普通的雙向列表形式的佇列,這個資料結構很簡單,但是仔細維護起來卻相當複雜,因為它需要精細考慮多執行緒併發問題,每一行程式碼都寫的無比小心。

JDK 鎖管理器的實現者是 Douglas S. Lea,Java 併發包幾乎全是他單槍匹馬寫出來的,在演算法的世界裡越是精巧的東西越是適合一個人來做。

Douglas S. Lea是紐約州立大學奧斯威戈分校電腦科學教授和現任電腦科學系主任,專門研究併發程式設計和併發資料結構的設計。他是Java Community Process的執行委員會成員,主持JSR 166,它為Java程式語言新增了併發實用程式。

圖片

後面我們將 AbstractQueuedSynchronizer 簡寫成 AQS。我必須提醒各位讀者,AQS 太複雜了,如果在理解它的路上遇到了挫折,這很正常。目前市場上並不存在一本可以輕鬆理解 AQS 的書籍,能夠吃透 AQS 的人太少太少,我自己也不算。

公平鎖與非公平鎖

公平鎖會確保請求鎖和獲得鎖的順序,如果在某個點鎖正處於自由狀態,這時有一個執行緒要嘗試加鎖,公平鎖還必須檢視當前有沒有其它執行緒排在排隊,而非公平鎖可以直接插隊。聯想一下在肯德基買漢堡時的排隊場景。

也許你會問,如果某個鎖處於自由狀態,那它怎麼會有排隊的執行緒呢?我們假設此刻持有鎖的執行緒剛剛釋放了鎖,它喚醒了等待佇列中第一個節點執行緒,這時候被喚醒的執行緒剛剛從 park 方法返回,接下來它就會嘗試去加鎖,那麼從 park 返回到加鎖之間的狀態就是鎖的自由態,這很短暫,而這短暫的時間內還可能有其它執行緒也在嘗試加鎖。

其次還有一點需要注意,執行了 Lock.park 方法的執行緒自我休眠後,並不是非要等到其它執行緒 unpark 了自己才會醒來,它可能隨時會以某種未知的原因醒來。我們看原始碼註釋,park 返回的原因有四種

  1. 其它執行緒 unpark 了當前執行緒
  2. 時間到了自然醒(park 有時間引數)
  3. 其它執行緒 interrupt 了當前執行緒
  4. 其它未知原因導致的「假醒」

文件中沒有明確說明何種未知原因會導致假醒,它倒是說明了當 park 方法返回時並不意味著鎖自由了,醒過來的執行緒在重新嘗試獲取鎖失敗後將會再次 park 自己。所以加鎖的過程需要寫在一個迴圈裡,在成功拿到鎖之前可能會進行多次嘗試。

計算機世界非公平鎖的服務效率要高於公平鎖,所以 Java 預設的鎖都使用了非公平鎖。不過現實世界似乎非公平鎖的效率會差一點,比如在肯德基如果可以不停插隊,你可以想象現場肯定一片混亂。為什麼計算機世界和現實世界會有差異,大概是因為在計算機世界裡某個執行緒插隊並不會導致其它執行緒抱怨。

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

public ReentrantLock(boolean fair) {
    this.sync = fair ? new FairSync() : new NonfairSync();
}
複製程式碼

共享鎖與排他鎖

ReentrantLock 的鎖是排他鎖,一個執行緒持有,其它執行緒都必須等待。而 ReadWriteLock 裡面的讀鎖不是排他鎖,它允許多執行緒同時持有讀鎖,這是共享鎖。共享鎖和排他鎖是通過 Node 類裡面的 nextWaiter 欄位區分的。

class AQS {
  static final Node SHARED = new Node();
  static final Node EXCLUSIVE = null;

  boolean isShared() {
    return this.nextWaiter == SHARED;
  }
}
複製程式碼

那為什麼這個欄位沒有命名成 mode 或者 type 或者乾脆直接叫 shared?這是因為 nextWaiter 在其它場景還有不一樣的用途,它就像 C 語言聯合型別的欄位一樣隨機應變,只不過 Java 語言沒有聯合型別。

條件變數

關於條件變數,需要提出的第一個問題是為什麼需要條件變數,只有鎖還不夠麼?考慮下面的虛擬碼,當某個條件滿足時,才去幹某件事

 void doSomething() {
   locker.lock();
   while(!condition_is_true()) {  // 先看能不能搞事
     locker.unlock();  // 搞不了就歇會再看看能不能搞
     sleep(1);
     locker.lock(); // 搞事需要加鎖,判斷能不能搞事也需要加鎖
   }
   justdoit();  // 搞事
   locker.unlock();
 }
複製程式碼

當條件不滿足時,就迴圈重試(其它執行緒會通過加鎖來修改條件),但是需要間隔 sleep,不然 CPU 就會因為空轉而飆高。這裡存在一個問題,那就是 sleep 多久不好控制。間隔太久,會拖慢整體效率,甚至會錯過時機(條件瞬間滿足了又立即被重置了),間隔太短,又回導致 CPU 空轉。有了條件變數,這個問題就可以解決了

void doSomethingWithCondition() {
  cond = locker.newCondition();
  locker.lock();
  while(!condition_is_true()) {
    cond.await();
  }
  justdoit();
  locker.unlock();
}
複製程式碼

await() 方法會一直阻塞在 cond 條件變數上直到被另外一個執行緒呼叫了 cond.signal() 或者 cond.signalAll() 方法後才會返回,await() 阻塞時會自動釋放當前執行緒持有的鎖,await() 被喚醒後會再次嘗試持有鎖(可能又需要排隊),拿到鎖成功之後 await() 方法才能成功返回。

圖片

阻塞在條件變數上的執行緒可以有多個,這些阻塞執行緒會被串聯成一個條件等待佇列。當 signalAll() 被呼叫時,會喚醒所有的阻塞執行緒,讓所有的阻塞執行緒重新開始爭搶鎖。如果呼叫的是 signal() 只會喚醒佇列頭部的執行緒,這樣可以避免「驚群問題」。

await() 方法必須立即釋放鎖,否則臨界區狀態就不能被其它執行緒修改,condition_is_true() 返回的結果也就不會改變。 這也是為什麼條件變數必須由鎖物件來建立,條件變數需要持有鎖物件的引用這樣才可以釋放鎖以及被 signal 喚醒後重新加鎖。建立條件變數的鎖必須是排他鎖,如果是共享鎖被 await() 方法釋放了並不能保證臨界區的狀態可以被其它執行緒來修改,可以修改臨界區狀態的只能是排他鎖。這也是為什麼 ReadWriteLock.ReadLock 類的 newCondition 方法定義如下

public Condition newCondition() {
    throw new UnsupportedOperationException();
}
複製程式碼

有了條件變數,sleep 不好控制的問題就解決了。當條件滿足時,呼叫 signal() 或者 signalAll() 方法,阻塞的執行緒可以立即被喚醒,幾乎沒有任何延遲。

條件等待佇列

當多個執行緒 await() 在同一個條件變數上時,會形成一個條件等待佇列。同一個鎖可以建立多個條件變數,就會存在多個條件等待佇列。這個佇列和 AQS 的佇列結構很接近,只不過它不是雙向佇列,而是單向佇列。佇列中的節點和 AQS 等待佇列的節點是同一個類,但是節點指標不是 prev 和 next,而是 nextWaiter。

class AQS {
  ...
  class ConditionObject {
    Node firstWaiter;  // 指向第一個節點
    Node lastWaiter;  // 指向第二個節點
  }
  
  class Node {
    static final int CONDITION = -2;
    static final int SIGNAL = -1;
    Thread thread;  // 當前等待的執行緒
    Node nextWaiter;  // 指向下一個條件等待節點
  
    Node prev;
    Node next;
    int waitStatus;  // waitStatus = CONDITION
  }
  ...
}

複製程式碼

圖片
ConditionObject 是 AQS 的內部類,這個物件裡會有一個隱藏的指標 this$0 指向外部的 AQS 物件,ConditionObject 可以直接訪問 AQS 物件的所有屬性和方法(加鎖解鎖)。位於條件等待佇列裡的所有節點的 waitStatus 狀態都被標記為 CONDITION,表示節點是因為條件變數而等待。

佇列轉移

當條件變數的 signal() 方法被呼叫時,條件等待佇列的頭節點執行緒會被喚醒,該節點從條件等待佇列中被摘走,然後被轉移到 AQS 的等待佇列中,準備排隊嘗試重新獲取鎖。這時節點的狀態從 CONDITION 轉為 SIGNAL,表示當前節點是被條件變數喚醒轉移過來的。

class AQS {
  ...
  boolean transferForSignal(Node node) {
    // 重置節點狀態
    if (!node.compareAndSetWaitStatus(Node.CONDITION, 0))
      return false
    Node p = enq(node); // 進入 AQS 等待佇列
    int ws = p.waitStatus;
    // 再修改狀態為SIGNAL
    if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL))
       LockSupport.unpark(node.thread);
       return true;
  }
  ...
}
複製程式碼

被轉移的節點的 nextWaiter 欄位的含義也發生了變更,在條件佇列裡它是下一個節點的指標,在 AQS 等待佇列裡它是共享鎖還是互斥鎖的標誌。

圖片
Java 併發包常用類庫依賴結構

ReentrantLock 加鎖過程

下面我們精細分析加鎖過程,深入理解鎖邏輯控制。我必須肯定 Dough Lea 的程式碼寫成下面這樣的極簡形式,閱讀起來還是挺難以理解的。

class ReentrantLock {
    ...
    public void lock() {
        sync.acquire(1);
    }
    ...
}

class Sync extends AQS {
  ...
  public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();
  }
  ...
}
複製程式碼

acquire 的 if 判斷語句要分為三個部分,tryAcquire 方法表示當前的執行緒嘗試加鎖,如果加鎖不成功就需要排隊,這時候呼叫 addWaiter 方法,將當前執行緒入隊。然後再呼叫 acquireQueued 方法,開始了 park 、醒來重試加鎖、加鎖不成功繼續 park 的迴圈重試加鎖過程。直到加鎖成功 acquire 方法才會返回。

如果在迴圈重試加鎖過程中被其它執行緒打斷了,acquireQueued 方法就會返回 true。這時候執行緒就需要呼叫 selfInterrupt() 方法給當前執行緒設定一個被打斷的標識位。

// 打斷當前執行緒,其實就是設定一個標識位
static void selfInterrupt() {
        Thread.currentThread().interrupt();
}
複製程式碼

執行緒如何知道自己被其它執行緒打斷了呢?在 park 醒來之後呼叫 Thread.interrupted() 就知道了,不過這個方法只能呼叫一次,因為它在呼叫之後就會立即 clear 打斷標誌位。這也是為什麼 acquire 方法裡需要呼叫 selfInterrupt() ,為的就是重新設定打斷標誌位。這樣上層的邏輯才可以通過 Thread.interrupted() 知道自己有沒有被打斷。

acquireQueued 和 addWaiter 方法由 AQS 類提供,tryAcquire 需要由子類自己實現。不同的鎖會有不同的實現。下面我們來看看 ReentrantLock 的公平鎖 tryAcquire 方法的實現

圖片
這裡有個 if else 分支,其中 else if 部分表示鎖的重入,當前嘗試加鎖的執行緒是已經持有了這把鎖的執行緒,也就是同一個執行緒重複加鎖,這時只需要增加計數值就行了。鎖的 state 記錄的就是加鎖計數,重入一次就 +1。AQS 物件裡有一個 exclusiveOwnerThread 欄位,記錄了當前持有排他鎖的執行緒。

if(c == 0) 意味著當前鎖是自由態,計數值為零。這時就需要爭搶鎖,因為同一時間可能會有多個執行緒在呼叫 tryAcquire。爭搶的方式是用 CAS 操作 compareAndSetState,成功將鎖計數值從 0 改成 1 的執行緒將獲得這把鎖,將當前的執行緒記錄到 exclusiveOwnerThread 中。

程式碼裡還有一個 hasQueuedPredecessors() 判斷,這個判斷非常重要,它的意思是看看當前的 AQS 等待佇列裡有沒有其它執行緒在排隊,公平鎖在加鎖之前需要 check 一下,如果有排隊的,自己就不能插隊。而非公平鎖就不需要 check,公平鎖和非公平鎖的全部的實現差異就在於此,就這一個 check 決定了鎖的公平與否。

下面我們再看看 addWaiter 方法的實現,引數 mode 表示是共享鎖還是排他鎖,它對應 Node.nextWaiter 屬性。

圖片
圖片
圖片
addWaiter 需要將新的節點新增到 AQS 等待佇列的隊尾。如果隊尾 tail 是空的意味著佇列還沒有初始化,那就需要初始化一下。AQS 佇列在初始化時需要一個冗餘的頭部節點,這個節點的 thread 欄位是空的。

將新節點新增到隊尾也是需要考慮多執行緒併發的,所以程式碼裡再一次使用了 CAS 操作 compareAndSetTail 來競爭隊尾指標。沒有競爭到的執行緒就會繼續下一輪競爭 for(;;) 繼續使用 CAS 操作將新節點往隊尾新增。

下面我們再看看 acquireQueue 方法的程式碼實現,它會重複 park、嘗試再次加鎖、加鎖失敗繼續 park 的迴圈過程。

圖片
acquireQueue 在嘗試加鎖之前會先看看自己是不是 AQS 等待佇列的第一個節點,如果不是它就繼續去 park。這意味著不管是公平還是非公平鎖,在這裡它們都統一採取了公平的方案,看看佇列中是不是輪到自己了。也就是說「一朝排隊,永遠排隊」。

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}
複製程式碼

執行緒在 park 返回醒來之後要立即檢測一下是否被其它執行緒中斷了。不過即使發生中斷了,它還會繼續嘗試獲取鎖,如果獲取不到,還會繼續睡眠,直到鎖獲取到了才將中斷狀態返回。這意味著打斷執行緒並不會導致死鎖狀態(拿不到鎖)退出。

同時我們還可以注意到鎖是可以取消的 cancelAcquire(),準確地說是取消處於等待加鎖的狀態,執行緒處於 AQS 的等待佇列中等待加鎖。那什麼情況下才會丟擲異常而導致取消加鎖呢,唯一的可能就是 tryAcquire 方法,這個方法是由子類實現的,子類的行為不受 AQS 控制。當子類的 tryAcquire 方法丟擲了異常,那 AQS 最好的處理方法就是取消加鎖了。cancelAcquire 會將當前節點從等待佇列中移除。

ReentrantLock 解鎖過程

解鎖的過程要簡單一些,將鎖計數降為零後,喚醒等待佇列中的第一個有效節點。

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
         return true;
     }
     return false;
}

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    // 解鈴還須繫鈴人
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}
複製程式碼

考慮到可重入鎖,需要判斷鎖計數是否降為零才可以確定鎖是否徹底被釋放。只有鎖徹底被釋放了才能喚醒後繼等待節點。unparkSuccessor 會跳過無效節點(已取消的節點),找到第一個有效節點呼叫 unpark() 喚醒相應的執行緒。

讀寫鎖

讀寫鎖分為兩個鎖物件 ReadLock 和 WriteLock,這兩個鎖物件共享同一個 AQS。AQS 的鎖計數變數 state 將分為兩個部分,前 16bit 為共享鎖 ReadLock 計數,後 16bit 為互斥鎖 WriteLock 計數。互斥鎖記錄的是當前寫鎖重入的次數,共享鎖記錄的是所有當前持有共享讀鎖的執行緒重入總次數。

讀寫鎖同樣也需要考慮公平鎖和非公平鎖。共享鎖和互斥鎖的公平鎖策略和 ReentrantLock 一樣,就是看看當前還有沒有其它執行緒在排隊,自己會乖乖排到隊尾。非公平鎖策略不一樣,它會比較偏向於給寫鎖提供更多的機會。如果當前 AQS 佇列裡有任何讀寫請求的執行緒在排隊,那麼寫鎖可以直接去爭搶,但是如果隊頭是寫鎖請求,那麼讀鎖需要將機會讓給寫鎖,去隊尾排隊。 畢竟讀寫鎖適合讀多寫少的場合,對於偶爾出現一個寫鎖請求就應該得到更高的優先順序去處理。

寫鎖加鎖過程

讀寫鎖的寫鎖加鎖在整體邏輯上和 ReentrantLock 是一樣的,不同的是 tryAcquire() 方法

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();
}

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    if (c != 0) {
         if (w == 0 || current != getExclusiveOwnerThread())
              return false;
         if (w + exclusiveCount(acquires) > MAX_COUNT)
              throw new Error("Maximum lock count exceeded");
         setState(c + acquires);
         return true;
     }
     if (writerShouldBlock() ||
           !compareAndSetState(c, c + acquires))
         return false;
     setExclusiveOwnerThread(current);
     return true;
}
複製程式碼

寫鎖也需要考慮可重入,如果當前 AQS 互斥鎖的持有執行緒正好是當前要加鎖的執行緒,那麼就是寫鎖在重入,重入只需要遞增鎖計數值即可。當 c!=0 也就是鎖計數不為零時,既可能是因為當前的 AQS 有讀鎖也可能是因為有寫鎖,判斷 w == 0 就是判斷當前的計數是不是讀鎖帶來的。

如果計數值為零,那就開始爭搶鎖。取決於鎖是否公平,在爭搶之前呼叫 writerShouldBlock() 方法看看自己是否需要排隊,如果不需要排隊,就可以使用 CAS 操作來爭搶,成功將計數值從 0 設定為 1 的執行緒將獨佔寫鎖。

讀鎖加鎖過程

讀鎖加鎖過程比寫鎖要複雜很多,它在整體流程上和寫鎖一樣,但是細節差距很大。特別是它需要為每一個執行緒記錄讀鎖計數,這部分邏輯佔據了不少程式碼。

public final void acquireShared(int arg) {
    // 如果嘗試加鎖不成功, 就去排隊休眠,然後迴圈重試
    if (tryAcquireShared(arg) < 0)
        // 排隊、迴圈重試
        doAcquireShared(arg);
}
複製程式碼

如果當前執行緒已經持有寫鎖,它還可以繼續加讀鎖,這是為了達成鎖降級必須支援的邏輯。鎖降級是指在持有寫鎖的情況下,再加讀鎖,再解寫鎖。相比於先寫解鎖再加讀鎖而言,這樣可以省去加鎖二次排隊的過程。因為鎖降級的存在,鎖計數中讀寫計數可以同時不為零。

wlock.lock();
if(whatever) {
  // 降級
  rlock.lock();
  wlock.unlock();
  doRead();
  rlock.unlock();
} else {
  // 不降級
  doWrite()
  wlock.unlock();
}
複製程式碼

為了給每一個讀鎖執行緒進行鎖計數,它設定了一個 ThreadLocal 變數。

private transient ThreadLocalHoldCounter readHolds;

static final class HoldCounter {
    int count;
    final long tid = LockSupport.getThreadId(Thread.currentThread());
}

static final class ThreadLocalHoldCounter
            extends ThreadLocal<HoldCounter> {
   public HoldCounter initialValue() {
        return new HoldCounter();
   }
}
複製程式碼

但是 ThreadLocal 變數訪問起來效率不夠高,所以又設定了快取。它會儲存最近一次獲取讀鎖執行緒的鎖計數。線上程爭用不是特別頻繁的情況下,直接讀取快取會比較高效。

private transient HoldCounter cachedHoldCounter;
複製程式碼

Dough Lea 覺得使用 cachedHoldCounter 還是不夠高效,所以又加了一層快取記錄 firstReader,記錄第一個將讀鎖計數從 0 變成 1 的執行緒以及鎖計數。當沒有執行緒爭用時,直接讀取這兩個欄位會更加高效。

private transient Thread firstReader;
private transient int firstReaderHoldCount;

final int getReadHoldCount() {
    // 先訪問鎖全域性計數的讀計數部分
    if (getReadLockCount() == 0)
        return 0;

    // 再訪問 firstReader
    Thread current = Thread.currentThread();
    if (firstReader == current)
         return firstReaderHoldCount;

    // 再訪問最近的讀執行緒鎖計數
    HoldCounter rh = cachedHoldCounter;
    if (rh != null && rh.tid == LockSupport.getThreadId(current))
        return rh.count;

    // 無奈讀 ThreadLocal 吧
    int count = readHolds.get().count;
    if (count == 0) readHolds.remove();
    return count;
}
複製程式碼

所以我們看到為了記錄這個讀鎖計數作者煞費苦心,那這個讀計數的作用是什麼呢?那就是執行緒可以通過這個計數值知道自己有沒有持有這個讀寫鎖。

讀加鎖還有一個自旋的過程,所謂自旋就是第一次加鎖失敗,那就直接迴圈重試,不休眠,聽起來有點像死迴圈重試法。

final static int SHARED_UNIT = 65536
// 讀計數是高16位

final int fullTryAcquireShared(Thread current) {
  for(;;) {
    int c = getState();
    // 如果有其它執行緒加了寫鎖,還是返回睡覺去吧
    if (exclusiveCount(c) != 0) {
        if (getExclusiveOwnerThread() != current)
            return -1;
    ...
    // 超出計數上限
    if (sharedCount(c) == MAX_COUNT)
       throw new Error("Maximum lock count exceeded");
    if (compareAndSetState(c, c + SHARED_UNIT)) {
       // 拿到讀鎖了
       ...
       return 1
    }
    ...
    // 迴圈重試
  }
}
複製程式碼

因為讀鎖需要使用 CAS 操作來修改底層鎖的總讀計數值,成功的才可以獲得讀鎖,獲取讀鎖的 CAS 操作失敗只是意味著讀鎖之間存在 CAS 操作的競爭,並不意味著此刻鎖被別人佔據了自己不能獲得。多試幾次肯定可以加鎖成功,這就是自旋的原因所在。同樣在釋放讀鎖的時候也有一個 CAS 操作的迴圈重試過程。

protected final boolean tryReleaseShared(int unused) {
   ...
   for (;;) {
       int c = getState();
       int nextc = c - SHARED_UNIT;
       if (compareAndSetState(c, nextc)) {
         return nextc == 0;
       }
   }
   ...
}
複製程式碼

小結

Java 併發能力的基石是 park() 和 unpark() 方法、volatile 變數、synchronized、CAS 操作和 AQS 佇列,深究進去這幾個知識點都不簡單,本節內容提及到的鎖相關知識點也不是特別完備,其中還有很多細節我自己也還沒有完全吃透,那麼關於鎖更多的細節我們後續再講吧。

打通 Java 任督二脈 —— 併發資料結構的基石

相關文章