ReentrantLock原理及AQS(羊群效应+实操)
一
1.1 悲觀鎖和樂觀鎖
悲觀鎖:還是像它的名字一樣,對于并發間操作產生的線程安全問題持悲觀狀態,悲觀鎖認為競爭總是會發生,因此每次對某資源進行操作時,都會持有一個獨占的鎖,就像synchronized,不管三七二十一,直接上了鎖就操作資源了。
樂觀鎖:就像它的名字一樣,對于并發間操作產生的線程安全問題持樂觀狀態,樂觀鎖認為競爭不總是會發生,因此它不需要持有鎖,將比較-替換這兩個動作作為一個原子操作嘗試去修改內存中的變量,如果失敗則表示發生沖突,那么就應該有相應的重試邏輯。
1.2 兩種常見的鎖
Synchronized 互斥鎖(悲觀鎖,有罪假設)
采用synchronized修飾符實現的同步機制叫做互斥鎖機制,它所獲得的鎖叫做互斥鎖。每個對象都有一個monitor(鎖標記),當線程擁有這個鎖標記時才能訪問這個資源,沒有鎖標記便進入鎖池。任何一個對象系統都會為其創建一個互斥鎖,這個鎖是為了分配給線程的,防止打斷原子操作。每個對象的鎖只能分配給一個線程,因此叫做互斥鎖。
ReentrantReadWriteLock 讀寫鎖(樂觀鎖,無罪假設)
ReentrantLock是排他鎖,排他鎖在同一時刻僅有一個線程可以進行訪問,實際上獨占鎖是一種相對比較保守的鎖策略,在這種情況下任何“讀/讀”、“讀/寫”、“寫/寫”操作都不能同時發生,這在一定程度上降低了吞吐量。然而讀操作之間不存在數據競爭問題,如果”讀/讀”操作能夠以共享鎖的方式進行,那會進一步提升性能。因此引入了ReentrantReadWriteLock,顧名思義,ReentrantReadWriteLock是Reentrant(可重入)Read(讀)Write(寫)Lock(鎖),我們下面稱它為讀寫鎖。
讀寫鎖內部又分為讀鎖和寫鎖,讀鎖可以在沒有寫鎖的時候被多個線程同時持有,寫鎖是獨占的。讀鎖和寫鎖分離從而提升程序性能,讀寫鎖主要應用于讀多寫少的場景。
ReentrantLock常常對比著synchronized來分析,我們先對比著來看然后再一點一點分析。
(1)synchronized是獨占鎖,加鎖和解鎖的過程自動進行,易于操作,但不夠靈活。ReentrantLock也是獨占鎖,加鎖和解鎖的過程需要手動進行,不易操作,但非常靈活。
(2)synchronized可重入,因為加鎖和解鎖自動進行,不必擔心最后是否釋放鎖;ReentrantLock也可重入,但加鎖和解鎖需要手動進行,且次數需一樣,否則其他線程無法獲得鎖。
(3)synchronized不可響應中斷,一個線程獲取不到鎖就一直等著;ReentrantLock可以相應中斷。
ReentrantLock好像比synchronized關鍵字沒好太多,我們再去看看synchronized所沒有的,一個最主要的就是ReentrantLock還可以實現公平鎖機制。什么叫公平鎖呢?也就是在鎖上等待時間最長的線程將獲得鎖的使用權。通俗的理解就是誰排隊時間最長誰先執行獲取鎖。
4 sychronized修飾的方法或者語句塊在代碼執行完之后鎖會自動釋放,而是用Lock需要我們手動釋放鎖,所以為了保證鎖最終被釋放(發生異常情況),要把互斥區放在try內,釋放鎖放在finally內!
與互斥鎖相比,讀-寫鎖允許對共享數據進行更高級別的并發訪問。雖然一次只有一個線程(writer 線程)可以修改共享數據,但在許多情況下,任何數量的線程可以同時讀取共享數據(reader 線程)從理論上講,與互斥鎖定相比,使用讀-寫鎖允許的并發性增強將帶來更大的性能提高。
什么是可重入鎖?
ReentrantLock是可重入鎖,什么是可重入鎖呢?可重入鎖就是當前持有該鎖的線程能夠多次獲取該鎖,無需等待。可重入鎖是如何實現的呢?這要從ReentrantLock的一個內部類Sync的父類說起,Sync的父類是AbstractQueuedSynchronizer(后面簡稱AQS)。
什么是AQS?
AQS是JDK1.5提供的一個基于FIFO等待隊列實現的一個用于實現同步器的基礎框架,這個基礎框架的重要性可以這么說,JCU包里面幾乎所有的有關鎖、多線程并發以及線程同步器等重要組件的實現都是基于AQS這個框架。AQS的核心思想是基于volatile int state這樣的一個屬性同時配合Unsafe工具對其原子性的操作來實現對當前鎖的狀態進行修改。當state的值為0的時候,標識改Lock不被任何線程所占有。
ReentrantLock鎖的架構
ReentrantLoc的架構相對簡單,主要包括一個Sync的內部抽象類以及Sync抽象類的兩個實現類。上面已經說過了Sync繼承自AQS,他們的結構示意圖如下:
上圖除了AQS之外,我把AQS的父類AbstractOwnableSynchronizer(后面簡稱AOS)也畫了進來,可以稍微提一下,AOS主要提供一個exclusiveOwnerThread屬性,用于關聯當前持有該所的線程。另外、Sync的兩個實現類分別是NonfairSync和FairSync,由名字大概可以猜到,一個是用于實現公平鎖、一個是用于實現非公平鎖。那么Sync為什么要被設計成內部類呢?我們可以看看AQS主要提供了哪些protect的方法用于修改state的狀態,我們發現Sync被設計成為安全的外部不可訪問的內部類。ReentrantLock中所有涉及對AQS的訪問都要經過Sync,其實,Sync被設計成為內部類主要是為了安全性考慮,這也是作者在AQS的comments上強調的一點
AQS的等待隊列
作為AQS的核心實現的一部分,舉個例子來描述一下這個隊列長什么樣子,我們假設目前有三個線程Thread1、Thread2、Thread3同時去競爭鎖,如果結果是Thread1獲取了鎖,Thread2和Thread3進入了等待隊列,那么他們的樣子如下:
AQS的等待隊列基于一個雙向鏈表實現的,HEAD節點不關聯線程,后面兩個節點分別關聯Thread2和Thread3,他們將會按照先后順序被串聯在這個隊列上。這個時候如果后面再有線程進來的話將會被當做隊列的TAIL。
1)入隊列
我們來看看,當這三個線程同時去競爭鎖的時候發生了什么?
代碼:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
解讀:
三個線程同時進來,他們會首先會通過CAS去修改state的狀態,如果修改成功,那么競爭成功,因此這個時候三個線程只有一個CAS成功,其他兩個線程失敗,也就是tryAcquire返回false。
接下來,addWaiter會把將當前線程關聯的EXCLUSIVE類型的節點入隊列:
代碼:private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
解讀:
如果隊尾節點不為null,則說明隊列中已經有線程在等待了,那么直接入隊尾。對于我們舉的例子,這邊的邏輯應該是走enq,也就是開始隊尾是null,其實這個時候整個隊列都是null的。
代碼:
private Node enq(final Node node) {
for (;😉 {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
解讀:
如果Thread2和Thread3同時進入了enq,同時t==null,則進行CAS操作對隊列進行初始化,這個時候只有一個線程能夠成功,然后他們繼續進入循環,第二次都進入了else代碼塊,這個時候又要進行CAS操作,將自己放在隊尾,因此這個時候又是只有一個線程成功,我們假設是Thread2成功,哈哈,Thread2開心的返回了,Thread3失落的再進行下一次的循環,最終入隊列成功,返回自己。
2)并發問題
基于上面兩段代碼,他們是如何實現不進行加鎖,當有多個線程,或者說很多很多的線程同時執行的時候,怎么能保證最終他們都能夠乖乖的入隊列而不會出現并發問題的呢?這也是這部分代碼的經典之處,多線程競爭,熱點、單點在隊列尾部,多個線程都通過【CAS+死循環】這個free-lock黃金搭檔來對隊列進行修改,每次能夠保證只有一個成功,如果失敗下次重試,如果是N個線程,那么每個線程最多loop N次,最終都能夠成功。
3)掛起等待線程
上面只是addWaiter的實現部分,那么節點入隊列之后會繼續發生什么呢?那就要看看acquireQueued是怎么實現的了,為保證文章整潔,代碼我就不貼了,同志們自行查閱,我們還是以上面的例子來看看,Thread2和Thread3已經被放入隊列了,進入acquireQueued之后:
對于Thread2來說,它的prev指向HEAD,因此會首先再嘗試獲取鎖一次,如果失敗,則會將HEAD的waitStatus值為SIGNAL,下次循環的時候再去嘗試獲取鎖,如果還是失敗,且這個時候prev節點的waitStatus已經是SIGNAL,則這個時候線程會被通過LockSupport掛起。
對于Thread3來說,它的prev指向Thread2,因此直接看看Thread2對應的節點的waitStatus是否為SIGNAL,如果不是則將它設置為SIGNAL,再給自己一次去看看自己有沒有資格獲取鎖,如果Thread2還是擋在前面,且它的waitStatus是SIGNAL,則將自己掛起。
如果Thread1死死的握住鎖不放,那么Thread2和Thread3現在的狀態就是掛起狀態啦,而且HEAD,以及Thread的waitStatus都是SIGNAL,盡管他們在整個過程中曾經數次去嘗試獲取鎖,但是都失敗了,失敗了不能死循環呀,所以就被掛起了。當前狀態如下:
鎖釋放-等待線程喚起
我們來看看當Thread1這個時候終于做完了事情,調用了unlock準備釋放鎖,這個時候發生了什么。
代碼:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
解讀:
首先,Thread1會修改AQS的state狀態,加入之前是1,則變為0,注意這個時候對于非公平鎖來說是個很好的插入機會,舉個例子,如果鎖是公平鎖,這個時候來了Thread4,那么這個鎖將會被Thread4搶去。。。
我們繼續走常規路線來分析,當Thread1修改完狀態了,判斷隊列是否為null,以及隊頭的waitStatus是否為0,如果waitStatus為0,說明隊列無等待線程,按照我們的例子來說,隊頭的waitStatus為SIGNAL=-1,因此這個時候要通知隊列的等待線程,可以來拿鎖啦,這也是unparkSuccessor做的事情,unparkSuccessor主要做三件事情:
將隊頭的waitStatus設置為0.
通過從隊列尾部向隊列頭部移動,找到最后一個waitStatus<=0的那個節點,也就是離隊頭最近的沒有被cancelled的那個節點,隊頭這個時候指向這個節點。
將這個節點喚醒,其實這個時候Thread1已經出隊列了。
還記得線程在哪里掛起的么,上面說過了,在acquireQueued里面,我沒有貼代碼,自己去看哦。這里我們也大概能理解AQS的這個隊列為什么叫FIFO隊列了,因此每次喚醒僅僅喚醒隊頭等待線程,讓隊頭等待線程先出。
羊群效應
這里說一下羊群效應,當有多個線程去競爭同一個鎖的時候,假設鎖被某個線程占用,那么如果有成千上萬個線程在等待鎖,有一種做法是同時喚醒這成千上萬個線程去去競爭鎖,這個時候就發生了羊群效應,海量的競爭必然造成資源的劇增和浪費,因此終究只能有一個線程競爭成功,其他線程還是要老老實實的回去等待。AQS的FIFO的等待隊列給解決在鎖競爭方面的羊群效應問題提供了一個思路:保持一個FIFO隊列,隊列每個節點只關心其前一個節點的狀態,線程喚醒也只喚醒隊頭等待線程。其實這個思路已經被應用到了分布式鎖的實踐中,見:Zookeeper分布式鎖的改進實現方案。
總結
這篇文章粗略的介紹一下ReentrantLock以及鎖實現基礎框架AQS的實現原理,大致上通過舉了個三個線程競爭鎖的例子,從lock、unlock過程發生了什么這個問題,深入了解AQS基于狀態的標識以及FIFO等待隊列方面的工作原理,最后擴展介紹了一下羊群效應問題,博主才疏學淺,還請多多指教。
三 使用
1.簡單實現
public class TestReetrantLock {
private static final Lock lock=new ReentrantLock();
public static void main(String[] args) {
new Thread(()-> test(),“線程A”).start();
new Thread(()-> test(),“線程B”).start();
}
private static Object test() {
// TODO Auto-generated method stub
}
}
輸出結果
Thread[線程A,5,main]
Thread[線程B,5,main]
Thread[線程B,5,main]
2、公平鎖實現
對于公平鎖的實現,就要結合著我們的可重入性質了。公平鎖的含義我們上面已經說了,就是誰等的時間最長,誰就先獲取鎖。
public class TestReetrantLock2 {
private static final Lock lock=new ReentrantLock(true);
public static void main(String[] args) {
new Thread(()-> test(),“線程A”).start();
new Thread(()-> test(),“線程B”).start();
new Thread(()-> test(),“線程C”).start();
new Thread(()-> test(),“線程D”).start();
}
private static Object test() {
// TODO Auto-generated method stub
for (int i = 0; i < 2; i++) {
}
}
測試結果
線程A獲取了鎖
線程B獲取了鎖
線程C獲取了鎖
線程D獲取了鎖
線程A獲取了鎖
線程B獲取了鎖
線程C獲取了鎖
線程D獲取了鎖
3.非公平鎖
非公平鎖那就隨機的獲取,誰運氣好,cpu時間片輪到哪個線程,哪個線程就能獲取鎖,和上面公平鎖的區別很簡單,就在于先new一個ReentrantLock的時候參數為false,當然我們也可以不寫,默認就是false。直接測試一下
public class TestReetrantLock3 {
private static final Lock lock=new ReentrantLock(false);
public static void main(String[] args) {
new Thread(()-> test(),“線程A”).start();
new Thread(()-> test(),“線程B”).start();
new Thread(()-> test(),“線程C”).start();
new Thread(()-> test(),“線程D”).start();
}
private static Object test() {
// TODO Auto-generated method stub
for (int i = 0; i < 2; i++) {
線程A獲取了鎖
線程A獲取了鎖
線程B獲取了鎖
線程B獲取了鎖
線程C獲取了鎖
線程C獲取了鎖
線程D獲取了鎖
線程D獲取了鎖
4、響應中斷
響應中斷就是一個線程獲取不到鎖,不會傻傻的一直等下去,ReentrantLock會給予一個中斷回應。在這里我們舉一個死鎖的案例。
在這里我們定義了兩個鎖lock1和lock2。然后使用兩個線程thread和thread1構造死鎖場景。正常情況下,這兩個線程相互等待獲取資源而處于死循環狀態。但是我們此時thread中斷,另外一個線程就可以獲取資源,正常地執行了。
5、限時等待
這個是什么意思呢?也就是通過我們的tryLock方法來實現,可以選擇傳入時間參數,表示等待指定的時間,無參則表示立即返回鎖申請的結果:true表示獲取鎖成功,false表示獲取鎖失敗。我們可以將這種方法用來解決死鎖問題。
首先還是測試代碼,不過在這里我們不需要再去中斷其中的線程了,我們直接看線程類是如何實現的。
在這個案例中,一個線程獲取lock1時候第一次失敗,那就等10毫秒之后第二次獲取,就這樣一直不停的調試,一直等到獲取到相應的資源為止。
當然,我們可以設置tryLock的超時等待時間tryLock(long timeout,TimeUnit unit),也就是說一個線程在指定的時間內沒有獲取鎖,那就會返回false,就可以再去做其他事了。
總結
以上是生活随笔為你收集整理的ReentrantLock原理及AQS(羊群效应+实操)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: MySQL 性能分析之 EXPLAIN
- 下一篇: python检查验证_Python:在时