在圖文詳解Java對象內存布局這篇文章中,在研究對象頭時我們了解了synchronized
鎖升級的過程,由於篇幅有限,對鎖升級的過程介紹的比較簡略,本文在上一篇的基礎上,來詳細研究一下鎖升級的過程以及各個狀態下鎖的原理。本文結構如下:
1 無鎖
在上一篇文章中,我們提到過 jvm會有4秒的偏向鎖開啟的延遲時間,在這個偏向延遲內對象處於為無鎖態。如果關閉偏向鎖啟動延遲、或是經過4秒且沒有線程競爭對象的鎖,那么對象會進入無鎖可偏向狀態。
准確來說,無鎖可偏向狀態應該叫做匿名偏向(Anonymously biased
)狀態,因為這時對象的mark word
中后三位已經是101
,但是threadId
指針部分仍然全部為0,它還沒有向任何線程偏向。綜上所述,對象在剛被創建時,根據jvm的配置對象可能會處於 無鎖 或 匿名偏向 兩個狀態。
此外,如果在jvm的參數中關閉偏向鎖,那么直到有線程獲取這個鎖對象之前,會一直處於無鎖不可偏向狀態。修改jvm啟動參數:
-XX:-UseBiasedLocking
延遲5s后打印對象內存布局:
public static void main(String[] args) throws InterruptedException {
User user=new User();
TimeUnit.SECONDS.sleep(5);
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
可以看到,即使經過一定的啟動延時,對象一直處於001
無鎖不可偏向狀態。大家可能會有疑問,在無鎖狀態下,為什么要存在一個不可偏向狀態呢?通過查閱資料得到的解釋是:
JVM內部的代碼有很多地方也用到了synchronized,明確在這些地方存在線程的競爭,如果還需要從偏向狀態再逐步升級,會帶來額外的性能損耗,所以JVM設置了一個偏向鎖的啟動延遲,來降低性能損耗
也就是說,在無鎖不可偏向狀態下,如果有線程試圖獲取鎖,那么將跳過升級偏向鎖的過程,直接使用輕量級鎖。使用代碼進行驗證:
//-XX:-UseBiasedLocking
public static void main(String[] args) throws InterruptedException {
User user=new User();
synchronized (user){
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
}
查看結果可以看到,在關閉偏向鎖情況下使用synchronized
,鎖會直接升級為輕量級鎖(00
狀態):
在目前的基礎上,可以用流程圖概括上面的過程:
額外注意一點就是匿名偏向狀態下,如果調用系統的hashCode()
方法,會使對象回到無鎖態,並在markword
中寫入hashCode
。並且在這個狀態下,如果有線程嘗試獲取鎖,會直接從無鎖升級到輕量級鎖,不會再升級為偏向鎖。
2 偏向鎖
2.1 偏向鎖原理
匿名偏向狀態是偏向鎖的初始狀態,在這個狀態下第一個試圖獲取該對象的鎖的線程,會使用CAS操作(匯編命令CMPXCHG
)嘗試將自己的threadID
寫入對象頭的mark word
中,使匿名偏向狀態升級為已偏向(Biased)的偏向鎖狀態。在已偏向狀態下,線程指針threadID
非空,且偏向鎖的時間戳epoch
為有效值。
如果之后有線程再次嘗試獲取鎖時,需要檢查mark word
中存儲的threadID
是否與自己相同即可,如果相同那么表示當前線程已經獲得了對象的鎖,不需要再使用CAS操作來進行加鎖。
如果mark word
中存儲的threadID
與當前線程不同,那么將執行CAS操作,試圖將當前線程的ID替換mark word
中的threadID
。只有當對象處於下面兩種狀態中時,才可以執行成功:
- 對象處於匿名偏向狀態
- 對象處於可重偏向(Rebiasable)狀態,新線程可使用CAS將
threadID
指向自己
如果對象不處於上面兩個狀態,說明鎖存在線程競爭,在CAS替換失敗后會執行偏向鎖撤銷操作。偏向鎖的撤銷需要等待全局安全點Safe Point
(安全點是 jvm為了保證在垃圾回收的過程中引用關系不會發生變化設置的安全狀態,在這個狀態上會暫停所有線程工作),在這個安全點會掛起獲得偏向鎖的線程。
在暫停線程后,會通過遍歷當前jvm的所有線程的方式,檢查持有偏向鎖的線程狀態是否存活:
- 如果線程還存活,且線程正在執行同步代碼塊中的代碼,則升級為輕量級鎖
- 如果持有偏向鎖的線程未存活,或者持有偏向鎖的線程未在執行同步代碼塊中的代碼,則進行校驗是否允許重偏向:
-
不允許重偏向,則撤銷偏向鎖,將
mark word
升級為輕量級鎖,進行CAS競爭鎖 -
允許重偏向,設置為匿名偏向鎖狀態,CAS將偏向鎖重新指向新線程
-
完成上面的操作后,喚醒暫停的線程,從安全點繼續執行代碼。可以使用流程圖總結上面的過程:
2.2 偏向鎖升級
在上面的過程中,我們已經知道了匿名偏向狀態可以變為無鎖態或升級為偏向鎖,接下來看一下偏向鎖的其他狀態的改變
- 偏向鎖升級為輕量級鎖
public static void main(String[] args) throws InterruptedException {
User user=new User();
synchronized (user){
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
Thread thread = new Thread(() -> {
synchronized (user) {
System.out.println("--THREAD--:"+ClassLayout.parseInstance(user).toPrintable());
}
});
thread.start();
thread.join();
System.out.println("--END--:"+ClassLayout.parseInstance(user).toPrintable());
}
查看內存布局,偏向鎖升級為輕量級鎖,在執行完成同步代碼后釋放鎖,變為無鎖不可偏向狀態:
- 偏向鎖升級為重量級鎖
public static void main(String[] args) throws InterruptedException {
User user=new User();
Thread thread = new Thread(() -> {
synchronized (user) {
System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
try {
user.wait(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("--THREAD END--:" + ClassLayout.parseInstance(user).toPrintable());
}
});
thread.start();
thread.join();
TimeUnit.SECONDS.sleep(3);
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
查看內存布局,可以看到在調用了對象的wait()
方法后,直接從偏向鎖升級成了重量級鎖,並在鎖釋放后變為無鎖態:
這里是因為wait()
方法調用過程中依賴於重量級鎖中與對象關聯的monitor
,在調用wait()
方法后monitor
會把線程變為WAITING
狀態,所以才會強制升級為重量級鎖。除此之外,調用hashCode
方法時也會使偏向鎖直接升級為重量級鎖。
在上面分析的基礎上,再加上我們上一篇中講到的輕量級鎖升級到重量級鎖的知識,就可以對上面的流程圖進行完善了:
2.3 批量重偏向
在未禁用偏向鎖的情況下,當一個線程建立了大量對象,並且對它們執行完同步操作解鎖后,所有對象處於偏向鎖狀態,此時若再來另一個線程也嘗試獲取這些對象的鎖,就會導偏向鎖的批量重偏向(Bulk Rebias)。當觸發批量重偏向后,第一個線程結束同步操作后的鎖對象當再被同步訪問時會被重置為可重偏向狀態,以便允許快速重偏向,這樣能夠減少撤銷偏向鎖再升級為輕量級鎖的性能消耗。
首先看一下和偏向鎖有關的參數,修改jvm啟動參數,使用下面的命令可以在項目啟動時打印jvm的默認參數值:
-XX:+PrintFlagsFinal
需要關注的屬性有下面3個:
BiasedLockingBulkRebiasThreshold
:偏向鎖批量重偏向閾值,默認為20次BiasedLockingBulkRevokeThreshold
:偏向鎖批量撤銷閾值,默認為40次BiasedLockingDecayTime
:重置計數的延遲時間,默認值為25000毫秒(即25秒)
批量重偏向是以class
而不是對象為單位的,每個class
會維護一個偏向鎖的撤銷計數器,每當該class
的對象發生偏向鎖的撤銷時,該計數器會加一,當這個值達到默認閾值20時,jvm就會認為這個鎖對象不再適合原線程,因此進行批量重偏向。而距離上次批量重偏向的25秒內,如果撤銷計數達到40,就會發生批量撤銷,如果超過25秒,那么就會重置在[20, 40)內的計數。
上面這段理論是不是聽上去有些難理解,沒關系,我們先用代碼驗證批量重偏向的過程:
private static Thread t1,t2;
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
List<Object> list = new ArrayList<>();
for (int i = 0; i < 40; i++) {
list.add(new Object());
}
t1 = new Thread(() -> {
for (int i = 0; i < list.size(); i++) {
synchronized (list.get(i)) {
}
}
LockSupport.unpark(t2);
});
t2 = new Thread(() -> {
LockSupport.park();
for (int i = 0; i < 30; i++) {
Object o = list.get(i);
synchronized (o) {
if (i == 18 || i == 19) {
System.out.println("THREAD-2 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
}
}
}
});
t1.start();
t2.start();
t2.join();
TimeUnit.SECONDS.sleep(3);
System.out.println("Object19:"+ClassLayout.parseInstance(list.get(18)).toPrintable());
System.out.println("Object20:"+ClassLayout.parseInstance(list.get(19)).toPrintable());
System.out.println("Object30:"+ClassLayout.parseInstance(list.get(29)).toPrintable());
System.out.println("Object31:"+ClassLayout.parseInstance(list.get(30)).toPrintable());
}
分析上面的代碼,當線程t1
運行結束后,數組中所有對象的鎖都偏向t1
,然后t1
喚醒被掛起的線程t2
,線程t2
嘗試獲取前30個對象的鎖。我們打印線程t2
獲取到的第19和第20個對象的鎖狀態:
線程t2
在訪問前19個對象時對象的偏向鎖會升級到輕量級鎖,在訪問后11個對象(下標19-29)時,因為偏向鎖撤銷次數達到了20,會觸發批量重偏向,將鎖的狀態變為偏向線程t2
。在全部線程結束后,再次查看第19、20、30、31個對象鎖的狀態:
線程t2
結束后,第1-19的對象釋放輕量級鎖變為無鎖不可偏向狀態,第20-30的對象狀態為偏向鎖、但從偏向t1
改為偏向t2
,第31-40的對象因為沒有被線程t2
訪問所以保持偏向線程t1
不變。
2.4 批量撤銷
在多線程競爭激烈的狀況下,使用偏向鎖將會導致性能降低,因此產生了批量撤銷機制,接下來使用代碼進行測試:
private static Thread t1, t2, t3;
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
List<Object> list = new ArrayList<>();
for (int i = 0; i < 40; i++) {
list.add(new Object());
}
t1 = new Thread(() -> {
for (int i = 0; i < list.size(); i++) {
synchronized (list.get(i)) {
}
}
LockSupport.unpark(t2);
});
t2 = new Thread(() -> {
LockSupport.park();
for (int i = 0; i < list.size(); i++) {
Object o = list.get(i);
synchronized (o) {
if (i == 18 || i == 19) {
System.out.println("THREAD-2 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
}
}
}
LockSupport.unpark(t3);
});
t3 = new Thread(() -> {
LockSupport.park();
for (int i = 0; i < list.size(); i++) {
Object o = list.get(i);
synchronized (o) {
System.out.println("THREAD-3 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
}
}
});
t1.start();
t2.start();
t3.start();
t3.join();
System.out.println("New: "+ClassLayout.parseInstance(new Object()).toPrintable());
}
對上面的運行流程進行分析:
- 線程
t1
中,第1-40的鎖對象狀態變為偏向鎖 - 線程
t2
中,第1-19的鎖對象撤銷偏向鎖升級為輕量級鎖,然后對第20-40的對象進行批量重偏向 - 線程
t3
中,首先直接對第1-19個對象競爭輕量級鎖,而從第20個對象開始往后的對象不會再次進行批量重偏向,因此第20-39的對象進行偏向鎖撤銷升級為輕量級鎖,這時t2
和t3
線程一共執行了40次的鎖撤銷,觸發鎖的批量撤銷機制,對偏向鎖進行撤銷置為輕量級鎖
看一下在3個線程都結束后創建的新對象:
可以看到,創建的新對象為無鎖不可偏向狀態001
,說明當類觸發了批量撤銷機制后,jvm會禁用該類創建對象時的可偏向性,該類新創建的對象全部為無鎖不可偏向狀態。
2.5 總結
偏向鎖通過消除資源無競爭情況下的同步原語,提高了程序在單線程下訪問同步資源的運行性能,但是當出現多個線程競爭時,就會撤銷偏向鎖、升級為輕量級鎖。
如果我們的應用系統是高並發、並且代碼中同步資源一直是被多線程訪問的,那么撤銷偏向鎖這一步就顯得多余,偏向鎖撤銷時進入Safe Point
產生STW
的現象應該是被極力避免的,這時應該通過禁用偏向鎖來減少性能上的損耗。
3 輕量級鎖
3.1 輕量級鎖原理
1、在代碼訪問同步資源時,如果鎖對象處於無鎖不可偏向狀態,jvm首先將在當前線程的棧幀中創建一條鎖記錄(lock record
),用於存放:
displaced mark word
(置換標記字):存放鎖對象當前的mark word
的拷貝owner
指針:指向當前的鎖對象的指針,在拷貝mark word
階段暫時不會處理它
2、在拷貝mark word
完成后,首先會掛起線程,jvm使用CAS操作嘗試將對象的 mark word
中的 lock record
指針指向棧幀中的鎖記錄,並將鎖記錄中的owner
指針指向鎖對象的mark word
- 如果CAS替換成功,表示競爭鎖對象成功,則將鎖標志位設置成
00
,表示對象處於輕量級鎖狀態,執行同步代碼中的操作
- 如果CAS替換失敗,則判斷當前對象的
mark word
是否指向當前線程的棧幀:- 如果是則表示當前線程已經持有對象的鎖,執行的是
synchronized
的鎖重入過程,可以直接執行同步代碼塊 - 否則說明該其他線程已經持有了該對象的鎖,如果在自旋一定次數后仍未獲得鎖,那么輕量級鎖需要升級為重量級鎖,將鎖標志位變成
10
,后面等待的線程將會進入阻塞狀態
- 如果是則表示當前線程已經持有對象的鎖,執行的是
4、輕量級鎖的釋放同樣使用了CAS操作,嘗試將displaced mark word
替換回mark word
,這時需要檢查鎖對象的mark word
中lock record
指針是否指向當前線程的鎖記錄:
- 如果替換成功,則表示沒有競爭發生,整個同步過程就完成了
- 如果替換失敗,則表示當前鎖資源存在競爭,有可能其他線程在這段時間里嘗試過獲取鎖失敗,導致自身被掛起,並修改了鎖對象的
mark word
升級為重量級鎖,最后在執行重量級鎖的解鎖流程后喚醒被掛起的線程
用流程圖對上面的過程進行描述:
3.2 輕量級鎖重入
我們知道,synchronized
是可以鎖重入的,在輕量級鎖的情況下重入也是依賴於棧上的lock record
完成的。以下面的代碼中3次鎖重入為例:
synchronized (user){
synchronized (user){
synchronized (user){
//TODO
}
}
}
輕量級鎖的每次重入,都會在棧中生成一個lock record
,但是保存的數據不同:
- 首次分配的
lock record
,displaced mark word
復制了鎖對象的mark word
,owner
指針指向鎖對象 - 之后重入時在棧中分配的
lock record
中的displaced mark word
為null
,只存儲了指向對象的owner
指針
輕量級鎖中,重入的次數等於該鎖對象在棧幀中lock record
的數量,這個數量隱式地充當了鎖重入機制的計數器。這里需要計數的原因是每次解鎖都需要對應一次加鎖,只有最后解鎖次數等於加鎖次數時,鎖對象才會被真正釋放。在釋放鎖的過程中,如果是重入則刪除棧中的lock record
,直到沒有重入時則使用CAS替換鎖對象的mark word
。
3.3 輕量級鎖升級
在jdk1.6以前,默認輕量級鎖自旋次數是10次,如果超過這個次數或自旋線程數超過CPU核數的一半,就會升級為重量級鎖。這時因為如果自旋次數過多,或過多線程進入自旋,會導致消耗過多cpu資源,重量級鎖情況下線程進入等待隊列可以降低cpu資源的消耗。自旋次數的值也可以通過jvm參數進行修改:
-XX:PreBlockSpin
jdk1.6以后加入了自適應自旋鎖 (Adapative Self Spinning
),自旋的次數不再固定,由jvm自己控制,由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定:
- 對於某個鎖對象,如果自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那么虛擬機就會認為這次自旋也是很有可能再次成功,進而允許自旋等待持續相對更長時間
- 對於某個鎖對象,如果自旋很少成功獲得過鎖,那在以后嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。
下面通過代碼驗證輕量級鎖升級為重量級鎖的過程:
public static void main(String[] args) throws InterruptedException {
User user = new User();
System.out.println("--MAIN--:" + ClassLayout.parseInstance(user).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (user) {
System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread thread2 = new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (user) {
System.out.println("--THREAD2--:" + ClassLayout.parseInstance(user).toPrintable());
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
TimeUnit.SECONDS.sleep(3);
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
在上面的代碼中,線程2在啟動后休眠兩秒后再嘗試獲取鎖,確保線程1能夠先得到鎖,在此基礎上造成鎖對象的資源競爭。查看對象鎖狀態變化:
在線程1持有輕量級鎖的情況下,線程2嘗試獲取鎖,導致資源競爭,使輕量級鎖升級到重量級鎖。在兩個線程都運行結束后,可以看到對象的狀態恢復為了無鎖不可偏向狀態,在下一次線程嘗試獲取鎖時,會直接從輕量級鎖狀態開始。
上面在最后一次打印前將主線程休眠3秒的原因是鎖的釋放過程需要一定的時間,如果在線程執行完成后直接打印對象內存布局,對象可能仍處於重量級鎖狀態。
3.4 總結
輕量級鎖與偏向鎖類似,都是jdk對於多線程的優化,不同的是輕量級鎖是通過CAS來避免開銷較大的互斥操作,而偏向鎖是在無資源競爭的情況下完全消除同步。
輕量級鎖的“輕量”是相對於重量級鎖而言的,它的性能會稍好一些。輕量級鎖嘗試利用CAS,在升級為重量級鎖之前進行補救,目的是為了減少多線程進入互斥,當多個線程交替執行同步塊時,jvm使用輕量級鎖來保證同步,避免線程切換的開銷,不會造成用戶態與內核態的切換。但是如果過度自旋,會引起cpu資源的浪費,這種情況下輕量級鎖消耗的資源可能反而會更多。
4 重量級鎖
4.1 Monitor
重量級鎖是依賴對象內部的monitor(監視器/管程)來實現的 ,而monitor 又依賴於操作系統底層的Mutex Lock
(互斥鎖)實現,這也就是為什么說重量級鎖比較“重”的原因了,操作系統在實現線程之間的切換時,需要從用戶態切換到內核態,成本非常高。在學習重量級鎖的工作原理前,首先需要了解一下monitor中的核心概念:
owner
:標識擁有該monitor
的線程,初始時和鎖被釋放后都為nullcxq (ConnectionList)
:競爭隊列,所有競爭鎖的線程都會首先被放入這個隊列中EntryList
:候選者列表,當owner
解鎖時會將cxq
隊列中的線程移動到該隊列中OnDeck
:在將線程從cxq
移動到EntryList
時,會指定某個線程為Ready狀態(即OnDeck
),表明它可以競爭鎖,如果競爭成功那么稱為owner
線程,如果失敗則放回EntryList
中WaitSet
:因為調用wait()
或wait(time)
方法而被阻塞的線程會被放在該隊列中count
:monitor的計數器,數值加1表示當前對象的鎖被一個線程獲取,線程釋放monitor對象時減1recursions
:線程重入次數
用圖來表示線程競爭的的過程:
當線程調用wait()
方法,將釋放當前持有的monitor,將owner
置為null,進入WaitSet
集合中等待被喚醒。當有線程調用notify()
或notifyAll()
方法時,也會釋放持有的monitor,並喚醒WaitSet
的線程重新參與monitor的競爭。
4.2 重量級鎖原理
當升級為重量級鎖的情況下,鎖對象的mark word
中的指針不再指向線程棧中的lock record
,而是指向堆中與鎖對象關聯的monitor對象。當多個線程同時訪問同步代碼時,這些線程會先嘗試獲取當前鎖對象對應的monitor的所有權:
- 獲取成功,判斷當前線程是不是重入,如果是重入那么
recursions+1
- 獲取失敗,當前線程會被阻塞,等待其他線程解鎖后被喚醒,再次競爭鎖對象
在重量級鎖的情況下,加解鎖的過程涉及到操作系統的Mutex Lock
進行互斥操作,線程間的調度和線程的狀態變更過程需要在用戶態和核心態之間進行切換,會導致消耗大量的cpu資源,導致性能降低。
總結
在jdk1.6中,引入了偏向鎖和輕量級鎖,並使用鎖升級機制對synchronized
進行了充分的優化。其實除鎖升級外,還使用了鎖消除、鎖粗化等優化手段,所以對它的認識要脫離“重量級”這一概念,不要再單純的認為它的性能差了。在某些場景下,synchronized
的性能甚至已經超過了Lock
同步鎖。
盡管java對synchronized
做了這些優化,但是在使用過程中,我們還是要盡量減少鎖的競爭,通過減小加鎖粒度和減少同步代碼的執行時間,來降低鎖競爭,盡量使鎖維持在偏向鎖和輕量級鎖的級別,避免升級為重量級鎖,造成性能的損耗。
最后不得不再提一句,在java15中已經默認禁用了偏向鎖,並棄用所有相關的命令行選項,雖然說不確定未來的LTS版本會怎樣改動,但是了解一下偏向鎖的基礎也沒什么不好的,畢竟你發任你發,我用java8~
如果文章對您有所幫助,歡迎關注公眾號 碼農參上
