1. 程式人生 > >深入理解java鎖的機制

深入理解java鎖的機制

前面我們看到了Locksynchronized都能正常的保證資料的一致性(上文例子中執行的結果都是20000000),也看到了Lock的優勢,那究竟他們是什麼原理來保障的呢?今天我們就來探討下Java中的鎖機制!

Synchronized是基於JVM來保證資料同步的,而Lock則是在硬體層面,依賴特殊的CPU指令實現資料同步的,那究竟是如何來實現的呢?我們一一看來!

一、synchronized的實現方案

synchronized比較簡單,語義也比較明確,儘管Lock推出後效能有較大提升,但是基於其使用簡單,語義清晰明瞭,使用還是比較廣泛的,其應用層的含義是把任意一個非NULL的物件當作鎖。當synchronized

作用於方法時,鎖住的是物件的例項(this),當作用於靜態方法時,鎖住的是Class例項,又因為Class的相關資料儲存在永久帶,因此靜態方法鎖相當於類的一個全域性鎖,當synchronized作用於一個物件例項時,鎖住的是對應的程式碼塊。在SunHotSpot JVM實現中,其實synchronized鎖還有一個名字:物件監視器。

當多個執行緒一起訪問某個物件監視器的時候,物件監視器會將這些請求儲存在不同的容器中。

1、  Contention List:競爭佇列,所有請求鎖的執行緒首先被放在這個競爭佇列中

2、  Entry ListContention List中那些有資格成為候選資源的執行緒被移動到Entry List

3、  Wait Set:哪些呼叫wait方法被阻塞的執行緒被放置在這裡

4、  OnDeck:任意時刻,最多隻有一個執行緒正在競爭鎖資源,該執行緒被成為OnDeck

5、  Owner:當前已經獲取到所資源的執行緒被稱為Owner

6、  !Owner:當前釋放鎖的執行緒

下圖展示了他們之前的關係

 ContentionList並不是真正意義上的一個佇列。僅僅是一個虛擬佇列,它只有Node以及對應的Next指標構成,並沒有Queue的資料結構。每次新加入Node會在隊頭進行,通過CAS改變第一個節點為新增節點,同時新增階段的next指向後續節點,而取資料都在佇列尾部進行。

 JVM每次從佇列的尾部取出一個數據用於鎖競爭候選者(OnDeck

),但是併發情況下,ContentionList會被大量的併發執行緒進行CAS訪問,為了降低對尾部元素的競爭,JVM會將一部分執行緒移動到EntryList中作為候選競爭執行緒。Owner執行緒會在unlock時,將ContentionList中的部分執行緒遷移到EntryList中,並指定EntryList中的某個執行緒為OnDeck執行緒(一般是最先進去的那個執行緒)。Owner執行緒並不直接把鎖傳遞給OnDeck執行緒,而是把鎖競爭的權利交個OnDeckOnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行為稱之為“競爭切換”。

OnDeck執行緒獲取到鎖資源後會變為Owner執行緒,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner執行緒被wait方法阻塞,則轉移到WaitSet佇列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。

處於ContentionListEntryListWaitSet中的執行緒都處於阻塞狀態,該阻塞是由作業系統來完成的(Linux核心下采用pthread_mutex_lock核心函式實現的)。該執行緒被阻塞後則進入核心排程狀態,會導致系統在使用者和核心之間進行來回切換,嚴重影響鎖的效能。為了緩解上述效能問題,JVM引入了自旋鎖。原理非常簡單,如果Owner執行緒能在很短時間內釋放鎖資源,那麼哪些等待競爭鎖的執行緒可以稍微等一等(自旋)而不是立即阻塞,當Owner執行緒釋放鎖後可立即獲取鎖,進而避免使用者執行緒和核心的切換。但是Owner可能執行的時間會超過設定的閾值,爭用執行緒在一定時間內還是獲取不到鎖,這是爭用執行緒會停止自旋進入阻塞狀態。基本思路就是先自旋等待一段時間看能否成功獲取,如果不成功再執行阻塞,儘可能的減少阻塞的可能性,這對於佔用鎖時間比較短的程式碼塊來說效能能大幅度的提升!

但是有個頭大的問題,何為自旋?其實就是執行幾個空方法,稍微等一等,也許是一段時間的迴圈,也許是幾行空的彙編指令,其目的是為了佔著CPU的資源不釋放,等到獲取到鎖立即進行處理。但是如何去選擇自旋的執行時間呢?如果自旋執行時間太長,會有大量的執行緒處於自旋狀態佔用CPU資源,進而會影響整體系統的效能。因此自旋的週期選的額外重要!

JVM對於自旋週期的選擇,基本認為一個執行緒上下文切換的時間是最佳的一個時間,同時JVM還針對當前CPU的負荷情況做了較多的優化

1、  如果平均負載小於CPUs則一直自旋

2、  如果有超過(CPUs/2)個執行緒正在自旋,則後來執行緒直接阻塞

3、  如果正在自旋的執行緒發現Owner發生了變化則延遲自旋時間(自旋計數)或進入阻塞

4、  如果CPU處於節電模式則停止自旋

5、  自旋時間的最壞情況是CPU的儲存延遲(CPU A儲存了一個數據,到CPU B得知這個資料直接的時間差)

6、  自旋時會適當放棄執行緒優先順序之間的差異

Synchronized線上程進入ContentionList時,等待的執行緒就通過自旋先獲取鎖,如果獲取不到就進入ContentionList,這明顯對於已經進入佇列的執行緒是不公平的,還有一個不公平的事情就是自旋獲取鎖的執行緒還可能直接搶佔OnDeck執行緒的鎖資源。

JVM6以後還引入了一種偏向鎖,主要用於解決無競爭下面鎖的效能問題。我們首先來看沒有這個會有什麼樣子的問題。

現在基本上所有的鎖都是可重入的,即已經獲取鎖的執行緒可以多次鎖定/解鎖監視物件,但是按照之前JVM的設計,每次加鎖解鎖都採用CAS操作,而CAS會引發本地延遲(下面會講原因),因此偏向鎖希望執行緒一旦獲取到監視物件後,之後讓監視物件偏向這個鎖,進而避免多次CAS操作,說白了就是設定了一個變數,發現是這個執行緒過來的就避免再走加鎖解鎖流程。

CAS為什麼會引發本地延遲呢?這要從多核處(SMP)理架構說起(前面有提到過--JVM記憶體模型),下圖基本上表明瞭多核處理的架構

多核CPU會共享一條系統匯流排,靠匯流排和主存通訊,但是每個CPU又有自己的一級快取,而CAS是一條原子指令,其作用是讓CPU比較,如果相同則進行資料更新,而這些是基於硬體實現的(JVM只是封裝了硬體的彙編呼叫,AtomicInteger其實是通過呼叫這些封裝後的介面實現的)。多核運算時,由於執行緒切換,很有可能第二次取值是在另外一核CPU上執行的。假設Core1Core2把對應的某個值載入到自己的一級快取時,某個時刻,core1更新了這個資料並通過匯流排通知主存,此時core2的一級快取中的資料就失效了,他需要從主存中重新載入一次到一級快取中,大家通過匯流排通訊被稱之為一致性流量,匯流排的通訊能力有限,當快取一致性流量過大時,匯流排會成為瓶頸,而當Core1Core2的資料再次一致時,被稱為快取一致性!

CAS要保證資料的一致性,恰好會引發比較多的一致性流量,如果有很多執行緒共享一個物件,當某個執行緒成功執行一次CAS時會引發匯流排風暴,這就是本地延遲,而偏向鎖就是為了消除CAS,降低Cache一致性流量!

當然並不是所有的CAS都會引發匯流排風暴,這和Cache一致性協議有關係的。但是偏向鎖的引入卻帶來了另外一個問題,在很多執行緒競爭使用中,如果一個執行緒持有偏向鎖,另外一個執行緒想爭用偏向物件,擁有者想釋放這個偏向鎖,釋放會帶來額外的效能開銷,但是總體來說偏向鎖帶來的好處還是大於CAS的代價的。

二、Lock的實現

synchronized不同的是,Lock書純Java實現的,與底層的JVM無關。在java.util.concurrent.locks包中有很多Lock的實現類,常用的有ReentrantLockReadWriteLock(實現類ReentrantReadWriteLock),其實現都依賴java.util.concurrent.AbstractQueuedSynchronizer類(簡稱AQS),實現思路都大同小異,因此我們以ReentrantLock作為講解切入點。

分析之前我們先來花點時間看下AQSAQS是我們後面將要提到的CountDownLatch/FutureTask/ReentrantLock/RenntrantReadWriteLock/Semaphore的基礎,因此AQS也是LockExcutor實現的基礎。它的基本思想就是一個同步器,支援獲取鎖和釋放鎖兩個操作。

獲取鎖:首先判斷當前狀態是否允許獲取鎖,如果是就獲取鎖,否則就阻塞操作或者獲取失敗,也就是說如果是獨佔鎖就可能阻塞,如果是共享鎖就可能失敗。另外如果是阻塞執行緒,那麼執行緒就需要進入阻塞佇列。當狀態位允許獲取鎖時就修改狀態,並且如果進了佇列就從佇列中移除。

複製程式碼
while(synchronization state does not allow acquire){

    enqueue current thread if not already queued;

    possibly block current thread;

}

dequeue current thread if it was queued;
複製程式碼

釋放鎖:這個過程就是修改狀態位,如果有執行緒因為狀態位阻塞的話,就喚醒佇列中的一個或者更多執行緒。

update synchronization state;

if(state may permit a blocked thread to acquire)

    unlock one or more queued threads;

要支援上面兩個操作就必須有下面的條件

1、  狀態位必須是原子操作的

2、  阻塞和喚醒執行緒

3、  一個有序的佇列,用於支援鎖的公平性

怎麼樣才能滿足這幾個條件呢?

1、  原子操作狀態位,前面我們已經提到了,實際JDK中也是通過一個32bit的整數位進行CAS操作來實現的。

2、  阻塞和喚醒,JDK1.5之前的API中並沒有阻塞一個執行緒,然後在將來的某個時刻喚醒它(wait/notify是基於synchronized下才生效的,在這裡不算),JDK5之後利用JNILockSupport這個類中實現了相關的特性!

3、  有序佇列:在AQS中採用CLH佇列來解決佇列的有序問題。

我們來看下ReentrantLock的呼叫過程

經過原始碼分析,我們看到ReentrantLock把所有的Lock都委託給Sync類進行處理,該類繼承自AQS,其類關係圖如下

其中Sync又有兩個final static的子類NonfairSyncFairSync用於支援非公平鎖和公平鎖。我們先來挑一個看下對應Reentrant.lock()的呼叫過程(預設為非公平鎖)

這些模版很難讓我們直觀的看到整個呼叫過程,但是通過上面的過程圖和AbstractQueuedSynchronizer的註釋可以看出,AbstractQueuedSynchronizer抽象了大多數Lock的功能,而只把tryAcquire(int)委託給子類進行多型實現。tryAcquire用於判斷對應執行緒事都能夠獲取鎖,無論成功與否,AbstractQueuedSynchronizer都將處理後面的流程。

簡單來講,AQS會把所有請求鎖的執行緒組成一個CLH的佇列,當一個執行緒執行完畢釋放鎖(Lock.unlock())的時候,AQS會啟用其後繼節點,正在執行的執行緒不在隊列當中,而那些等待的執行緒全部處於阻塞狀態,經過原始碼分析,我們可以清楚的看到最終是通過LockSupport.park()實現的,而底層是呼叫sun.misc.Unsafe.park()本地方法,再進一步,HotSpotLinux中中通過呼叫pthread_mutex_lock函式把執行緒交給系統核心進行阻塞。其執行示意圖如下

synchronized相同的是,這個也是一個虛擬佇列,並不存在真正的佇列示例,僅存在節點之前的前後關係。(注:原生的CLH佇列用於自旋鎖,JUC將其改造為阻塞鎖)。和synchronized還有一點相同的是,就是當獲取鎖失敗的時候,不是立即進行阻塞,而是先自旋一段時間看是否能獲取鎖,這對那些已經在阻塞佇列裡面的執行緒顯然不公平(非公平鎖的實現,公平鎖通過有序佇列強制執行緒順序進行),但會極大的提升吞吐量。如果自旋還是獲取失敗了,則建立一個節點加入佇列尾部,加入方法仍採用CAS操作,併發對隊尾CAS操作有可能會發生失敗,AQS是採用自旋迴圈的方法,知道CAS成功!下面我們來看下鎖的實現細節!

鎖的實現依賴與lock()方法,Lock()方法首先是呼叫acquire(int)方法,不管是公平鎖還是非公平鎖

public final void acquire(int arg) {
         if (!tryAcquire(arg) &&
             acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
             selfInterrupt();
     }

Acquire()方法預設首先呼叫tryAcquire(int)方法,而此時公平鎖和不公平鎖的實現就不一樣了。

1Sync.NonfairSync.TryAcquire(非公平鎖)

nonfairTryAcquire方法是lock方法間接呼叫的第一個方法,每次呼叫都會首先呼叫這個方法,我們來看下對應的實現程式碼:

複製程式碼
final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
複製程式碼

該方法首先會判斷當前執行緒的狀態,如果c==0說明沒有執行緒正在競爭鎖。(反過來,如果c!=0則說明已經有其他執行緒已經擁有了鎖)。如果c==0,則通過CAS將狀態設定為acquires(獨佔鎖的acquires1),後續每次重入該鎖都會+1,每次unlock都會-1,當資料為0時則釋放鎖資源。其中精妙的部分在於:併發訪問時,有可能多個執行緒同時檢測到c0,此時執行compareAndSetState(0, acquires))設定,可以預見,如果當前執行緒CAS成功,則其他執行緒都不會再成功,也就預設當前執行緒獲取了鎖,直接作為running執行緒,很顯然這個執行緒並沒有進入等待佇列。如果c!=0,首先判斷獲取鎖的執行緒是不是當前執行緒,如果是當前執行緒,則表明為鎖重入,繼續+1,修改state的狀態,此時並沒有鎖競爭,也非CAS,因此這段程式碼也非常漂亮的實現了偏向鎖。