前面我們看到了Lock和synchronized都能正常的保證數據的一致性(上文例子中執行的結果都是20000000),也看到了Lock的優勢,那究竟他們是什么原理來保障的呢?今天我們就來探討下Java中的鎖機制!
Synchronized是基於JVM來保證數據同步的,而Lock則是在硬件層面,依賴特殊的CPU指令實現數據同步的,那究竟是如何來實現的呢?我們一一看來!
一、synchronized的實現方案
synchronized比較簡單,語義也比較明確,盡管Lock推出后性能有較大提升,但是基於其使用簡單,語義清晰明了,使用還是比較廣泛的,其應用層的含義是把任意一個非NULL的對象當作鎖。當synchronized作用於方法時,鎖住的是對象的實例(this),當作用於靜態方法時,鎖住的是Class實例,又因為Class的相關數據存儲在永久帶,因此靜態方法鎖相當於類的一個全局鎖,當synchronized作用於一個對象實例時,鎖住的是對應的代碼塊。在Sun的HotSpot JVM實現中,其實synchronized鎖還有一個名字:對象監視器。
當多個線程一起訪問某個對象監視器的時候,對象監視器會將這些請求存儲在不同的容器中。
1、 Contention List:競爭隊列,所有請求鎖的線程首先被放在這個競爭隊列中
2、 Entry List:Contention 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線程,而是把鎖競爭的權利交個OnDeck,OnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行為稱之為“競爭切換”。
OnDeck線程獲取到鎖資源后會變為Owner線程,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner線程被wait方法阻塞,則轉移到WaitSet隊列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。
處於ContentionList、EntryList、WaitSet中的線程都處於阻塞狀態,該阻塞是由操作系統來完成的(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上執行的。假設Core1和Core2把對應的某個值加載到自己的一級緩存時,某個時刻,core1更新了這個數據並通過總線通知主存,此時core2的一級緩存中的數據就失效了,他需要從主存中重新加載一次到一級緩存中,大家通過總線通訊被稱之為一致性流量,總線的通訊能力有限,當緩存一致性流量過大時,總線會成為瓶頸,而當Core1和Core2的數據再次一致時,被稱為緩存一致性!
而CAS要保證數據的一致性,恰好會引發比較多的一致性流量,如果有很多線程共享一個對象,當某個線程成功執行一次CAS時會引發總線風暴,這就是本地延遲,而偏向鎖就是為了消除CAS,降低Cache一致性流量!
當然並不是所有的CAS都會引發總線風暴,這和Cache一致性協議有關系的。但是偏向鎖的引入卻帶來了另外一個問題,在很多線程競爭使用中,如果一個線程持有偏向鎖,另外一個線程想爭用偏向對象,擁有者想釋放這個偏向鎖,釋放會帶來額外的性能開銷,但是總體來說偏向鎖帶來的好處還是大於CAS的代價的。
二、Lock的實現
與synchronized不同的是,Lock書純Java實現的,與底層的JVM無關。在java.util.concurrent.locks包中有很多Lock的實現類,常用的有ReentrantLock、ReadWriteLock(實現類ReentrantReadWriteLock),其實現都依賴java.util.concurrent.AbstractQueuedSynchronizer類(簡稱AQS),實現思路都大同小異,因此我們以ReentrantLock作為講解切入點。
分析之前我們先來花點時間看下AQS。AQS是我們后面將要提到的CountDownLatch/FutureTask/ReentrantLock/RenntrantReadWriteLock/Semaphore的基礎,因此AQS也是Lock和Excutor實現的基礎。它的基本思想就是一個同步器,支持獲取鎖和釋放鎖兩個操作。
獲取鎖:首先判斷當前狀態是否允許獲取鎖,如果是就獲取鎖,否則就阻塞操作或者獲取失敗,也就是說如果是獨占鎖就可能阻塞,如果是共享鎖就可能失敗。另外如果是阻塞線程,那么線程就需要進入阻塞隊列。當狀態位允許獲取鎖時就修改狀態,並且如果進了隊列就從隊列中移除。
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之后利用JNI在LockSupport 這個類中實現了相關的特性!
3、 有序隊列:在AQS中采用CLH隊列來解決隊列的有序問題。
我們來看下ReentrantLock的調用過程
經過源碼分析,我們看到ReentrantLock把所有的Lock都委托給Sync類進行處理,該類繼承自AQS,其類關系圖如下
其中Sync又有兩個final static的子類NonfairSync和FairSync用於支持非公平鎖和公平鎖。我們先來挑一個看下對應Reentrant.lock()的調用過程(默認為非公平鎖)
這些模版很難讓我們直觀的看到整個調用過程,但是通過上面的過程圖和AbstractQueuedSynchronizer的注釋可以看出,AbstractQueuedSynchronizer抽象了大多數Lock的功能,而只把tryAcquire(int)委托給子類進行多態實現。tryAcquire用於判斷對應線程事都能夠獲取鎖,無論成功與否,AbstractQueuedSynchronizer都將處理后面的流程。
簡單來講,AQS會把所有請求鎖的線程組成一個CLH的隊列,當一個線程執行完畢釋放鎖(Lock.unlock())的時候,AQS會激活其后繼節點,正在執行的線程不在隊列當中,而那些等待的線程全部處於阻塞狀態,經過源碼分析,我們可以清楚的看到最終是通過LockSupport.park()實現的,而底層是調用sun.misc.Unsafe.park()本地方法,再進一步,HotSpot在Linux中中通過調用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)方法,而此時公平鎖和不公平鎖的實現就不一樣了。
1、Sync.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(獨占鎖的acquires為1),后續每次重入該鎖都會+1,每次unlock都會-1,當數據為0時則釋放鎖資源。其中精妙的部分在於:並發訪問時,有可能多個線程同時檢測到c為0,此時執行compareAndSetState(0, acquires))設置,可以預見,如果當前線程CAS成功,則其他線程都不會再成功,也就默認當前線程獲取了鎖,直接作為running線程,很顯然這個線程並沒有進入等待隊列。如果c!=0,首先判斷獲取鎖的線程是不是當前線程,如果是當前線程,則表明為鎖重入,繼續+1,修改state的狀態,此時並沒有鎖競爭,也非CAS,因此這段代碼也非常漂亮的實現了偏向鎖。