latch和lock的區別_兒童安全座椅latch接口
AbstractQueuedSynchronizer(AQS)是 Java 并發編程中繞不過去的一道坎,JUC 并發包下的 Lock、Semaphore、ReentrantLock 等都是基于 AQS 實現的。AQS 是一個抽象的同步框架,提供了原子性管理同步狀態,基于阻塞隊列模型實現阻塞和喚醒等待線程的功能
文章從 ReentrantLock 加鎖、解鎖應用 API 入手,逐步講解 AQS 對應源碼以及相關隱含流程
列出本篇文章大綱以及相關知識點,方便大家更好的理解
ReentrantLock 翻譯為 可重入鎖,指的是一個線程能夠對 臨界區共享資源進行重復加鎖
確保線程安全最常見的做法是利用鎖機制(Lock、sychronized)來對 共享數據做互斥同步,這樣在同一個時刻,只有 一個線程可以執行某個方法或者某個代碼塊,那么操作必然是 原子性的,線程安全的
這里就有個疑問,因為 JDK 中關鍵字 synchronized 也能同時支持原子性以及線程安全
有了 synchronized 關鍵字后為什么還需要 ReentrantLock?
為了大家更好的掌握 ReentrantLock 源碼,這里列出兩種鎖之間的區別
通過以上六個維度比對,可以看出 ReentrantLock 是要比 synchronized 靈活以及支持功能更豐富
什么是 AQSAQS( AbstractQueuedSynchronizer )是一個用來構建鎖和同步器的抽象框架,只需要繼承 AQS 就可以很方便的實現我們自定義的多線程同步器、鎖
如圖所示,在 java.util.concurrent 包下相關鎖、同步器(常用的有 ReentrantLock、 ReadWriteLock、CountDownLatch…)都是基于 AQS 來實現
AQS 是典型的模板方法設計模式,父類(AQS)定義好骨架和內部操作細節,具體規則由子類去實現
AQS 核心原理如果被請求的共享資源未被占用,將當前請求資源的線程設置為獨占線程,并將共享資源設置為鎖定狀態
AQS 使用一個 Volatile 修飾的 int 類型的成員變量 State 來表示同步狀態,修改同步狀態成功即為獲得鎖
Volatile 保證了變量在多線程之間的可見性,修改 State 值時通過 CAS 機制來保證修改的原子性
如果共享資源被占用,需要一定的阻塞等待喚醒機制來保證鎖的分配,AQS 中會將競爭共享資源失敗的線程添加到一個變體的 CLH 隊列中
關于支撐 AQS 特性的重要方法及屬性如下:
CLH 隊列既然是 AQS 中使用的是 CLH 變體隊列,我們先來了解下 CLH 隊列是什么
CLH:Craig、Landin and Hagersten 隊列,是 單向鏈表實現的隊列。申請線程只在本地變量上自旋,它不斷輪詢前驅的狀態,如果發現 前驅節點釋放了鎖就結束自旋
通過對 CLH 隊列的說明,可以得出以下結論
- CLH 隊列是一個單向鏈表,保持 FIFO 先進先出的隊列特性
- 通過 tail 尾節點(原子引用)來構建隊列,總是指向最后一個節點
- 未獲得鎖節點會進行自旋,而不是切換線程狀態
- 并發高時性能較差,因為未獲得鎖節點不斷輪訓前驅節點的狀態來查看是否獲得鎖
AQS 中的隊列是 CLH 變體的虛擬雙向隊列,通過將每條請求共享資源的線程封裝成一個節點來實現鎖的分配
相比于 CLH 隊列而言,AQS 中的 CLH 變體等待隊列擁有以下特性
- AQS 中隊列是個雙向鏈表,也是 FIFO 先進先出的特性
- 通過 Head、Tail 頭尾兩個節點來組成隊列結構,通過 volatile 修飾保證可見性
- Head 指向節點為已獲得鎖的節點,是一個虛擬節點,節點本身不持有具體線程
- 獲取不到同步狀態,會將節點進行自旋獲取鎖,自旋一定次數失敗后會將線程阻塞,相對于 CLH 隊列性能較好
抽象類 AQS 同樣繼承自抽象類 AOS(AbstractOwnableSynchronizer)
AOS 內部只有一個 Thread 類型的變量,提供了獲取和設置當前獨占鎖線程的方法
主要作用是 記錄當前占用獨占鎖(互斥鎖)的線程實例
為什么要掌握 AQS如何能夠體現程序員的水平,那就是掌握大多數人所不掌握的技術,這也是為什么面試時 AQS 高頻出現的原因,因為它不簡單
最初接觸 ReentrantLock 以及 AQS 的時候,看到源碼就是一頭霧水,Debug 跟著跟著就 迷失了自己,相信這也是大多數人的反應
正是因為經歷過,所以才能從小白的心理上出發,把其中的知識點能夠盡數梳理
作者寫的很用心,看過這篇文章的小伙伴,不敢保證百分百理解 AQS 和 ReentrantLock 的原理,但是一定會有所收獲
獨占加鎖源碼解析什么是獨占鎖獨占鎖也叫排它鎖,是指該鎖一次只能被一個線程所持有,如果別的線程想要獲取鎖,只有等到持有鎖線程釋放
獲得排它鎖的線程即能讀數據又能修改數據,與之對立的就是共享鎖
共享鎖是指該鎖可被多個線程所持有。如果線程T對數據A加上共享鎖后,則其他線程只能對A再加共享鎖,不能加排它鎖
獲得共享鎖的線程只能讀數據,不能修改數據
獨占鎖加鎖ReentrantLock 就是獨占鎖的一種實現方式,接下來看代碼中如何使用 ReentrantLock 完成獨占式加鎖業務邏輯
new ReentrantLock() 構造函數默認創建的是非公平鎖 NonfairSync
同時也可以在創建鎖構造函數中傳入具體參數創建公平鎖 FairSync
FairSync、NonfairSync 代表公平鎖和非公平鎖,兩者都是 ReentrantLock 靜態內部類,只不過實現不同鎖語義
公平鎖 FairSync
- 公平鎖是指多個線程按照申請鎖的順序來獲取鎖,線程直接進入隊列中排隊,隊列中的第一個線程才能獲得鎖
- 公平鎖的優點是等待鎖的線程不會餓死。缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU 喚醒阻塞線程的開銷比非公平鎖大
非公平鎖 NonfairSync
- 非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到才會到等待隊列的隊尾等待。但如果此時鎖剛好可用,那么這個線程可以無需阻塞直接獲取到鎖
- 非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因為線程有幾率不阻塞直接獲得鎖,CPU 不必喚醒所有線程。缺點是處于等待隊列中的線程可能會餓死,或者等很久才會獲得鎖
兩者的都繼承自 ReentrantLock 靜態抽象內部類 Sync,Sync 類繼承自 AQS,這里就有個疑問
這些鎖都沒有直接繼承 AQS,而是定義了一個 Sync 類去繼承 AQS,為什么要這樣呢?
因為 鎖面向的是使用用戶,同步器面向的則是線程控制,那么在鎖的實現中聚合同步器而不是直接繼承 AQS 就可以很好的 隔離二者所關注的事情
通過對不同鎖種類的講解以及 ReentrantLock 內部結構的解析,根據上下級關系繼承圖,加深其理解
這里以非公平鎖舉例,查看加鎖的具體過程,詳細信息下文會詳細說明
看一下非公平鎖加鎖方法 lock 內部怎么做的
Sync#lock 為抽象方法,最終會調用其子類非公平鎖的方法 lock
非公平加鎖方法有兩個邏輯
- 通過比較并替換 State(同步狀態)成功與否決定是否獲得鎖,設置 State 為 1表示成功獲取鎖,并將當前線程設置為獨占線程
- 修改 State 值失敗則進入嘗試獲取鎖流程,acquire 方法為 AQS 提供的方法
compareAndSetState 以 CAS 比較并替換的方式將 State 值設置為 1,表示同步狀態被占用
setExclusiveOwnerThread 設置當前線程為獨占鎖擁有線程
acquire 對整個 AQS 做到了承上啟下的作用,通過 tryAcquire 模版方法進行嘗試獲取鎖,獲取鎖失敗包裝當前線程為 Node 節點加入等待隊列排隊
tryAcquire 是 AQS 中抽象模版方法,但是內部會有默認實現,雖然默認的方法內部拋出異常,為什么不直接定義為抽象方法呢?
因為 AQS 不只是對獨占鎖實現了抽象,同時還包括共享鎖;不同鎖定義了不同類別的方法,共享鎖就不需要 tryAcquire,如果定義為抽象方法,繼承 AQS 子類都需要實現該方法
NonfairSync 類中有 tryAcquire 重寫方法,繼續查看具體如何進行非公平方式獲取鎖
由于 tryAcquire 做了取反,如果設置 state 失敗并且獨占鎖線程不是自己本身返回 false,通過取反會進入接下來的流程
Node 入隊流程嘗試獲得鎖失敗,接下來會將線程組裝成為 Node 進行入隊流程
Node 是 AQS 中最基本的數據結構,也是 CLH 變體隊列中的節點,Node 有 SHARED(共享)、EXCLUSIVE(獨占) 兩種模式,文章主要介紹 EXCLUSIVE 模式,不相關的屬性和方法不予介紹
下面列出關于 Node EXCLUSIVE 模式的一些關鍵方法以及狀態信息
Node 中獨占鎖相關的 waitStatus 屬性分別有以下幾種狀態
介紹完 Node 相關基礎知識,看一下請求鎖線程如何被包裝為 Node,又是如何初始化入隊的
pred 為隊列的尾節點,根據尾節點是否為空會執行對應流程
- 尾節點不為空,證明隊列已被初始化,那么需要將對應的 node(當前線程)設置為新的尾節點,也就是入隊操作;將 node 節點的前驅指針指向 pred(尾節點),并將 node 通過 CAS 方式設置為 AQS 等待隊列的尾節點,替換成功后將原來的尾節點后繼指針指向新的尾節點
- 尾節點為空,證明還沒有初始化隊列,執行 enq 方法進行初始化隊列
enq 方法執行初始化隊列操作,等待隊列中虛擬化的頭節點也是在這里產生
執行 enq 方法的前提就是隊列尾節點為空,為什么還要再判斷尾節點是否為空?
因為 enq 方法中是一個死循環,循環過程中 t 的值是不固定的。假如執行 enq 方法時隊列為空,for 循環會執行兩遍不同的處理邏輯
- 尾節點為空,虛擬化出一個新的 Node 頭節點,這時隊列中只有一個元素,為了保證 AQS 隊列結構的完整性,會將尾節點指向頭節點,第一遍循環結束
- 第二遍不滿足尾節點為空條件,執行 else 語句塊,node 節點前驅指針指向尾節點,并將 node 通過 CAS 設置為新的尾節點,成功后設置原尾節點的后繼指針指向 node,至此入隊成功。返回的 t 無意義,只是為了終止死循環
畫兩張圖來理解 enq 方法整體初始化 AQS 隊列流程,假設T1、T2兩個線程爭取鎖,T1成功獲得鎖,T2進行入隊操作
- T2進行入隊操作,循環第一遍,尾節點為空。開始初始化頭節點,并將尾節點指向頭節點,最終隊列形式是這樣紙滴
- 循環第二遍,需要將 node 設置為新的尾節點。邏輯如下:尾節點不為空,設置 node 前驅指針指向尾節點,并將 node 設置為尾節點,原尾節點 next 指針指向 node
addWaiter 方法就是為了讓 Node 入隊,并且維護出一個雙向隊列模型
入隊執行成功后,會在 acquireQueued 再次嘗試競爭鎖,競爭失敗后會將線程阻塞
acquireQueued 方法會嘗試自旋獲取鎖,獲取失敗對當前線程實施阻塞流程,這也是為了避免無意義的自旋,對比 CLH 隊列性能優化的體現
通過 node.predecessor() 獲取節點的前驅節點,前驅節點為空拋出空指針異常
獲取到前驅節點后進行兩步邏輯判斷
- 判斷前驅節點 p 是否為頭節點,為 true 進行嘗試獲取鎖,獲取鎖成功設置當前節點為新的頭節點,并將原頭節點的后驅指針設為空
- 前驅節點不是頭節點或者嘗試加鎖失敗,執行線程休眠阻塞操作
如果 node 獲得鎖后,setHead 將節點設置為隊列頭,從而實現出隊效果,出于 GC 的考慮,清空未使用的數據
shouldParkAfterFailedAcquire 需要重點關注下,流程相對比較難理解
ws 表示為當前申請鎖節點前驅節點的等待狀態,代碼中包含三個邏輯,分別是:
- ws == Node.SIGNAL,表示需要將申請鎖節點進行阻塞
- ws > 0,表示等待隊列中包含被取消節點,需要調整隊列
- 如果 ws == Node.SIGNAL || ws >0 都為 false,使用 CAS 的方式將前驅節點等待狀態設置為 Node.SIGNAL
設置當前節點的前置節點等待狀態為 Node.SIGNAL,表示當前節點獲取鎖失敗,需要進行阻塞操作
還是通過幾張圖來理解流程,假設此時 T1、T2 線程來爭奪鎖
T1 線程獲得鎖,T2 進入 AQS 等待隊列排隊,并通過 CAS 將 T2 節點的前驅節點等待狀態置為 SIGNAL
執行切換前驅節點等待狀態后返回 false,繼續進行循環嘗試獲取同步狀態
這一步操作保證了線程能進行多次重試,盡量避免線程狀態切換
如果 T1 線程沒有釋放鎖,T2 線程第二次執行到 shouldParkAfterFailedAcquire 方法,因為前驅節點已設置為 SIGNAL,所以會直接返回 true,執行線程阻塞操作
LockSupport.park 方法將當前等待隊列中線程進行阻塞操作,線程執行一個從 RUNNABLE 到 WAITING 狀態轉變
如果線程被喚醒,通過執行 Thread.interrupted 查看中斷狀態,這里的中斷狀態會被傳遞到 acquire 方法
即使線程從 park 方法中喚醒后發現自己被中斷了,但是不影響接下來的獲取鎖操作,如果需要設置線程中斷來影響流程,可以使用 lockInterruptibly 獲得鎖,拋出檢查異常 InterruptedExceptio
cancelAcquire取消排隊方法是 AQS 中比較難的知識點,不容易被理解
當線程因為自旋或者異常等情況獲取鎖失敗,會調用此方法進行取消正在獲取鎖的操作
邏輯稍微復雜一些,比較重要是以下三個邏輯
- 步驟一當前節點為尾節點的話,設置 pred 節點為新的尾節點,成功設置后再將 pred 后繼節點設置為空(尾節點不會有后繼節點)
- 步驟二需要滿足以下四個條件才會將前驅節點(非取消狀態)的后繼指針指向當前節點的后繼指針1)當前節點不等于尾節點2)當前節點前驅節點不等于頭節點3)前驅節點的等待狀態不為取消狀態4)前驅節點的擁有線程不為空
- 如果不滿足步驟二的話,會執行步驟三相關邏輯,喚醒后繼節點
步驟一:
假設當前取消節點為尾節點并且前置節點無取消節點,現有等待隊列如下圖,執行下述邏輯
將 pred 設置為新的尾節點,并將 pred 后繼節點設置為空,因為尾節點不會有后繼節點了
T4 線程所在節點因無引用指向,會被 GC 垃圾回收處理
步驟二:
如果當前需要取消節點的前驅節點為取消狀態節點,如圖所示
設置 pred(非取消狀態)的后繼節點為 node 的后繼節點,并設置 node 的 next 為 自己本身
線程T2、T3所在節點因為被T4所直接或間接指向,如何進行GC?
AQS 等待隊列中取消狀態節點會在 shouldParkAfterFailedAcquire 方法中被 GC 垃圾回收
T4 線程所在節點獲取鎖失敗嘗試停止時,會執行上述代碼,執行后的等待隊列如下圖所示
等待隊列中取消狀態節點就可以被 GC 垃圾回收了,至此加鎖流程也就結束了,下面繼續看如何解鎖
獨占解鎖源碼解析解鎖流程相對于加鎖簡單了很多,調用對應API-lock.unlock()
釋放鎖同步狀態tryRelease 是定義在 AQS 中的抽象方法,通過 Sync 類重寫了其實現
喚醒后繼節點此時 State 值已被釋放,對于頭節點的判斷這塊流程比較有意思
什么情況下頭節點為空,當線程還在爭奪鎖,隊列還未初始化,頭節點必然是為空的
當頭節點等待狀態等于0,證明后繼節點還在自旋,不需要進行后繼節點喚醒
如果同時滿足上述兩個條件,會對等待隊列頭節點的后繼節點進行喚醒操作
為什么查找隊列中未被取消的節點需要從尾部開始?
這個問題有兩個原因可以解釋,分別是 Node 入隊和清理取消狀態的節點
- 先從 addWaiter 入隊時說起,compareAndSetTail(pred, node)、pred.next = node 并非原子操作,如果在執行 pred.next = node 前進行 unparkSuccessor,就沒有辦法通過 next 指針向后遍歷,所以才會從后向前找尋非取消的節點
- cancelAcquire 方法也有導致使用 head 無法遍歷全部 Node 的因素,因為先斷開的是 next 指針,prev 指針并未斷開
當線程獲取鎖失敗被 park 后進入了阻塞模式,前驅節點釋放鎖后會進行喚醒 unpark,被阻塞線程狀態回歸 RUNNABLE 狀態
被喚醒線程檢查自身是否被中斷,返回自身中斷狀態到 acquireQueued
假設自身被中斷,設置 interrupted = true,繼續通過循環嘗試獲取鎖,獲取鎖成功后返回 interrupted 中斷狀態
中斷狀態本身并不會對加鎖流程產生影響,被喚醒后還是會不斷進行獲取鎖,直到獲取鎖成功進行返回,返回中斷狀態是為了后續補充中斷紀錄
如果線程被喚醒后發現中斷,成功獲取鎖后會將中斷狀態返回,補充中斷狀態
selfInterrupt 就是對線程中斷狀態的一個補充,補充狀態成功后,流程結束
閱讀源碼小技巧1、從全局掌握要閱讀的源碼提供了什么功能
這也是我一直推崇的學習源碼方式,學習源碼的關鍵點是抓住主線流程,在了解主線之前不要最開始就研究到源碼實現細節中,否則很容易迷失在細枝末節的代碼中
以文章中的 AQS 舉例,當你知道了它是一個抽象隊列同步器,使用它可以更簡單的構造鎖和同步器等實現
然后從中理解 tryAcquire、tryRelease 等方法實現,這樣是不是可以更好的理解與 AQS 與其子類相關的代碼
2、把不易理解的源碼粘貼出來,整理好格式打好備注
一般源碼中的行為格式和我們日常敲代碼是不一樣的,而且 JDK 源碼中的變量命名實在是慘不忍睹
所以就應該將難以理解的源碼粘貼出,標上對應注釋以及調整成易理解的格式,這樣對于源碼的閱讀就會輕松很多
后記平常工作中接觸到 AQS 相關知識還是很多的,知其然知其所以然,文章以 ReentrantLock 作為切入點,講述了其公平鎖和非公平鎖的概念,以及對應 AQS 中 CLH、AOS 等不容易被發現的概念
針對 ReentrantLock 以及 AQS 加鎖、解鎖、排隊等流程進行了詳細說明,以圖文并茂的方式講述了其流程源碼實現細節,這里希望在看的小伙伴都能收獲 AQS 相關知識
作者:馬稱
原文鏈接:https://machen.blog.csdn.net/article/details/109758867