Java并發編程(五):顯式鎖和 AQS

一、顯式鎖

有了 synchronized 為什么還要 Lock?

Java 程序是靠 synchronized 關鍵字實現鎖功能的,使用 synchronized 關鍵字將會隱式地獲取鎖,但是它將鎖的獲取和釋放固化了,也就是先獲取再釋放。

顯式鎖

1、Lock 的標準用法

Lock 的標準用法

在 finally 塊中釋放鎖,目的是保證在獲取到鎖之后,最終能夠被釋放不要將獲取鎖的過程寫在 try 塊中,因為如果在獲取鎖(自定義鎖的實現)時發生了異常,異常拋出的同時,也會導致鎖無故釋放。

2、Lock 的常用 API

Lock 的常用 API

3、ReentrantLock

3.1? 鎖的可重入

簡單地講就是:“同一個線程對于已經獲得到的鎖,可以多次繼續申請到該鎖的使用權”。而 synchronized 關鍵字隱式的支持重進入,比如一個 synchronized修飾的遞歸方法,在方法執行時,執行線程在獲取了鎖之后仍能連續多次地獲得該鎖。ReentrantLock 在調用 lock()方法時,已經獲取到鎖的線程,能夠再次調用lock()方法獲取鎖而不被阻塞

3.2? 公平和非公平鎖

如果在時間上,先對鎖進行獲取的請求一定先被滿足,那么這個鎖是公平的,反之,是不公平的。公平的獲取鎖,也就是等待時間最長的線程最優先獲取鎖,也可以說鎖獲取是順序的。 ReentrantLock 提供了一個構造函數,能夠控制鎖是否是公平的。事實上,公平的鎖機制往往沒有非公平的效率高。在激烈競爭的情況下,非公平鎖的性能高于公平鎖的性能的一個原因是:在恢復一個被掛起的線程與該線程真正開始運行之間存在著嚴重的延遲。假設線程 A持有一個鎖,并且線程 B 請求這個鎖。由于這個鎖已被線程 A 持有,因此 B 將被掛起。當 A 釋放鎖時,B 將被喚醒,因此會再次嘗試獲取鎖。與此同時,如果 C 也請求這個鎖,那么 C 很可能會在 B 被完全喚醒之前獲得、使用以及釋放這個鎖。這樣的情況是一種“雙贏”的局面:B 獲得鎖的時刻并沒有推遲,C 更早地獲得了鎖,并且吞吐量也獲得了提高。?

4、讀寫鎖 ReentrantReadWriteLock

之前提到鎖(如 Mutex 和 ReentrantLock)基本都是排他鎖,這些鎖在同一時刻只允許一個線程進行訪問,而讀寫鎖在同一時刻可以允許多個讀線程訪問但是在寫線程訪問時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得并發性相比一般的排他鎖有了很大提升。

除了保證寫操作對讀操作的可見性以及并發性的提升之外,讀寫鎖能夠簡化讀寫交互場景的編程方式。假設在程序中定義一個共享的用作緩存數據結構,大部分時間提供讀服務(例如查詢和搜索),而寫操作占有的時間很少,但是寫操作完成之后的更新需要對后續的讀服務可見。

在沒有讀寫鎖支持的(Java 5 之前)時候,如果需要完成上述工作就要使用Java 的等待通知機制,就是當寫操作開始時,所有晚于寫操作的讀操作均會進入等待狀態,只有寫操作完成并進行通知之后,所有等待的讀操作才能繼續執行(寫操作之間依靠 synchronized 關鍵進行同步),這樣做的目的是使讀操作能讀取到正確的數據,不會出現臟讀。改用讀寫鎖實現上述功能,只需要在讀操作時獲取讀鎖,寫操作時獲取寫鎖即可。當寫鎖被獲取到時,后續(非當前寫操作線程)的讀寫操作都會被阻塞,寫鎖釋放之后,所有操作繼續執行,編程方式相對于使用等待通知機制的實現方式而言,變得簡單明了。

一般情況下,讀寫鎖的性能都會比排它鎖好,因為大多數場景讀是多于寫的。在讀多于寫的情況下,讀寫鎖能夠提供比排它鎖更好的并發性和吞吐量ReentrantReadWriteLock 其實實現的是 ReadWriteLock 接口

5、Condition 接口

任意一個 Java 對象,都擁有一組監視器方法(定義在 java.lang.Object 上),主要包括 wait()、wait(long timeout)、notify()以及 notifyAll()方法,這些方法與synchronized 同步關鍵字配合,可以實現等待/通知模式。Condition 接口也提供了類似 Object 的監視器方法,與 Lock 配合可以實現等待/通知模式。

5.1? Condition 常用方法

Condition 常用方法

5.2? Condition 使用范式

Condition 使用范式

Condition 使用參見 cn.enjoyedu.ch4.condition. ExpressCond

6、了解 LockSupportLock

Support 定義了一組的公共靜態方法,這些方法提供了最基本的線程阻塞和喚醒功能,而 LockSupport 也成為構建同步組件的基礎工具。

LockSupport 定義了一組以 park 開頭的方法用來阻塞當前線程,以及unpark(Thread thread)方法來喚醒一個被阻塞的線程。LockSupport 增加了park(Object blocker)、parkNanos(Object blocker,long nanos)和 parkUntil(Objectblocker,long deadline)3 個方法,用于實現阻塞當前線程的功能,其中參數 blocker是用來標識當前線程在等待的對象(以下稱為阻塞對象),該對象主要用于問題排查和系統監控。

7、CLH 隊列鎖

CLH 隊列鎖即 Craig, Landin, and Hagersten (CLH) locks。CLH 隊列鎖也是一種基于鏈表的可擴展、高性能、公平的自旋鎖,申請線程僅僅在本地變量上自旋,它不斷輪詢前驅的狀態,假設發現前驅釋放了鎖就結束自旋。

當一個線程需要獲取鎖時:

1. 創建一個的QNode,將其中的locked設置為true表示需要獲取鎖,myPred表示對其前驅結點的引用

2. 線程 A 對 tail 域調用 getAndSet 方法,使自己成為隊列的尾部,同時獲取一個指向其前驅結點的引用 myPred

????????線程 B 需要獲得鎖,同樣的流程再來一遍

3.線程就在前驅結點的 locked 字段上旋轉,直到前驅結點釋放鎖(前驅節點的鎖值 locked == false)

4.當一個線程需要釋放鎖時,將當前結點的 locked 域設置為 false,同時回收前驅結點

如上圖所示,前驅結點釋放鎖,線程 A 的 myPred 所指向的前驅結點的 locked字段變為 false,線程 A 就可以獲取到鎖。

CLH 隊列鎖的優點是空間復雜度低(如果有 n 個線程,L 個鎖,每個線程每次只獲取一個鎖,那么需要的存儲空間是 O(L+n),n 個線程有 n 個 myNode,L 個鎖有 L 個 tail)。CLH 隊列鎖常用在 SMP 體系結構下。Java 中的 AQS 是 CLH 隊列鎖的一種變體實現。

7.1? 擴展知識點

SMP(Symmetric Multi-Processor)。即對稱多處理器結構,指 server 中多個 CPU 對稱工作,每一個 CPU 訪問內存地址所需時間同樣。其主要特征是共享,包括對 CPU,內存,I/O 等進行共享。SMP 的長處是可以保證內存一致性。缺點是這些共享的資源非??赡艹蔀樾阅茴i。隨著 CPU 數量的添加,每一個 CPU 都要訪問同樣的內存資源,可能導致內存訪問沖突,可能會導致 CPU 資源的浪費。經常使用的 PC 機就屬于這樣的。

非一致存儲訪問,將 CPU 分為 CPU 模塊,每個 CPU 模塊由多個 CPU 組成,并且具有獨立的本地內存、I/O 槽口等,模塊之間可以通過互聯模塊相互訪問,訪問本地內存(本 CPU模塊的內存)的速度將遠遠高于訪問遠地內存(其他 CPU 模塊的內存)的速度,這也是非一致存儲訪問的由來。NUMA 較好地解決 SMP 的擴展問題,當 CPU 數量增加時,因為訪問遠地內存的延時遠遠超過本地內存,系統性能無法線性增加。

CLH 唯一的缺點是在 NUMA 系統結構下性能很差,但是在 SMP 系統結構下該法還是非常有效的。解決 NUMA 系統結構的思路是 MCS 隊列鎖。

?二、AbstractQueuedSynchronizer

1? 學習 AQS 的必要性

隊列同步器 AbstractQueuedSynchronizer(以下簡稱同步器或 AQS),是用來構建鎖或者其他同步組件的基礎框架,它使用了一個 int 成員變量表示同步狀態,通過內置的 FIFO 隊列來完成資源獲取線程的排隊工作。并發包的大師(DougLea)期望它能夠成為實現大部分同步需求的基礎。

2? AQS 使用方式和其中的設計模式

AQS 的主要使用方式是繼承,子類通過繼承 AQS 并實現它的抽象方法來管理同步狀態,在 AQS 里由一個 int 型的 state 來代表這個狀態,在抽象方法的實現過程中免不了要對同步狀態進行更改,這時就需要使用同步器提供的 3 個方法(getState()、setState(int newState)和 compareAndSetState(int expect,int update))來進行操作,因為它們能夠保證狀態的改變是安全的。

在實現上,子類推薦被定義為自定義同步組件的靜態內部類,AQS 自身沒實現任何同步接口,它僅僅是定義了若干同步狀態獲取和釋放的方法來供自定同步組件使用,同步器既可以支持獨占式地獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣就可以方便實現不同類型的同步組件(ReentrantLock、ReentrantReadWriteLock 和 CountDownLatch 等)。

(1)同步器是實現鎖(也可以是任意同步組件)的關鍵,在鎖的實現中聚合同步器。可以這樣理解二者之間的關系:

(2)鎖是面向使用者的,它定義了使用者與鎖交互的接口(比如可以允許兩個線程并行訪問),隱藏了實現細節;

(3)同步器面向的是鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理線程的排隊、等待與喚醒等底層操作。鎖和同步器很好地隔離了使用者和實現者所需關注的領域。

(4)實現者需要繼承同步器并重寫指定的方法,隨后將同步器組合在自定義同組件的實現中,并調用同步器提供的模板方法,而這些模板方法將會調用使用者重寫的方法。

模板方法模式

同步器的設計基于模板方法模式。模板方法模式的意圖是,定義一個操作中的算法的骨架,而將一些步驟的實現延遲到子類中。模板方法使得子類可以不改變一個算法的結構即可重定義該算法的某些特定步驟。我們最常見的就是 Spring框架里的各種 Template。

實際例子

我們開了個蛋糕店,蛋糕店不能只賣一種蛋糕呀,于是我們決定先賣奶油蛋糕,芝士蛋糕和慕斯蛋糕。三種蛋糕在制作方式上一樣,都包括造型,烘焙和涂抹蛋糕上的東西。所以可以定義一個抽象蛋糕模型

? ? ? ?然后就可以批量生產三種蛋糕

這樣一來,不但可以批量生產三種蛋糕,而且如果日后有擴展,只需要繼承抽象蛋糕方法就可以了,十分方便,我們天天生意做得越來越賺錢。突然有一天,我們發現市面有一種最簡單的小蛋糕銷量很好,這種蛋糕就是簡單烘烤成型就可以賣,并不需要涂抹什么食材,由于制作簡單銷售量大,這個品種也很賺錢,于是我們也想要生產這種蛋糕。但是我們發現了一個問題,抽象蛋糕是定義了抽象的涂抹方法的,也就是說擴展的這種蛋糕是必須要實現涂抹方法,這就很雞兒蛋疼了。怎么辦?我們可以將原來的模板修改為帶鉤子的模板。

做小蛋糕的時候通過 flag 來控制是否涂抹,其余已有的蛋糕制作不需要任何修改可以照常進行。

3? AQS 中的方法

3.1 模板方法

實現自定義同步組件時,將會調用同步器提供的模板方法,

這些模板方法同步器提供的模板方法基本上分為 3 類:獨占式獲取與釋放同步狀態、共享式獲取與釋放、同步狀態和查詢同步隊列中的等待線程情況。

3.2 可重寫的方法

訪問或修改同步狀態的方法重寫同步器指定的方法時,需要使用同步器提供的如下 3 個方法來訪問或修改同步狀態。

?????????getState():獲取當前同步狀態。

?????????setState(int newState):設置當前同步狀態。

?????????compareAndSetState(int expect,int update):使用 CAS 設置當前狀態,該方法能夠保證狀態設置的原子性。

4? 實現一個自己的獨占鎖

參見代碼 cn.enjoyedu.ch4.aqs.SelfLock

5? 深入源碼

5.1? AQS 中的數據結構-節點和同步隊列

5.1.1 節點 Node

既然說 Java 中的 AQS 是 CLH 隊列鎖的一種變體實現,毫無疑問,作為隊列來說,必然要有一個節點的數據結構來保存我們前面所說的各種域,比如前驅節點,節點的狀態等,這個數據結構就是 AQS 中的內部類 Node。作為這個數據結構應該關心些什么信息?

????1、線程信息,肯定要知道我是哪個線程;

????2、隊列中線程狀態,既然知道是哪一個線程,肯定還要知道線程當前處在什么狀態,是已經取消了“獲鎖”請求,還是在“”等待中”,或者說“即將得到鎖”

????3、前驅和后繼線程,因為是一個等待隊列,那么也就需要知道當前線程前面的是哪個線程,當前線程后面的是哪個線程(因為當前線程釋放鎖以后,理當立馬通知后繼線程去獲取鎖)。

所以這個 Node 類是這么設計的:

其中包括了:

線程的 2 種等待模式:

????SHARED:表示線程以共享的模式等待鎖(如 ReadLock)

????EXCLUSIVE:表示線程以互斥的模式等待鎖(如 ReetrantLock),互斥就是一把鎖只能由一個線程持有,不能同時存在多個線程使用同一個鎖

線程在隊列中的狀態枚舉:

????CANCELLED:值為 1,表示線程的獲鎖請求已經“取消”

????SIGNAL:值為-1,表示該線程一切都準備好了,就等待鎖空閑出來給我

????CONDITION:值為-2,表示線程等待某一個條件(Condition)被滿足

????PROPAGATE:值為-3,當線程處在“SHARED”模式時,該字段才會被使用上

????初始化 Node 對象時,默認為 0

成員變量:

waitStatus:該 int 變量表示線程在隊列中的狀態,其值就是上述提到的CANCELLED、SIGNAL、CONDITION、PROPAGATE

????prev:該變量類型為 Node 對象,表示該節點的前一個 Node 節點(前驅)

????next:該變量類型為 Node 對象,表示該節點的后一個 Node 節點(后繼)

????thread:該變量類型為 Thread 對象,表示該節點的代表的線程

????nextWaiter:該變量類型為 Node 對象,表示等待 condition 條件的 Node 節點

當前線程獲取同步狀態失敗時,同步器會將當前線程以及等待狀態等信息構造成為一個節點(Node)并將其加入同步隊列,同時會阻塞當前線程,當同步狀態釋放時,會把首節點中的線程喚醒,使其再次嘗試獲取同步狀態。同步隊列中的節點(Node)用來保存獲取同步狀態失敗的線程引用、等待狀態以及前驅和后繼節點。

5.1.2 head 和 tail

AQS 還擁有首節點(head)和尾節點(tail)兩個引用,一個指向隊列頭節點,而另一個指向隊列尾節點。

注意:因為首節點 head 是不保存線程信息的節點,僅僅是因為數據結構設計上的需要,在數據結構上,這種做法往往叫做“空頭節點鏈表”。對應的就有“非空頭結點鏈表”?

6、節點在同步隊列中的增加和移出

6.1? 節點加入到同步隊列

當一個線程成功地獲取了同步狀態(或者鎖),其他線程將無法獲取到同步狀態,也就是獲取同步狀態失敗,AQS 會將這個線程以及等待狀態等信息構造成為一個節點(Node)并將其加入同步隊列的尾部。而這個加入隊列的過程必須要保證線程安全,因此同步器提供了一個基于 CAS 的設置尾節點的方法:compareAndSetTail(Node expect,Nodeupdate),它需要傳遞當前線程“認為”的尾節點和當前節點,只有設置成功后,當前節點才正式與之前的尾節點建立關聯。

6.2? 首節點的變化

首節點是獲取同步狀態成功的節點,首節點的線程在釋放同步狀態時,將會喚醒后繼節點,而后繼節點將會在獲取同步狀態成功時將自己設置為首節點。設置首節點是通過獲取同步狀態成功的線程來完成的,由于只有一個線程能夠成功獲取到同步狀態,因此設置頭節點的方法并不需要使用 CAS 來保證,它只需要將首節點設置成為原首節點的后繼節點并斷開原首節點的 next 引用即可。

6.3? 獨占式同步狀態獲取與釋放

6.3.1 獲取

通過調用同步器的 acquire(int arg)方法可以獲取同步狀態,主要完成了同步狀態獲取、節點構造、加入同步隊列以及在同步隊列中自旋等待的相關工作,其主要邏輯是:

首先調用自定義同步器實現的 tryAcquire(int arg)方法,該方法需要保證線程安全的獲取同步狀態。如果同步狀態獲取失敗(tryAcquire 返回 false),則構造同步節點(獨占式Node.EXCLUSIVE,同一時刻只能有一個線程成功獲取同步狀態)并通過addWaiter(Node node)方法將該節點加入到同步隊列的尾部,最后調用 acquireQueued(Node node,int arg)方法,使得該節點以“死循環”的方式獲取同步狀態。如果獲取不到則阻塞節點中的線程,而被阻塞線程的喚醒主要依靠前驅節點的出隊或阻塞線程被中斷來實現。

addWaiter(Node node)方法中

將當前線程包裝成 Node 后,隊列不為空的情況下,先嘗試把當前節點加入隊列并成為尾節點,如果不成功或者隊列為空進入 enq(final Node node)方法。

在 enq(final Node node)方法中,同步器通過“死循環”來保證節點的正確添加,這個死循環中,做了兩件事,第一件,如果隊列為空,初始化隊列,new 出一個空節點,并讓首節點(head)和尾節點(tail)兩個引用都指向這個空節點;第二件事,把當前節點加入隊列。在“死循環”中只有通過 CAS 將節點設置成為尾節點之后,當前線程才能從該方法返回,否則,當前線程不斷地嘗試設置。節點進入同步隊列之后,觀察 acquireQueued(Node node,int arg)方法

其實就是一個自旋的過程,每個節點(或者說每個線程)都在自省地觀察,當條件滿足,獲取到了同步狀態,就可以從這個自旋過程中退出,否則依舊留在這個自旋過程中(并會阻塞節點的線程)。在 acquireQueued(final Node node,int arg)方法中,當前線程在“死循環”中嘗試獲取同步狀態,而只有前驅節點是頭節點才能夠嘗試獲取同步狀態,這是為什么?原因有兩個。

第一,頭節點是成功獲取到同步狀態的節點,而頭節點的線程釋放了同步狀態之后,將會喚醒其后繼節點,后繼節點的線程被喚醒后需要檢查自己的前驅節點是否是頭節點。

第二,維護同步隊列的 FIFO 原則。

當前線程獲取到同步狀態后,讓首節點(head)這個引用指向自己所在節點。當同步狀態獲取成功后,當前線程就從 acquire 方法返回了。如果同步器實現的是鎖,那就代表當前線程獲得了鎖。

6.3.2? 釋放

當前線程獲取同步狀態并執行了相應邏輯之后,就需要釋放同步狀態,使得后續節點能夠繼續獲取同步狀態。通過調用同步器的 release(int arg)方法可以釋放同步狀態,該方法在釋放了同步狀態之后,會喚醒其后繼節點(進而使后繼節點重新嘗試獲取同步狀態)。

該方法執行時,會喚醒首節點(head)所指向節點的后繼節點線程,unparkSuccessor(Node node)方法使用 LockSupport 來喚醒處于等待狀態的線程。而在 unparkSuccessor 中,

這段代碼的意思,一般情況下,被喚醒的是 head 指向節點的后繼節點線程,如果這個后繼節點處于被 cancel 狀態,(我推測開發者的思路這樣的:后繼節點處于被 cancel 狀態,意味著當鎖競爭激烈時,隊列的第一個節點等了很久(一直被還未加入隊列的節點搶走鎖),包括后續的節點 cancel 的幾率都比較大,所以)先從尾開始遍歷,找到最前面且沒有被 cancel 的節點。

6.3.3? 總結

在獲取同步狀態時,同步器維護一個同步隊列,獲取狀態失敗的線程都會加入到隊列中并在隊列中進行自旋;移出隊列(或停止自旋)的條件是前驅節點為頭節點且成功獲取了同步狀態。在釋放同步狀態時,同步器調用 tryRelease(intarg)方法釋放同步狀態,然后喚醒 head 指向節點的后繼節點。

6.4? 共享式同步狀態獲取與釋放

共享式獲取與獨占式獲取最主要的區別在于同一時刻能否有多個線程同獲取到同步狀態。以讀寫為例,如果一個程序在進行讀操作,那么這一時刻寫操作均被阻塞,而讀操作能夠同時進行。寫操作要求對資源的獨占式訪問,而讀操作可以是共享式訪問。在 acquireShared(int arg)方法中,同步器調用 tryAcquireShared(int arg)方法試獲取同步狀態,tryAcquireShared(int arg)方法返回值為 int 類型,當返回值大于等于 0 時,表示能夠獲取到同步狀態。因此,在共享式獲取的自旋過程中,成功獲取到同步狀態并退出自旋的條件就是 tryAcquireShared(int arg)方法返回值大于等于 0??梢钥吹剑?doAcquireShared(int arg)方法的自旋過程中,如果當前節點的前驅為頭節點時,嘗試獲取同步狀態,如果返回值大于等于 0,表示該次獲取同步狀態成功并從自旋過程中退出。該方法在釋放同步狀態之后,將會喚醒后續處于等待狀態的節點。對于能夠支持多個線程同時訪問的并發組件(比如 Semaphore),它和獨占式主要區別在于 tryReleaseShared(int arg)方法必須確保同步狀態(或者資源數)線程安全釋放,一般是通過循環和 CAS 來保證的,因為釋放同步狀態的操作會同時來自多個線程。?

7、共享式的同步工具類

設計一個同步工具:該工具在同一時刻,只允許至多 3 個線程同時訪問,超過 3 個線程的訪問將被阻塞

首先,確定訪問模式。TrinityLock 能夠在同一時刻支持多個線程的訪問,這顯然是共享式訪問,因此,需要使用同步器提供的 acquireShared(int args)方法等和 Shared 相關的方法,這就要求 TwinsLock 必須重寫 tryAcquireShared(int args)方法和 tryReleaseShared(int args)方法,這樣才能保證同步器的共享式同步狀態的獲取與釋放方法得以執行。

其次,定義資源數。TrinityLock 在同一時刻允許至多三個線程的同時訪問,表明同步資源數為 3,這樣可以設置初始狀態 status 為 3,當一個線程進行獲取,status 減 1,該線程釋放,則 status 加 1,狀態的合法范圍為 0、1 和 2,3,其中 0表示當前已經有 3 個線程獲取了同步資源,此時再有其他線程對同步狀態進行取,該線程只能被阻塞。在同步狀態變更時,需要使用 compareAndSet(intexpect,int update)方法做原子性保障。

最后,組合自定義同步器。前面的章節提到,自定義同步組件通過組合自定義同步器來完成同步功能,一般情況下自定義同步器會被定義為自定義同步組件的內部類。?

8、了解 Condition 的實現

Condition 的數據結構

等待隊列是一個 FIFO 的隊列,在隊列中的每個節點都包含了一個線程引用,該線程就是在Condition對象上等待的線程,如果一個線程調用了Condition.await()方法,那么該線程將會釋放鎖、構造成節點加入等待隊列并進入等待狀態。事實上,節點的定義復用了同步器中節點的定義,也就是說,同步隊列和等待隊列中節點類型都是同步器的靜態內部類。

一個 Condition 包含一個等待隊列,Condition 擁有首節點(firstWaiter)和尾節點(lastWaiter)。當前線程調用 Condition.await()方法,將會以當前線程構造節點,并將節點從尾部加入等待隊列。Condition 擁有首尾節點的引用,而新增節點只需要將原有的尾節點 nextWaiter 指向它,并且更新尾節點即可。上述節點引用更新的過程并沒有使用 CAS 保證,原因在于調用 await()方法的線程必定是獲取了鎖的線程,也就是說該過程是由鎖來保證線程安全的。

Lock(更確切地說是同步器)擁有一個同步隊列和多個等待隊列。

調用 Condition 的 await()方法(或者以 await 開頭的方法),會使當前線程進入等待隊列并釋放鎖,同時線程狀態變為等待狀態。當從 await()方法返回時,當前線程一定獲取了 Condition 相關聯的鎖。

如果從隊列(同步隊列和等待隊列)的角度看 await()方法,當調用 await()方法時,相當于同步隊列的首節點(獲取了鎖的節點)移動到 Condition 的等待隊列中。調用該方法的線程成功獲取了鎖的線程,也就是同步隊列中的首節點,該方法會將當前線程構造成節點并加入等待隊列中,然后釋放同步狀態,喚醒同步隊列中的后繼節點,然后當前線程會進入等待狀態。當等待隊列中的節點被喚醒,則喚醒節點的線程開始嘗試獲取同步狀態。如果不是通過其他線程調用Condition.signal()方法喚醒,而是對等待線程進行中斷,則會拋出InterruptedException。

如圖所示,同步隊列的首節點并不會直接加入等待隊列,而是通過addConditionWaiter()方法把當前線程構造成一個新的節點并將其加入等待隊列中。

調用 Condition 的 signal()方法,將會喚醒在等待隊列中等待時間最長的節點(首節點),在喚醒節點之前,會將節點移到同步隊列中。

調用該方法的前置條件是當前線程必須獲取了鎖,可以看到 signal()方法進行了 isHeldExclusively()檢查,也就是當前線程必須是獲取了鎖的線程。接著獲取等待隊列的首節點,將其移動到同步隊列并使用 LockSupport 喚醒節點中的線程。

通過調用同步器的 enq(Node node)方法,等待隊列中的頭節點線程安全地移動到同步隊列。當節點移動到同步隊列后,當前線程再使用 LockSupport 喚醒該節點的線程。

被喚醒后的線程,將從 await()方法中的 while 循環中退出(isOnSyncQueue(Node node)方法返回 true,節點已經在同步隊列中),進而調用同步器的 acquireQueued()方法加入到獲取同步狀態的競爭中。

成功獲取同步狀態(或者說鎖)之后,被喚醒的線程將從先前調用的 await()方法返回,此時該線程已經成功地獲取了鎖。

Condition 的 signalAll()方法,相當于對等待隊列中的每個節點均執行一次signal()方法,效果就是將等待隊列中所有節點全部移動到同步隊列中,并喚醒每個節點的線程。?

三、回頭看 Lock 的實現

1、ReentrantLock 的實現

1.1 鎖的可重入

重進入是指任意線程在獲取到鎖之后能夠再次獲取該鎖而不會被鎖所阻塞,該特性的實現需要解決以下兩個問題。1)線程再次獲取鎖。鎖需要去識別獲取鎖的線程是否為當前占據鎖的線程,如果是,則再次成功獲取。2)鎖的最終釋放。線程重復 n 次獲取了鎖,隨后在第 n 次釋放該鎖后,其他線程能夠獲取到該鎖。鎖的最終釋放要求鎖對于獲取進行計數自增,計數表示當前鎖被重復獲取的次數,而鎖被釋放時,計數自減,當計數等于 0 時表示鎖已經成功釋放。nonfairTryAcquire 方法增加了再次獲取同步狀態的處理邏輯:通過判斷當前線程是否為獲取鎖的線程來決定獲取操作是否成功,如果是獲取鎖的線程再次請求,則將同步狀態值進行增加并返回 true,表示獲取同步狀態成功。同步狀態表示鎖被一個線程重復獲取的次數。如果該鎖被獲取了 n 次,那么前(n-1)次 tryRelease(int releases)方法必須返回false,而只有同步狀態完全釋放了,才能返回 true??梢钥吹?,該方法將同步狀態是否為 0 作為最終釋放的條件,當同步狀態為 0 時,將占有線程設置為 null,并返回 true,表示釋放成功。

1.2 公平和非公平鎖

ReentrantLock 的構造函數中,默認的無參構造函數將會把 Sync 對象創建為NonfairSync 對象,這是一個“非公平鎖”;而另一個構造函數ReentrantLock(boolean fair)傳入參數為 true 時將會把 Sync 對象創建為“公平鎖”FairSync。nonfairTryAcquire(int acquires)方法,對于非公平鎖,只要 CAS 設置同步狀態成功,則表示當前線程獲取了鎖,而公平鎖則不同。tryAcquire 方法,該方法與nonfairTryAcquire(int acquires)比較,唯一不同的位置為判斷條件多了hasQueuedPredecessors()方法,即加入了同步隊列中當前節點是否有前驅節點的判斷,如果該方法返回 true,則表示有線程比當前線程更早地請求獲取鎖,因此需要等待前驅線程獲取并釋放鎖之后才能繼續獲取鎖。

1.3 改造我們的獨占鎖為可重入

參見代碼 cn.enjoyedu.ch4.aqs.ReenterSelfLock

2、ReentrantReadWriteLock 的實現

2.1 讀寫狀態的設計

讀寫鎖同樣依賴自定義同步器來實現同步功能,而讀寫狀態就是其同步器的同步狀態?;叵?ReentrantLock 中自定義同步器的實現,同步狀態表示鎖被一個線程復獲取的次數,而讀寫鎖的自定義同步器需要在同步狀態(一個整型變量)上維護多個讀線程和一個寫線程的狀態,使得該狀態的設計成為讀寫鎖實現的關鍵。如果在一個整型變量上維護多種狀態,就一定需要“按位切割使用”這個變量,讀寫鎖將變量切分成了兩個部分,高 16 位表示讀,低 16 位表示寫,讀寫鎖是如何迅速確定讀和寫各自的狀態呢?答案是通過位運算。假設當前同步狀態值為 S,寫狀態等于 S&0x0000FFFF(將高 16 位全部抹去),讀狀態等于 S>>>16(無符號補 0 右移 16 位)。當寫狀態增加1時,等于S+1,當讀狀態增加1時,等于S+(1<<16),也就是S+0x00010000。根據狀態的劃分能得出一個推論:S 不等于 0 時,當寫狀態(S&0x0000FFFF)等于 0 時,則讀狀態(S>>>16)大于 0,即讀鎖已被獲取。

2.2 寫鎖的獲取與釋放

寫鎖是一個支持重進入的排它鎖。如果當前線程已經獲取了寫鎖,則增加寫狀態。如果當前線程在獲取寫鎖時,讀鎖已經被獲?。ㄗx狀態不為 0)或者該線程不是已經獲取寫鎖的線程,則當前線程進入等待狀態。該方法除了重入條件(當前線程為獲取了寫鎖的線程)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被獲取,原因在于:讀寫鎖要確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那么正在運行的其他讀線程就無法感知到當前寫線程的操作。因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當前線程獲取,而寫鎖一旦被獲取,則其他寫線程的后續訪問均被阻塞。寫鎖的釋放與 ReentrantLock 的釋放過程基本類似,每次釋放均減少寫狀態,當寫狀態為 0 時表示寫鎖已被釋放,從而等待的讀寫線程能夠繼續訪問讀寫鎖,同時前次寫線程的修改對后續讀寫線程可見。

2.3 讀鎖的獲取與釋放

讀鎖是一個支持重進入的共享鎖,它能夠被多個線程同時獲取,在沒有其他寫線程訪問(或者寫狀態為 0)時,讀鎖總會被成功地獲取,而所做的也只是(線程安全的)增加讀狀態。如果當前線程已經獲取了讀鎖,則增加讀狀態。如果當前線程在獲取讀鎖時,寫鎖已被其他線程獲取,則進入等待狀態。讀狀態是所有線程獲取讀鎖次數的總和,而每個線程各自獲取讀鎖的次數只能選擇保存在 ThreadLocal 中,由線程自身維護。在 tryAcquireShared(int unused)方法中,如果其他線程已經獲取了寫鎖,則當前線程獲取讀鎖失敗,進入等待狀態。如果當前線程獲取了寫鎖或者寫鎖未被獲取,則當前線程(線程安全,依靠 CAS 保證)增加讀狀態,成功獲取讀鎖。讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態。

2.4 鎖的升降級

鎖降級指的是寫鎖降級成為讀鎖。如果當前線程擁有寫鎖,然后將其釋放,最后再獲取讀鎖,這種分段完成的過程不能稱之為鎖降級。鎖降級是指把持?。ó斍皳碛械模戞i,再獲取到讀鎖,隨后釋放(先前擁有的)寫鎖的過程。RentrantReadWriteLock 不支持鎖升級(把持讀鎖、獲取寫鎖,最后釋放讀鎖的過程)。目的是保證數據可見性,如果讀鎖已被多個線程獲取,其中任意線程成功獲取了寫鎖并更新了數據,則其更新對其他獲取到讀鎖的線程是不可見的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370