­

Synchronize關鍵字及鎖優化機制 總結

  • 2019 年 10 月 5 日
  • 筆記

版權聲明:本文為部落客原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接和本聲明。

本文鏈接:https://blog.csdn.net/qq_37933685/article/details/81088073

個人部落格:https://suveng.github.io/blog/​​​​​​​

作用

synchronized可以保證方法或者程式碼塊在運行時,同一時刻只有一個方法可以進入到臨界區,同時它還可以保證共享變數的記憶體可見性

鎖優化機制

引用自 https://mp.weixin.qq.com/s?__biz=MzI5NTYwNDQxNA==&mid=2247483816&idx=1&sn=16c0f7f48bddb2d7ca6546d1cf0f20b2&scene=19#wechat_redirect

自旋鎖

執行緒的阻塞和喚醒需要CPU從用戶態轉為核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,勢必會給系統的並發性能帶來很大的壓力。同時我們發現在許多應用上面,對象鎖的鎖狀態只會持續很短一段時間,為了這一段很短的時間頻繁地阻塞和喚醒執行緒是非常不值得的。所以引入自旋鎖。 何謂自旋鎖? 所謂自旋鎖,就是讓該執行緒等待一段時間,不會被立即掛起,看持有鎖的執行緒是否會很快釋放鎖。怎麼等待呢?執行一段無意義的循環即可(自旋)。 自旋等待不能替代阻塞,先不說對處理器數量的要求(多核,貌似現在沒有單核的處理器了),雖然它可以避免執行緒切換帶來的開銷,但是它佔用了處理器的時間。如果持有鎖的執行緒很快就釋放了鎖,那麼自旋的效率就非常好,反之,自旋的執行緒就會白白消耗掉處理的資源,它不會做任何有意義的工作,典型的占著茅坑不拉屎,這樣反而會帶來性能上的浪費。所以說,自旋等待的時間(自旋的次數)必須要有一個限度,如果自旋超過了定義的時間仍然沒有獲取到鎖,則應該被掛起。 自旋鎖在JDK 1.4.2中引入,默認關閉,但是可以使用-XX:+UseSpinning開開啟,在JDK1.6中默認開啟。同時自旋的默認次數為10次,可以通過參數-XX:PreBlockSpin來調整; 如果通過參數-XX:preBlockSpin來調整自旋鎖的自旋次數,會帶來諸多不便。假如我將參數調整為10,但是系統很多執行緒都是等你剛剛退出的時候就釋放了鎖(假如你多自旋一兩次就可以獲取鎖),你是不是很尷尬。於是JDK1.6引入自適應的自旋鎖,讓虛擬機會變得越來越聰明。

適應自旋鎖

JDK 1.6引入了更加聰明的自旋鎖,即自適應自旋鎖。所謂自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。它怎麼做呢?執行緒如果自旋成功了,那麼下次自旋的次數會更加多,因為虛擬機認為既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。反之,如果對於某個鎖,很少有自旋能夠成功的,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。 有了自適應自旋鎖,隨著程式運行和性能監控資訊的不斷完善,虛擬機對程式鎖的狀況預測會越來越準確,虛擬機會變得越來越聰明。

鎖消除

我們知道在使用同步鎖的時候,需要讓同步塊的作用範圍儘可能小—僅在共享數據的實際作用域中才進行同步,這樣做的目的是為了使需要同步的操作數量儘可能縮小,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖。 在大多數的情況下,上述觀點是正確的,LZ也一直堅持著這個觀點。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗話的概念。 鎖粗話概念比較好理解,就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。如上面實例:vector每次add的時候都需要加鎖操作,JVM檢測到對同一個對象(vector)連續加鎖、解鎖操作,會合併一個更大範圍的加鎖、解鎖操作,即加鎖解鎖操作會移到for循環之外。

鎖粗化

我們知道在使用同步鎖的時候,需要讓同步塊的作用範圍儘可能小—僅在共享數據的實際作用域中才進行同步,這樣做的目的是為了使需要同步的操作數量儘可能縮小,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖。 在大多數的情況下,上述觀點是正確的,LZ也一直堅持著這個觀點。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗話的概念。 鎖粗話概念比較好理解,就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。如上面實例:vector每次add的時候都需要加鎖操作,JVM檢測到對同一個對象(vector)連續加鎖、解鎖操作,會合併一個更大範圍的加鎖、解鎖操作,即加鎖解鎖操作會移到for循環之外。

偏向鎖

引用自 https://www.cnblogs.com/dsj2016/p/5714921.html

下一次有執行緒嘗試獲取鎖的時候,首先檢查這個對象頭的MarkWord是不是儲存著這個執行緒的ID。如果是,那麼直接進去而不需要任何別的操作。如果不是,那麼分為兩種情況。1,對象的偏向鎖標誌位為0(當前不是偏向鎖),說明發生了競爭,已經膨脹為輕量級鎖,這時使用CAS操作嘗試獲得鎖(這個操作具體是輕量級鎖的獲得鎖的過程下面講)。2,偏向鎖標誌位為1,說明還是偏向鎖不過請求的執行緒不是原來那個了。這時只需要使用CAS嘗試把對象頭偏向鎖從原來那個執行緒指向目前求鎖的執行緒。 這個CAS失敗了呢?首先必須明確這個CAS為什麼會失敗,也就是說發生了競爭,有別的執行緒和它搶鎖並且搶贏了,那麼這個情況下,它就會要求撤銷偏向鎖(因為發生了競爭)。那麼直接把對象頭設置為無鎖狀態重新來過。如果還是活動執行緒,先遍歷棧幀裡面的鎖記錄,讓這個偏向鎖變為無鎖狀態,然後恢復執行緒。

輕量級鎖

加鎖的過程:JVM在當前執行緒的棧幀中創建用於儲存鎖記錄的空間(LockRecord),然後把MarkWord放進去,同時生成一個叫Owner的指針指向那個加鎖的對象,同時用CAS嘗試把對象頭的MarkWord成一個指向鎖記錄的指針。成功了就拿到了鎖。那麼失敗了呢?失敗了的說法比較多。主流有《深入理解JVM》的說法和《並發編程的藝術》的說法。

《深入理解JVM》的說法:

失敗了,去查看MarkWord的值。有2種可能:1,指向當前執行緒的指針,2,別的值。

如果是1,那麼說明發生了「重入」的情況,直接當做成功獲得鎖處理。

其實這個有個疑問,為什麼獲得鎖成功了而CAS失敗了,這裡其實要牽扯到CAS的具體過程:先比較某個值是不是預測的值,是的話就動用原子操作交換(或賦值),否則不操作直接返回失敗。在用CAS的時候期待的值是其原本的MarkWord。發生「重入」的時候會發現其值不是期待的原本的MarkWord,而是一個指針,所以當然就返回失敗,但是如果這個指針指向這個執行緒,那麼說明其實已經獲得了鎖,不過是再進入一次。如果不是這個執行緒,那麼情況2:

如果是2,那麼發生了競爭,鎖會膨脹為一個重量級鎖(MutexLock)

《並發編程的藝術》的說法:

失敗了直接自旋。期望在自旋的時間內獲得鎖,如果還是不能獲得,那麼開始膨脹,修改鎖的MarkWord改為重量級鎖的指針,並且阻塞自己。

解鎖過程:(那個拿到鎖的執行緒)用CAS把MarkWord換回到原來的對象頭,如果成功,那麼沒有競爭發生,解鎖完成。如果失敗,表示存在競爭(之前有執行緒試圖通過CAS修改MarkWord),這時要釋放鎖並且喚醒阻塞的執行緒。

重量級鎖

重量級鎖通過對象內部的監視器(monitor)實現,其中monitor的本質是依賴於底層作業系統的Mutex Lock實現,作業系統實現執行緒之間的切換需要從用戶態到內核態的切換,切換成本非常高。

CAS原理

引用自 http://zl198751.iteye.com/blog/1848575

CAS原理

CAS通過調用JNI的程式碼實現的。JNI:Java Native Interface為JAVA本地調用,允許java調用其他語言。

而compareAndSwapInt就是藉助C來調用CPU底層指令實現的。

下面從分析比較常用的CPU(intel x86)來解釋CAS的實現原理。

下面是sun.misc.Unsafe類的compareAndSwapInt()方法的源程式碼:

public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

可以看到這是個本地方法調用。這個本地方法在openjdk中依次調用的c++程式碼為:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。這個本地方法的最終實現在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011openjdkhotspotsrcoscpuwindowsx86vm atomicwindowsx86.inline.hpp(對應於windows作業系統,X86處理器)。下面是對應於intel x86處理器的源程式碼的片段:

// Adding a lock prefix to an instruction on MP machine  // VC++ doesn't like the lock prefix to be on a single line  // so we can't insert a label after the lock prefix.  // By emitting a lock prefix, we can define a label after it.  #define LOCK_IF_MP(mp) __asm cmp mp, 0                           __asm je L0                               __asm _emit 0xF0                          __asm L0:    inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {    // alternative for InterlockedCompareExchange    int mp = os::is_MP();    __asm {      mov edx, dest      mov ecx, exchange_value      mov eax, compare_value      LOCK_IF_MP(mp)      cmpxchg dword ptr [edx], ecx    }  }

如上面源程式碼所示,程式會根據當前處理器的類型來決定是否為cmpxchg指令添加lock前綴。如果程式是在多處理器上運行,就為cmpxchg指令加上lock前綴(lock cmpxchg)。反之,如果程式是在單處理器上運行,就省略lock前綴(單處理器自身會維護單處理器內的順序一致性,不需要lock前綴提供的記憶體屏障效果)。

intel的手冊對lock前綴的說明如下:

確保對記憶體的讀-改-寫操作原子執行。在Pentium及Pentium之前的處理器中,帶有lock前綴的指令在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。很顯然,這會帶來昂貴的開銷。從Pentium 4,Intel Xeon及P6處理器開始,intel在原有匯流排鎖的基礎上做了一個很有意義的優化:如果要訪問的記憶體區域(area of memory)在lock前綴指令執行期間已經在處理器內部的快取中被鎖定(即包含該記憶體區域的快取行當前處於獨佔或以修改狀態),並且該記憶體區域被完全包含在單個快取行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該快取行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的記憶體區域,因此能保證指令執行的原子性。這個操作過程叫做快取鎖定(cache locking),快取鎖定將大大降低lock前綴指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的記憶體地址未對齊時,仍然會鎖住匯流排。 禁止該指令與之前和之後的讀和寫指令重排序。 把寫緩衝區中的所有數據刷新到記憶體中。 備註知識:

關於CPU的鎖有如下3種:

3.1 處理器自動保證基本記憶體操作的原子性

首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從系統記憶體當中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能訪問這個位元組的記憶體地址。奔騰6和最新的處理器能自動保證單處理器對同一個快取行里進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器不能自動保證其原子性,比如跨匯流排寬度,跨多個快取行,跨頁表的訪問。但是處理器提供匯流排鎖定和快取鎖定兩個機制來保證複雜記憶體操作的原子性。

3.2 使用匯流排鎖保證原子性

第一個機制是通過匯流排鎖保證原子性。如果多個處理器同時對共享變數進行讀改寫(i++就是經典的讀改寫操作)操作,那麼共享變數就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變數的值會和期望的不一致,舉個例子:如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2。如下圖

原因是有可能多個處理器同時從各自的快取中讀取變數i,分別進行加一操作,然後分別寫入系統記憶體當中。那麼想要保證讀改寫共享變數的操作是原子的,就必須保證CPU1讀改寫共享變數的時候,CPU2不能操作快取了該共享變數記憶體地址的快取。

處理器使用匯流排鎖就是來解決這個問題的。所謂匯流排鎖就是使用處理器提供的一個LOCK#訊號,當一個處理器在匯流排上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔使用共享記憶體。

3.3 使用快取鎖保證原子性

第二個機制是通過快取鎖定保證原子性。在同一時刻我們只需保證對某個記憶體地址的操作是原子性即可,但匯流排鎖定把CPU和記憶體之間通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的數據,所以匯流排鎖定的開銷比較大,最近的處理器在某些場合下使用快取鎖定代替匯流排鎖定來進行優化。

頻繁使用的記憶體會快取在處理器的L1,L2和L3高速快取里,那麼原子操作就可以直接在處理器內部快取中進行,並不需要聲明匯流排鎖,在奔騰6和最近的處理器中可以使用「快取鎖定」的方式來實現複雜的原子性。所謂「快取鎖定」就是如果快取在處理器快取行中記憶體區域在LOCK操作期間被鎖定,當它執行鎖操作回寫記憶體時,處理器不在匯流排上聲言LOCK#訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域數據,當其他處理器回寫已被鎖定的快取行的數據時會起快取行無效,在例1中,當CPU1修改快取行中的i時使用快取鎖定,那麼CPU2就不能同時快取了i的快取行。

但是有兩種情況下處理器不會使用快取鎖定。第一種情況是:當操作的數據不能被快取在處理器內部,或操作的數據跨多個快取行(cache line),則處理器會調用匯流排鎖定。第二種情況是:有些處理器不支援快取鎖定。對於Inter486和奔騰處理器,就算鎖定的記憶體區域在處理器的快取行中也會調用匯流排鎖定。

以上兩個機制我們可以通過Inter處理器提供了很多LOCK前綴的指令來實現。比如位測試和修改指令BTS,BTR,BTC,交換指令XADD,CMPXCHG和其他一些操作數和邏輯指令,比如ADD(加),OR(或)等,被這些指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問它。

CAS缺點

CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,循環時間長開銷大和只能保證一個共享變數的原子操作

  1. ABA問題。因為CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。

從Java1.5開始JDK的atomic包里提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置為給定的更新值。

關於ABA問題參考文檔: http://blog.hesey.net/2011/09/resolve-aba-by-atomicstampedreference.html

  1. 循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因記憶體順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。
  2. 只能保證一個共享變數的原子操作。當對一個共享變數執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變數操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變數放在一個對象里來進行CAS操作。