banner
bladedragon

bladedragon

《並發編程的藝術》-閱讀筆記05:java中的鎖

《並發編程的藝術》閱讀筆記第五章,圖文絕配

你鎖我,我鎖你,兩者互不相讓,然後就進入了死局,這像極了愛情。

image

一、Lock 接口#

提供了 synchronized 不具有的特性:

  1. 嘗試非阻塞地獲取鎖:tryLock(),調用方法後立刻返回
  2. 能被中斷地獲取鎖:lockInterruptibly(): 在鎖的獲取中可以中斷當前線程
  3. 超時獲取鎖:tryLock(time,unit),超時返回

Lock 接口的實現基本都是通過 == 聚合了一個同步器的子類來完成線程訪問控制的。==

使用注意事項

  • unlock方法要在finally中使用,目的保證在獲取到鎖之後,最終能被釋放
  • lock方法不能放在try塊中,因為如果try catch拋出異常,會導致鎖無故釋放

image

二、隊列同步器#

隊列同步器AbstractQueuedSynchronizer是用來構建鎖或其他同步組件的基礎框架。
它使用一個int成員變量表示同步狀態,通過內置的FIFO隊列來完成資源獲取線程的排隊工作。

同步器的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態。同步器既可以支持獨占式地獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣就可以方便實現不同類型的同步組件(ReentrantLock
ReentrantReadWriteLockCountDownLatch等)

==同步器== 是實現鎖的關鍵,在鎖的實現中聚合同步器,利用同步器實現鎖的語義。
理解兩者的關係:

  • 鎖是面向使用者的,它定義了使用者與鎖交互的接口,隱藏了實現細節;
  • 同步器是面向鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理、線程的排隊、等待和喚醒等底層操作。

隊列同步器的接口與示例#

同步器的設計是基於模板方法模式的,也就是說,使用者需要繼承同步器並重寫指定的方法,隨後將同步器組合在自定義同步組件的實現中,並調用同步器提供的模板方法,而這些模板方法將會調用使用者重寫的方法。

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

  • getState():獲取當前同步狀態。
  • setState(int newState):設置當前同步狀態。
  • compareAndSetState(int expect,int update):使用 CAS 設置當前狀態,該方法能夠保證狀態設置的原子性。

image

image

同步器提供的模板方法基本上分為 3 類:獨占式獲取與釋放同步狀態共享式獲取與釋放同步狀態查詢同步隊列中的等待線程情況。自定義同步組件將使用同步器提供的模板方法來實現自己的同步語義。

工作原理#

MUtux源代碼略

隊列同步器的實現分析#

1. 同步隊列#

通過一個FIFO雙向隊列來完成同步狀態的管理,當前線程獲取同步狀態失敗時,同步器會將當前線程以及等待狀態等信息構造成一個Node並將其加入同步隊列,同時會阻塞當前線程,當同步狀態釋放時,會把首節點中的線程喚醒,使其再次嘗試獲取同步狀態。

首節點是獲取同步狀態成功的節點,首節點在釋放同步狀態時,會喚醒後繼節點,而後繼節點在獲取同步狀態成功時將自己設置為首節點。

節點的屬性類型與名稱以及描述

image

image

2. 獨占式同步狀態獲取和釋放#

 public final void acquire(int arg) {
        if (!tryAcquire(arg) 					&&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            //如果上述操作失敗,則阻塞線程
            selfInterrupt();
 }
//上述代碼主要完成了同步狀態獲取、節點構造、加入同步隊列以及在同步隊列中自旋等待的相關工作,

代碼分析:
首先嘗試獲取同步狀態,如果獲取失敗,構造獨占式同步節點 (獨占式 Node.EXCLUSIVE) 並將其加入到節點的尾部,然後調用acquireQueued,使節點一死循環的方式去獲取同步狀態,如果獲取不到就阻塞節點中的線程。

兩個死循環:入隊、入隊後

  1. addWaiterenq方法・

    在 “死循環” 中只有通過CAS將節點設置成為尾節點之後,當前線程才能從該方法返回,否則,當前線程不斷地嘗試設置。可以看出,enq(final Node node)方法將並發添加節點的請求通過 CAS 變得 “串行化” 了

    addWaiter方法嘗試快速添加,但是存在出現並發導致節點無法正常添加成功(獲取尾節點==null),因此enq方法無限循環添加節點,將節點加入到尾部
    
  2. acquireQueued方法

    ・== 只有前驅節點是頭結點才能嘗試獲取同步狀態 ==,原因:

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

  4. 維護同步隊列的 FIFO 原則。節點之間互不通信,便於對過早通知的處理(過早通知是指前驅節點不是頭節點的線程
    由於中斷而被喚醒)

釋放同步狀態使用release方法

public final boolean release(int arg) {
	if (tryRelease(arg)) {
		Node h = head;
		if (h != null && h.waitStatus != 0
            //unparkSuccessor(Node node)方法使用LockSupport(在後面的章節會專門介紹)來喚醒處於等待狀態的線程
			unparkSuccessor(h);
		return true;
	}
	return false;
}
總結:在獲取同步狀態時,同步器維護一個同步隊列,獲取狀態失敗的線程都會被加入到隊列中並在隊列中進行自旋,移出隊列(停止自旋)的條件是前驅節點是頭結點且成功獲取了同步狀態。在釋放同步狀態時,同步器調用tryRelease方法釋放同步狀態,然後喚醒頭結點的後繼節點

image

3. 共享式同步狀態獲取和釋放#

主要區別:同一時刻是否有多個線程同時獲取到同步狀態

共享式訪問資源時,其他共享式的訪問均被允許,而獨占式訪問被阻塞。獨占式訪問資源時,同一時刻其他訪問均被阻塞。
  • tryAcquireShared(int arg)方法返回值為int類型,當返回值大於等於0時,表示能夠獲取到同步狀態
  • releaseShared・方法和獨占式主要區別在於tryReleaseShared(int arg)方法必須確保同步狀態(或者資源數)線程安全釋放,一般是通過循環和CAS來保證的,因為釋放同步狀態的操作會同時來自多個線程。
源碼細節還是有很多沒有看懂
比如interrupt的放置時機,如何保證共享的時候線程安全性、共享獲取同步狀態中的傳播和信號都是什么意思。。。

4. 獨占式超時獲取同步狀態#

通過調用同步器的doAcquireNanos(int arg,long nanosTimeout)方法可以超時獲取同步狀態,即在指定的時間段內獲取同步狀態,如果獲取到同步狀態則返回true,否則,返回false。該方法提供了傳統Java同步操作(比如synchronized關鍵字)所不具備的特性。

響應中斷的同步狀態獲取過程

在 Java 5 中,同步器提供了acquireInterruptibly(int arg)方法,這個方法在等待獲取同步狀態時,如果當前線程被中斷,會立刻返回,並拋出InterruptedException

doAcquireNanos(int arg,long nanosTimeout)方法在支持響應中斷的基礎上,增加了超時獲取的特性。針對超時獲取,主要需要計算出需要睡眠的時間間隔nanosTimeout,為了防止過早通知,nanosTimeout計算公式為:nanosTimeout-=now-lastTime,其中now為當前喚醒時間,lastTime為上次喚醒時間,如果nanosTimeout大於0則表示超時時間未到,需要繼續睡眠nanosTimeout納秒,反之,表示已經超時・

獨占式超時獲取同步狀態和獨占式獲取同步狀態流程上非常相似

主要區別在於未獲取到同步狀態時的處理邏輯。acquire(int args)在未獲取到同步狀態時,將會使當前線程一直處於等待狀態,而doAcquireNanos(int arg,long nanosTimeout)會使當前線程等待nanosTimeout納秒,如果當前線程在nanosTimeout納秒內沒有獲取到同步狀態,將會從等待邏輯中自動返回

當時間小於等於一個超時自旋門限時則不再進行超時等待,而是進入快速的自旋過程

image

三、重入鎖(ReentrantLock)#

synchronized 關鍵字隱式地支持重入

ReentrantLock 不像 synchronized 隱式支持,在調用 lock 方法時,已經獲取到鎖的線程,能夠再次調用 lock 方法獲取鎖而不被阻塞。

公平的獲取鎖,也就是等待時間最長的線程最優先獲取鎖,也可以說鎖獲取是順序的

事實上,公平的鎖機制往往沒有非公平的效率高,但是公平鎖的好處在於:公平鎖能夠減少 “饑餓” 發生的概率,等待越久的請求越是能夠得到優先滿足。

1. 重入的實現#

兩個問題:

  1. 再次獲取鎖

    鎖需要識別獲取鎖的線程是否為當前佔據鎖的線程,如果是,再次成功獲取

  2. 最終釋放

    要求鎖對於獲取進行自增計數

    問題:意義何在?
    防止出現循環獲取鎖影響性能或者造成死鎖
    
    可重入獲取鎖的機制,在獲取的時候如果不是第一次獲取,狀態加一,實際上沒有進行CAS操作,因此在釋放鎖的時候要求state為0,才能徹底釋放鎖
    

2. 公平鎖與非公平鎖的區別:#

如果一個鎖是公平的,那麼鎖的獲取順序就應該符合請求的絕對時間順序,也就是 FIFO

公平鎖:CAS成功,且是隊列的首節點(判斷多了一層對前去前驅節點的判斷)
非公平鎖:CAS成功即可

重入鎖的默認實現是非公平鎖,原因:雖然會導致饑餓,但是非公平鎖的的開銷少(線程切換次數少),從而可以有更高的吞吐量。

四、讀寫鎖(ReentrantReadWriteLock)#

前文中的鎖基本都是排他鎖,在同一時刻只允許一個線程訪問。

讀寫所在同一時刻可以允許多個讀線程訪問,但在寫線程訪問時,所有讀線程和其他寫線程均被阻塞。(保證了寫操作的可見性)

讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得並發性相比一般的排他鎖有了很大提升。

讀寫鎖的實現分析#

1. 讀寫狀態的設計#

依賴自定義同步器,讀寫鎖的自定義同步器需要在同步狀態(一個 int 值)上維護多個讀線程和一個寫線程的狀態,高 16 位表示讀,低 16 位表示寫。

image

位運算
當前同步狀態表示一個線程已經獲取了寫鎖,且重進入了兩次,同時也連續獲取了兩次
讀鎖。讀寫鎖是如何迅速確定讀和寫各自的狀態呢?答案是通過位運算。假設當前同步狀態
值為S,寫狀態等於S&0x0000FFFF(將高16位全部抹去),讀狀態等於S>>>16(無符號補0右移16位)。當寫狀態增加1時,等於S+1,當讀狀態增加1時,等於S+(1<<16),也就是
S+0x00010000。

2. 寫鎖的獲取與釋放#

image

寫鎖是一個支持重入的排他鎖,如果當前線程已經獲取了寫鎖,則增加寫狀態。
如果當前線程在獲取寫鎖時,讀鎖已經被獲取或者該線程不是已經獲取寫鎖的線程,則當前線程進入等待狀態。

讀鎖存在,寫鎖不能獲取:

讀寫鎖要確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那麼正在運行的其他讀線程就無法感知到當前寫線程的操作。因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當前線程獲取,而寫鎖一旦被獲取,則其他讀寫線程的後續訪問均被阻塞。

3. 讀鎖的獲取與釋放#

在沒有其他寫線程訪問時,讀鎖總會被成功地獲取。如果寫鎖已經被其他線程獲取,則進入等待狀態。

讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態,減少的值是(1<<16)

讀狀態的線程安全由 CAS 保證

4. 鎖降級(寫鎖降級成為讀鎖)#

定義:把持住寫鎖,再獲取到讀鎖,隨後釋放寫鎖的過程

writeLock.lock();
readLock.lock();
writeLock.unlock();

這邊不是很理解。。。。

鎖降級的前提是所有線程都希望對數據變化敏感,但是因為寫鎖只有一個,所以會發生降級。如果先釋放寫鎖,再獲取讀鎖,可能在獲取之前,會有其他線程獲取到寫鎖,阻塞讀鎖的獲取,就無法感知數據變化了。所以需要先 hold 住寫鎖,保證數據無變化,獲取讀鎖,然後再釋放寫鎖。鎖降級中讀鎖獲取的必要性:

為了保證數據的可見性,如果當前線程不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個線程獲取了寫鎖並修改了數據,那麼當前線程無法感知到數據的更新.如果當前線程獲取讀鎖,則另一個線程會被阻塞,直到當前線程使用數據並釋放鎖之後,另一個線程才能獲取寫鎖進行數據更新。

五、LockSupport 工具#

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

image

在 Java 6 中,LockSupport 增加了 park (Object blocker)、parkNanos (Object blocker,long nanos) 和 parkUntil (Object blocker,long deadline) 3 個方法,用於實現阻塞當前線程的功能,其中參數 blocker 是用來標識當前線程在等待的對象(以下稱為阻塞對象),該對象主要用於問題排查和系統監控。

六、Condition 接口#

Condition 接口也提供了類似 Object 的監視器方法,與 Lock 配合可以實現等待 / 通知模式,但是這兩者在使用方式以及功能特性上還是有差別的

1.Condition 接口和示例#

image

Condition 在調用方法之前先獲取鎖

image

在添加和刪除方法中使用while循環而非if判斷,目的是防止過早或意外的通知,只有條件
符合才能夠退出循環。回想之前提到的等待/通知的經典範式,二者是非常類似的。

2.Condition 的實現分析#

ConditionObject是同步器AbstractQueuedSynchronizer的內部類,因為Condition的操作需要獲取相關聯的鎖,所以作為同步器的內部類也較為合理。每個Condition對象都包含著一個隊列(以下稱為等待隊列),該隊列是Condition對象實現等待 / 通知功能的關鍵。

等待隊列

等待隊列是一個 FIFO 的隊列,在隊列中的每個節點都包含了一個線程引用,該線程就是
在 Condition 對象上等待的線程,如果一個線程調用了 Condition.await () 方法,那麼該線程將會釋放鎖、構造成節點加入等待隊列並進入等待狀態(和同步隊列類似)

image

在 Object 的監視器模型上,一個對象擁有一個同步隊列和等待隊列,而並發包中的
Lock(更確切地說是同步器)擁有一個同步隊列和多個等待隊列

上述節點引用更新的過程並沒有使用 CAS 保證,原因在於調用 await () 方法的線程必定是獲取了鎖的線程,也就是說該過程是由鎖來保證線程安全

等待

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

源碼略
1. 使用locksupport中的park()方法進入等待狀態,判斷是否喚醒節點的標志是查看節點是否在同步隊列上,因為通知condition在喚醒節點之前後將節點轉移到同步隊列上
喚醒後注意還有判斷喚醒方式是通知還是中斷
2. 從隊列角度,線程加入Condition的等待隊列實質是構造了新的節點加入等待隊列

通知

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

image

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。