上一篇文章我們介紹了一個顯式鎖,ReentrantLock ,了解到它是一個『獨占式』鎖,簡而言之就是,
我拿到鎖以后,不管我是讀或是寫操作,其他人都不能和我搶,都得等着。
因而在某些讀操作遠大於寫操作的場景之下,即便我只是讀數據也不得不排隊一個一個來,於是有人提出了一個『讀寫鎖』的概念。
『讀寫鎖』並不是真正意義上的讀寫分離,它只允許讀讀共存,而讀寫、寫寫依然是互斥的,所以只有在大量讀操作、少量甚至沒有寫操作的情境之下,讀寫鎖才具有較高的性能體現。
類的基本結構
來自父接口的規范
ReentrantReadWriteLock 繼承了接口 ReadWriteLock,而父接口約束它必須提供的能力如下:
而 ReentrantReadWriteLock 對該接口的實現也是簡單明了了的:
顯然,ReentrantReadWriteLock 通過在內部定義兩個靜態內部類來分別實現接口 Lock,以達到內嵌讀寫鎖的能力,而兩個內部類的實現是如何的?區別在哪?怎么實現一個讀一個寫?我們稍后會詳細地從源碼層面一點點分析,不要着急。
自定義實現 AQS
AQS 是什么呢?相信看過之前文章的朋友是一定知道的,AQS 指的是 AbstractQueuedSynchronizer,就是一個同步容器。簡而言之就是:
一個隊列、一個狀態、一個線程對象。
線程對象保存的當前被允許訪問代碼塊的線程實例,隊列中每一個線程都是一個節點,這些線程都是由於沒能獲取到鎖而阻塞排隊在這里。狀態可以取值為零或正正整數,零表示當前無人持有該鎖,正數表示當前線程多次重入該鎖的次數。
除此之外,ReentrantReadWriteLock 中剩余的一些方法主要提供了該鎖的一些狀態信息的返回,這部分比較簡單。本文的重點將放在對那兩個內部類實現的讀鎖寫鎖原理的分析。
讀讀共存
下面我們深入到源碼層面去看看讀鎖在何種情況下才能成功的加在臨界資源上,哪些情況下不得加讀鎖。另外說一句,對於有些方法我並不會一跟到底,不然篇幅太長了,我會大體概括這些方法的作用與核心邏輯,具體的大家可以自行閱讀分析。
ReadLock 是 ReentrantReadWriteLock 中定義的一個內部類,它實現了 Lock 接口,提供基本的 lock、unlock 等方法,我們先看 lock 方法:
public void lock() {
sync.acquireShared(1);
}
lock 方法很簡單,調用了外部類同步容器實例的同步方法,因為需要讀寫分離,所以讀鎖寫鎖必須共用同一個 AQS,而這個 AQS 則定義在外圍類 ReentrantReadWriteLock 之中,供兩種鎖使用。
簡而言之,無論是讀鎖或是寫鎖,他們共用的一個 AQS 同步器,同一個阻塞隊列,同一個狀態,同一個線程持有器。ReentrantReadWriteLock 也正是通過這個公用的 AQS 同步器來協調讀鎖寫鎖能同時工作。
acquireShared 方法實現如下,這里我們先以公平策略作引例:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
tryAcquireShared 方法實現如下:
這個方法的代碼不鋪開分析了,主要切三個部分總結下邏輯及完成的功能,具體源碼大家自行分析了,如有疑問歡迎加我微信一起探討(文末)。
- 如果有線程對臨界資源加了寫鎖,並且該線程不是自己,那么認為自己應當退出阻塞,不能再加讀鎖,返回負值。
- 到達這里必然說明臨界資源沒有被任何其他寫鎖占用,然后這部分首先會通過 CAS 去修改狀態,為讀鎖計數增一。除此之外,還將計算並保存當前線程重入該讀鎖的次數,這里的記錄算法也是很有意思的,如果你有些疑惑歡迎和我討論討論。
- 第三個步驟是上兩個步驟的綜合,這個方法體中將循環的執行上述 1、2 兩個步驟,直到成功加上讀鎖或是條件發生改變,不再具備嘗試獲取讀鎖的能力,例如當前的臨界資源已經被寫鎖占用、等待隊列中有其他線程正在等待向臨界資源添加鎖限於公平策略,當前線程不得繼續競爭並嘗試加鎖。
分析完了 tryAcquireShared 方法以后,我們知道如果此次嘗試加鎖失敗,方法會返回值 -1,意味着加讀鎖失敗,當前線程需要被阻塞排隊等待。
於是就有了我們的 doAcquireShared 方法,該方法會將當前線程包裝成節點添加到阻塞隊列尾部,排隊等待再次競爭臨界資源。
- addWaiter 會將當前線程包裝成一個 Node 節點添加到隊列的尾部,如果隊列沒有初始化會優先做初始化隊列的操作。
- 接着在一個死循環准備阻塞當前線程,當然阻塞之前會取出當前節點的前一個節點,比較看是不是 head 節點,如果是則說明當前線程排在隊列的第一位置,於是再次嘗試添加讀鎖,如果成功方法即刻返回。
- 如果當前線程並沒有排在隊列第一的位置,亦或是再次的嘗試也失敗,那么將在這部分的 parkAndCheckInterrupt 方法中被阻塞。
- 如果上述步驟失敗了,也就是 failed 的值是 true,那么將取消當前試圖添加讀鎖的操作,刪除當前線程對應阻塞隊列中的節點,喚醒下一個節點對應的線程。
這樣的話,我們關於讀鎖的加鎖大致上也摸清楚了,總結一下整個過程:
首先 lock 方法會調用 tryAcquireShared 方法做一次嘗試加鎖操作,如果成功了那么整個加鎖的過程也就結束了,否則還會去區分是什么原因導致的失敗。
如果是由於臨界資源正在被寫鎖鎖住,那么認為你不應該再嘗試了,先去阻塞等着吧,而如果是由於並發修改 state 導致的失敗,那么將進入循環嘗試,直到成功或是遇到和上述一樣的情況,有寫鎖成功的占有了臨界資源,不得繼續嘗試。
tryAcquireShared 失敗后將導致 doAcquireShared 方法的調用,將當前線程包裝成節點添加到隊列的尾部,然后阻塞在循環體之中,等待別人的喚醒。
接下來我們來看看讀鎖的 unlock 方法實現:
類似的代碼結構,我們看 tryReleaseShared 方法:
兩個部分,比較簡單:
- 將當前線程的讀鎖計數器自減一
- 循環的進行 CAS 操作,修改 state 的值,讓它減一,只有當所有的讀鎖都釋放后,此方法才會返回 true。
只有當所有的讀鎖都釋放結束之后,該方法才會返回 true 並轉而去執行方法 doReleaseShared 試圖喚醒隊列中下一個狀態正常線程。
- 獲取隊列 head 節點,如果 head 等於 null 或是和 tail 節點相等,那么認定此隊列是空隊列,沒有任何線程在排隊也即無節點可釋放,方法結束。反之,如果 head 節點的 waitstatus 是 SIGNAL,那么認為該節點已經被阻塞,調用 UnparkSuccessor 方法去喚醒 head 節點后首個有效的線程節點。
- 正常情況下,被阻塞的線程節點的等待狀態都是 SINGLE ,如果等待狀態是零,也即等於初始化默認的值,那么將修改該等待狀態並結束循環。也就是這種情況的 head 節點在調用 doReleaseShared 方法是不會釋放任何隊列中的線程節點的。
關於第二步,很多人可能根本不知道為啥這么做,這里簡單說一下:
我們的 doAcquireShared 方法嘗試阻塞當前線程的過程中有這么一個過程,就是在實際阻塞之前會判斷一下當前線程節點是不是排在隊列的第一個,如果是則作最后一次嘗試,一旦失敗就真正阻塞了,成功的話會調用 setHeadAndPropagate 方法。
這個方法會將當前節點置換到 head 節點上,並且調用 doReleaseShared 將自己的 waitStatus 值改成 PROPAGATE,象征一種「傳播」特性,並且隊列此時沒有人在排隊,所以下一個讀鎖會無條件的成功,就這樣一直傳播下去,直到任一線程失敗了才將頭結點的傳播狀態修改為 SINGLE,以此釋放 doReleaseShared 的釋放能力。
說一下哈,有關「傳播特性」,市面上分析這部分源碼的文章大多都選擇略過或是含糊其辭,沒怎么搜索到描述詳盡的資料。以上是我個人理解,各位要是有疑問,也歡迎大家和我一起交流探討!
關於讀鎖的釋放,我想我已經描述的很清晰了,總結下大體邏輯:
每調用一次 tryReleaseShared 都會減少一次讀鎖的持有數量,只有讀鎖的持有量為零,該方法才會返回 true,並接着調用 doReleaseShared 方法釋放隊列中第一個有效的阻塞節點,讓它重新競爭臨界資源添加讀鎖,這個過程本來是很簡單的,就節點向前移動並喚醒線程而已,但是其中涉及了一個「傳播」共享傳遞,需要額外去理解,這一點我們上述也做了說明了。
寫寫互斥
分析完了讀鎖的加鎖和釋放鎖的過程,接下來我們分析寫鎖的添加和釋放過程是如何彼此互斥工作的。
寫鎖的 lock 方法調用 acquire 方法:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
和讀鎖的嘗試加鎖方法具有相似的代碼風格,都是先通過一個 tryXXX 方法嘗試加鎖,失敗了就會返回調用另一個方法阻塞當前線程到等待隊列上。我們先看這個 tryAcquire 方法:
如果你認真的分析了讀鎖的源碼,你會發現寫鎖的嘗試加鎖就非常簡單了。
- 第一部分會根據鎖的狀態 state 值得到當前臨界資源的各種鎖持有情況,如果狀態為零,則說明沒有任何鎖在臨界資源上,轉而第二部嘗試加鎖。否則,如果有寫線程正在工作並且不是自己,那么直接返回失敗,不再嘗試,否則就是自己重入了該臨界資源了,直接無並發增加持有次數。
- 第二部分就是嘗試加鎖的過程,由於是公平策略,所以需要先做判斷來判斷當前線程是否有資格去競爭鎖,也就是如果等待隊列中有其他節點在排隊,公平策略下是不允許「后來居上」的,當前線程不允許競爭。反之如果隊列是空或者當前線程排在隊列的第一個有效位置上,那么也認為不違反公平策略的,因為沒有「插別人的隊」,於是 CAS 更改 state 狀態,嘗試加鎖。
寫的分析文字有點多,但是這個嘗試加鎖的代碼邏輯確實是簡單易理解的。
我們再回到 acquire 方法:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
如果 tryAcquire 失敗了,那么將調用 acquireQueued 添加當前線程到等待隊列上並阻塞當前線程,我們一起看看這個方法的實現:
這個方法也是不難的,兩個部分,前一個部分是做「臨死掙扎」,如果自己是隊列首個有效的線程節點,那么將再進行一次嘗試,如果成功即刻返回而不必阻塞自己,否則將通過調用 LockSupport 中的 unpark 方法阻塞當前線程。
接着我們看寫鎖的釋放實現邏輯:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
廢話不多說,直接看 tryRelease 方法:
如果 state 中代表寫鎖持有數量值減去一還不等於零,那么說明當前線程多次重入該寫鎖,於是修改 state 的值,讓寫鎖持有數量減一,返回 false。
否則,認為該線程的多次重入已經全部退出了,這時才會返回 true,表示寫鎖全部釋放。
這時我們回到 release 方法,剩余的代碼也已經明了了,如果返回了 true,也即寫鎖全部釋放了,那么將喚醒隊列中等待着的第一個有效結點線程,喚醒之后方法返回 true,表示寫鎖釋放完成,否則返回 false,表示寫鎖釋放失敗,多次的重入並沒有得到完全的釋放。
寫在最后
總的來說,寫鎖的加鎖與釋放相對於讀鎖來說是簡單的,因為它是互斥了,沒那么多條件,不管你是什么鎖,只要你正在占用臨界資源,那么我就等待。而相對於讀鎖來說,它需要去區分讀線程正在使用資源、還是寫線程線程正在使用資源。
所以,讀寫鎖的復雜點在於讀鎖的共存,寫鎖是互斥的,沒有過多的要求,重點在於對讀鎖的理解。