天天看點

java多線程(七)

目前在Java中存在兩種鎖機制:synchronized和Lock,Lock接口及其實作類是JDK5增加的内容。本文并不比較synchronized與Lock孰優孰劣,隻是介紹二者的實作原理。

13、偏向鎖和輕量級鎖、鎖粗化、鎖消除、鎖膨脹

因為這幾個概念連續非常緊密是以放在一起會友善了解記憶。

在jdk1.6中對鎖的實作引入了大量的優化,如鎖粗化(Lock Coarsening)、鎖消除(Lock Elimination)、輕量級鎖(Lightweight Locking)、

偏向鎖(Biased Locking)、适應性自旋(Adaptive Spinning)等技術來減少鎖操作的開銷。

鎖粗化(Lock Coarsening):也就是減少不必要的緊連在一起的unlock,lock操作,将多個連續的鎖擴充成一個範圍更大的鎖。

鎖消除(Lock Elimination):通過運作時JIT編譯器的逃逸分析來消除一些沒有在目前同步塊以外被其他線程共享的資料的鎖保護,

通過逃逸分析也可以線上程本地Stack上進行對象空間的配置設定(同時還可以減少Heap上的垃圾收集開銷)。

輕量級鎖(Lightweight Locking):這種鎖實作的背後基于這樣一種假設,即在真實的情況下我們程式中的大部分同步代碼一般都處于無鎖競争狀态

(即單線程執行環境),在無鎖競争的情況下完全可以避免調用作業系統層面的重量級互斥鎖,

取而代之的是在monitorenter和monitorexit中隻需要依靠一條CAS原子指令就可以完成鎖的擷取及釋放。

當存在鎖競争的情況下,執行CAS指令失敗的線程将調用作業系統互斥鎖進入到阻塞狀态,當鎖被釋放的時候被喚醒(具體處理步驟下面詳細讨論)。

偏向鎖(Biased Locking):是為了在無鎖競争的情況下避免在鎖擷取過程中執行不必要的CAS原子指令,

因為CAS原子指令雖然相對于重量級鎖來說開銷比較小但還是存在非常可觀的本地延遲(可參考這篇文章)。

适應性自旋(Adaptive Spinning):當線程在擷取輕量級鎖的過程中執行CAS操作失敗時,在進入與monitor相關聯的作業系統重量級鎖

(mutex semaphore)前會進入忙等待(Spinning)然後再次嘗試,當嘗試一定的次數後如果仍然沒有成功則調用與該monitor關聯的semaphore(即互斥鎖),

進入到阻塞狀态。

注:(适應性)自旋鎖,是在從輕量級鎖向重量級鎖膨脹的過程中使用的,是在進入重量級鎖之前進行的。

鎖存在Java對象頭裡。如果對象是數組類型,則虛拟機用3個Word(字寬)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。

在32位虛拟機中,一字寬等于四位元組,即32bit。

鎖狀态包括:輕量級鎖定、重量級鎖定、GC标記、可偏向

簡單的加鎖機制:

機制:每個鎖都關聯一個請求計數器和一個占有他的線程,當請求計數器為0時,這個鎖可以被認為是unhled的,

當一個線程請求一個unheld的鎖時,JVM記錄鎖的擁有者,并把鎖的請求計數加1,如果同一個線程再次請求這個鎖時,請求計數器就會增加,

當該線程退出syncronized塊時,計數器減1,當計數器為0時,鎖被釋放(這就保證了鎖是可重入的,不會發生死鎖的情況)。

偏向鎖流程:

偏向鎖,簡單的講,就是在鎖對象的對象頭中有個ThreaddId字段,這個字段如果是空的,

第一次擷取鎖的時候,就将自身的ThreadId寫入到鎖的ThreadId字段内,将鎖頭内的是否偏向鎖的狀态位置1.

這樣下次擷取鎖的時候,直接檢查ThreadId是否和自身線程Id一緻,如果一緻,則認為目前線程已經擷取了鎖,是以不需再次擷取鎖,

略過了輕量級鎖和重量級鎖的加鎖階段。提高了效率。

但是偏向鎖也有一個問題,就是當鎖有競争關系的時候,需要解除偏向鎖,使鎖進入競争的狀态。

下面是清晰的流程:

[img]http://dl2.iteye.com/upload/attachment/0126/0379/7fa4224c-0b3e-304b-a029-36b08028d855.png[/img]

上圖中隻講了偏向鎖的釋放,其實還涉及偏向鎖的搶占,其實就是兩個程序對鎖的搶占,在synchrnized鎖下表現為輕量鎖方式進行搶占。

注:也就是說一旦偏向鎖沖突,雙方都會更新為輕量級鎖。(這一點與輕量級->重量級鎖不同,那時候失敗一方直接更新,成功一方在釋放時候notify,加下文後面較長的描述)

如下圖。之後會進入到輕量級鎖階段,兩個線程進入鎖競争狀态(注,我了解仍然會遵守先來後到原則;注2,的确是的,下圖中提到了mark word中的lock record指向堆棧中最近的一個線程的lock record),一個具體例子可以參考synchronized鎖機制。(圖後面有介紹)

[img]http://dl2.iteye.com/upload/attachment/0126/0381/c2d1dc74-138b-3417-86f9-c35660eca38f.jpg[/img]

每一個線程在準備擷取共享資源時:

第一步,檢查MarkWord裡面是不是放的自己的ThreadId ,如果是,表示目前線程是處于 “偏向鎖”

第二步,如果MarkWord不是自己的ThreadId,鎖更新,這時候,用CAS來執行切換,新的線程根據MarkWord裡面現有的ThreadId,通知之前線程暫停,

之前線程将Markword的内容置為空。

第三步,兩個線程都把對象的HashCode複制到自己建立的用于存儲鎖的記錄空間,接着開始通過CAS操作,

把共享對象的MarKword的内容修改為自己建立的記錄空間的位址的方式競争MarkWord,

第四步,第三步中成功執行CAS的獲得資源,失敗的則進入自旋

第五步,自旋的線程在自旋過程中,成功獲得資源(即之前獲的資源的線程執行完成并釋放了共享資源),則整個狀态依然處于 輕量級鎖的狀态,如果自旋失敗

第六步,進入重量級鎖的狀态,這個時候,自旋的線程進行阻塞,等待之前線程執行完成并喚醒自己

複制代碼

總結:

偏向鎖,其實是無鎖競争下可重入鎖的簡單實作。流程是這樣的 偏向鎖->輕量級鎖->重量級鎖

同步的原理

JVM規範規定JVM基于進入和退出Monitor對象來實作方法同步和代碼塊同步,但兩者的實作細節不一樣。

代碼塊同步是使用monitorenter和monitorexit指令實作,而方法同步是使用另外一種方式實作的,細節在JVM規範裡并沒有詳細說明,但是方法的同步同樣可以使用這兩個指令來實作。

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處, JVM要保證每個monitorenter必須有對應的monitorexit與之配對。

任何對象都有一個 monitor 與之關聯,當且一個monitor 被持有後,它将處于鎖定狀态。線程執行到 monitorenter 指令時,将會嘗試擷取對象所對應的 monitor 的所有權,即嘗試獲得對象的鎖。

Java對象頭

鎖存在Java對象頭裡。如果對象是數組類型,則虛拟機用3個Word(字寬)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛拟機中,一字寬等于四位元組,即32bit。(下面這個表格講的很清楚)

[img]http://dl2.iteye.com/upload/attachment/0126/0383/8ec54173-f008-3d31-a488-228ad1195baf.png[/img]

Java對象頭裡的Mark Word裡預設存儲對象的HashCode,分代年齡和鎖标記位。32位JVM的Mark Word的預設存儲結構如下:

[img]http://dl2.iteye.com/upload/attachment/0126/0385/5babd8c3-b3a5-3a40-9fc3-0203533e1a1d.png[/img]

在運作期間Mark Word裡存儲的資料會随着鎖标志位的變化而變化。Mark Word可能變化為存儲以下4種資料:

[img]http://dl2.iteye.com/upload/attachment/0126/0387/45293d65-3951-3b4e-a36f-c0ed62be5d33.png[/img]

上圖裡面的GC标記,為11的話,推斷應該是準備GC的意思。

在64位虛拟機下,Mark Word是64bit大小的,其存儲結構如下:

[img]http://dl2.iteye.com/upload/attachment/0126/0389/abe63984-0810-3b45-b97a-824cd199ff92.png[/img]

鎖的更新

Java SE1.6為了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,

是以在Java SE1.6裡鎖一共有四種狀态,無鎖狀态,偏向鎖狀态,輕量級鎖狀态和重量級鎖狀态,它會随着競争情況逐漸更新。

鎖可以更新但不能降級,意味着偏向鎖更新成輕量級鎖後不能降級成偏向鎖。

這種鎖更新卻不能降級的政策,目的是為了提高獲得鎖和釋放鎖的效率,下文會詳細分析。

[img]http://dl2.iteye.com/upload/attachment/0126/0391/f1b98d58-83ae-3643-ad9e-ccd29c61c729.png[/img]

偏向鎖

複制代碼

Hotspot的作者經過以往的研究發現大多數情況下鎖不僅不存在多線程競争,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。

當一個線程通路同步塊并擷取鎖時,會在對象頭和棧幀中的鎖記錄裡存儲鎖偏向的線程ID,

以後該線程在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而隻需簡單的測試一下對象頭的Mark Word裡是否存儲着指向目前線程的偏向鎖,

如果測試成功,表示線程已經獲得了鎖,如果測試失敗,則需要再測試下Mark Word中偏向鎖的辨別是否設定成1(表示目前是偏向鎖),如果沒有設定,

則使用CAS競争鎖,如果設定了,則嘗試使用CAS将對象頭的偏向鎖指向目前線程。

偏向鎖的撤銷:偏向鎖使用了一種等到競争出現才釋放鎖的機制,是以當其他線程嘗試競争偏向鎖時,持有偏向鎖的線程才會釋放鎖。

偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有位元組碼正在執行),

它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,

如果線程不處于活動狀态,則将對象頭設定成無鎖狀态,

如果線程仍然活着,擁有偏向鎖的棧會被執行,周遊偏向對象的鎖記錄,

棧中的鎖記錄和對象頭的Mark Word要麼重新偏向于其他線程,要麼恢複到無鎖或者标記對象不适合作為偏向鎖,最後喚醒暫停的線程。

上面的意思是,先暫停持有偏向鎖的線程,嘗試直接切換。如果不成功,就繼續運作,并且标記對象不适合偏向鎖,鎖膨脹(鎖更新)。

詳見,上面有張圖中的“偏向鎖搶占模式”:

其中提到了mark word中的lock record指向堆棧最近的一個線程的lock record,其實就是按照先來後到模式進行了輕量級的加鎖。

複制代碼

上文提到全局安全點:在這個時間點上沒有位元組碼正在執行。

關閉偏向鎖:偏向鎖在Java 6和Java 7裡是預設啟用的,但是它在應用程式啟動幾秒鐘之後才激活,

如有必要可以使用JVM參數來關閉延遲-XX:BiasedLockingStartupDelay = 0。

如果你确定自己應用程式裡所有的鎖通常情況下處于競争狀态,可以通過JVM參數關閉偏向鎖-XX:-UseBiasedLocking=false,那麼預設會進入輕量級鎖狀态。

輕量級鎖

輕量級鎖加鎖:線程在執行同步塊之前,JVM會先在目前線程的棧桢中建立用于存儲鎖記錄的空間,并将對象頭中的Mark Word複制到鎖記錄中,官方稱為Displaced Mark Word。

然後線程嘗試使用CAS将對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,目前線程獲得鎖,如果失敗,表示其他線程競争鎖,目前線程便嘗試使用自旋來擷取鎖。

輕量級鎖解鎖:輕量級解鎖時,會使用原子的CAS操作來将Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競争發生。

如果失敗,表示目前鎖存在競争,鎖就會膨脹成重量級鎖。

注:輕量級鎖會一直保持,喚醒總是發生在輕量級鎖解鎖的時候,因為加鎖的時候已經成功CAS操作;而CAS失敗的線程,會立即鎖膨脹,并阻塞等待喚醒。(詳見下圖)

下圖是兩個線程同時争奪鎖,導緻鎖膨脹的流程圖。

[img]http://dl2.iteye.com/upload/attachment/0126/0393/3bddc842-1725-35d9-ae30-aae1cd8647c0.png[/img]

鎖不會降級

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖更新成重量級鎖,就不會再恢複到輕量級鎖狀态。

當鎖處于這個狀态下,其他線程試圖擷取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之争。

[img]http://dl2.iteye.com/upload/attachment/0126/0395/903c8fe9-ea3e-36c3-8aca-5942fb52305e.png[/img]

輕量級鎖具體實作:

一個線程能夠通過兩種方式鎖住一個對象:1、通過膨脹一個處于無鎖狀态(狀态位001)的對象獲得該對象的鎖;

2、對象已經處于膨脹狀态(狀态位00)但LockWord指向的monitor record的Owner字段為NULL,

則可以直接通過CAS原子指令嘗試将Owner設定為自己的辨別來獲得鎖。

從中可以看出,是先檢查鎖的辨別位。

CAS應用

CAS有3個操作數,記憶體值V,舊的預期值A,要修改的新值B。當且僅當預期值A和記憶體值V相同時,将記憶體值V修改為B,否則什麼都不做。

複制代碼

下面從分析比較常用的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。

複制代碼

對于32位/64位的操作應該是原子的:

奔騰6和最新的處理器能自動保證單處理器對同一個緩存行裡進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器不能自動保證其原子性,

比如跨總線寬度,跨多個緩存行,跨頁表的通路。但是處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜記憶體操作的原子性。

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

2. 循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令那麼效率會有一定的提升,

pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,

延遲的時間取決于具體實作的版本,在一些處理器上延遲時間是零。

第二它可以避免在退出循環的時候因記憶體順序沖突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),進而提高CPU的執行效率。

3. 隻能保證一個共享變量的原子操作。當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,

但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,

或者有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合并一下ij=2a,然後用CAS來操作ij。

從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裡來進行CAS操作。

轉載自http://www.cnblogs.com/charlesblc/p/5994162.html這邊部落格寫的太好了!隻是調整了排版然後删除了一些備援段落直接複用了!這都是滿滿的幹貨啊!