synchronized鎖升級過程
更過博文請關注:https://blog.bigcoder.cn
JDK 1.6后鎖的狀態總共有四種,級別由低到高依次為:無鎖、偏向鎖、輕量級鎖、重量級鎖,這四種鎖狀態分別代表什么,為什么會有鎖升級?
其實在 JDK 1.6之前,synchronized 還是一個重量級鎖,底層使用操作系統的 Mutex Lock(互斥鎖)實現,而操作系統實現線程之間的切換需要從用戶態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什么重量級鎖效率低的原因。
但是在JDK 1.6后,JVM為了提高鎖的獲取與釋放效率對(synchronized )進行了優化,引入了 偏向鎖 和 輕量級鎖 ,從此以后鎖的狀態就有了四種(無鎖、偏向鎖、輕量級鎖、重量級鎖),并且四種狀態會隨著競爭的情況逐漸升級,而且是不可逆的過程,也就是說只能進行鎖升級(從低級別到高級別),不能鎖降級(高級別到低級別),這種設計目的是為了提高獲得鎖和釋放鎖的效率。
一. 對象的內存布局
要弄清楚加鎖過程到底發生了什么需要看一下對象創建之后再內存中的布局是個什么樣的?
一個對象在new出來之后在內存中主要分為4個部分:
-
markword:默認存儲對象的HashCode,分代年齡和鎖標志位信息。這些信息都是與對象自身定義無關的數據,所以Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲盡量多的數據。它會根據對象的狀態復用自己的存儲空間,也就是說在運行期間Mark Word里存儲的數據結構會隨著鎖標志位的變化而變化。
-
klass pointer:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
-
instance data:記錄了對象里面的變量數據。
-
padding:作為對齊使用,對象在64位服務器版本中,規定對象內存必須要能被8字節整除,如果不能整除,那么就靠對齊來補。舉個例子:new出了一個對象,內存只占用18字節,但是規定要能被8整除,所以padding=6。

知道了這4個部分之后,我們來驗證一下底層。借助于第三方包 JOL( Java Object Layout)內存布局去看看。首先我們先引入JOL依賴:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
編寫以下代碼,查看Object內存布局:
public class JOLDemo {
private static Object o;
public static void main(String[] args) {
o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

synchronized不論是修飾方法還是代碼塊,都是通過持有修飾對象的鎖來實現同步,那么Synchronized鎖對象是存在哪里的呢?答案是存在鎖對象的對象頭的markword中。那么markword在對象頭中到底長什么樣?
前面我們說過,markword中的數據結構會在運行時期隨著鎖標識位的變化而發生變化的,而markword分為下列幾種狀態:

-
無鎖: 對象頭開辟 31bit 的空間用來存儲對象的 hashcode ,4 bit 用于存放對象分代年齡,1 bit 用來存放是否偏向鎖的標識位,2 bit 用來存放鎖標識位為01
-
偏向鎖:在偏向鎖中劃分更細,其中54 bit 用來存放線程ID,2 bit 用來存放 Epoch,4 bit 存放對象分代年齡,1 bit 存放是否偏向鎖標識( 0表示無鎖,1表示偏向鎖),鎖的標識位還是01
-
輕量級鎖:在輕量級鎖中直接開辟 62 bit 的空間存放指向棧中鎖記錄的指針,2 bit 存放鎖的標志位,其標志位為00
-
重量級鎖: 在重量級鎖中和輕量級鎖一樣,62 bit 的空間用來存放指向重量級鎖的指針,2 bit 存放鎖的標識位,其標志位為11
-
GC標記: 開辟62 bit 的內存空間卻沒有占用,2 bit 空間存放鎖標志位為11。
二. 鎖的分類
2.1 無鎖
無鎖是指沒有對資源進行鎖定,所有的線程都能訪問并修改同一個資源,但同時只有一個線程能修改成功。
無鎖的特點是修改操作會在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有沖突就修改成功并退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。
2.2 偏向鎖
大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得。偏向鎖的目的是在某個線程獲得鎖之后,消除這個線程鎖重入(CAS)的開銷,看起來讓這個線程得到了偏護。另外,JVM對那種會有多線程加鎖,但不存在鎖競爭的情況也做了優化,聽起來比較拗口,但在現實應用中確實是可能出現這種情況,因為線程之前除了互斥之外也可能發生同步關系,被同步的兩個線程(一前一后)對共享對象鎖的競爭很可能是沒有沖突的。對這種情況,JVM用一個epoch表示一個偏向鎖的時間戳(真實地生成一個時間戳代價還是蠻大的,因此這里應當理解為一種類似時間戳的identifier)
2.3.1 偏向鎖的獲取
當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而只需簡單的測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖,如果測試成功,表示線程已經獲得了鎖,如果測試失敗,則需要再測試下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖),如果沒有設置,則使用CAS競爭鎖,如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
2.3.2 偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著,如果線程不處于活動狀態,則將對象頭設置成無鎖狀態,如果線程仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word,要么重新偏向于其他線程,要么恢復到無鎖或者標記對象不適合作為偏向鎖,最后喚醒暫停的線程。
2.4 輕量級鎖(CAS自旋實現)
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,則自旋獲取鎖,當自旋獲取鎖仍然失敗時,表示存在其他線程競爭鎖(兩條或兩條以上的線程競爭同一個鎖),則輕量級鎖會膨脹成重量級鎖。
輕量級解鎖時,會使用原子的CAS操作來將Displaced Mark Word替換回到對象頭,如果成功,則表示同步過程已完成。如果失敗,表示有其他線程嘗試過獲取該鎖,則要在釋放鎖的同時喚醒被掛起的線程。
2.5 重量級鎖
重量級鎖對應的鎖標志位是10,存儲了指向重量級監視器鎖的指針,在HotSpot中,對象的監視器(monitor)鎖對象由ObjectMonitor對象實現(C++),其跟同步相關的數據結構如下:
ObjectMonitor() {
_count = 0; //用來記錄該對象被線程獲取鎖的次數
_waiters = 0;
_recursions = 0; //鎖的重入次數
_owner = NULL; //指向持有ObjectMonitor對象的線程
_WaitSet = NULL; //處于wait狀態的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_EntryList = NULL ; //處于等待鎖block狀態的線程,會被加入到該列表
}
光看這些數據結構對監視器鎖的工作機制還是一頭霧水,那么我們首先看一下線程在獲取鎖的幾個狀態的轉換:

線程的生命周期存在5個狀態,start、running、waiting、blocking和dead
對于一個synchronized修飾的方法(代碼塊)來說:
- 當多個線程同時訪問該方法,那么這些線程會先被放進
_EntryList隊列,此時線程處于blocking狀態 - 當一個線程獲取到了實例對象的監視器(monitor)鎖,那么就可以進入running狀態,執行方法,此時,ObjectMonitor對象的
_owner指向當前線程,_count加1表示當前對象鎖被一個線程獲取。 - 當running狀態的線程調用wait()方法,那么當前線程釋放monitor對象,進入waiting狀態,ObjectMonitor對象的
_owner變為null,_count減1,同時線程進入_WaitSet隊列,直到有線程調用notify()方法喚醒該線程,則該線程會進入_EntryList隊列中
三. 鎖的升級路線

3.1 升級為偏向鎖
從鎖升級的圖中能看到兩個路線,“偏向鎖未啟動–>普通對象”和“偏向鎖已啟動–>匿名偏向對象”什么意思呢?原來我們是可以配置偏向鎖未啟動和偏向鎖已啟動的。 操作系統有個參數-XX:BiasedLockingStartupDelay=0從字面意思,偏向鎖的啟動延遲。JVM默認情況下會在啟動后4s開啟偏向鎖。
我們編寫下列代碼來驗證“無鎖->偏向鎖”的轉化過程:
public class JOLDemo {
private static Object o;
public static void main(String[] args) {
o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}

默認情況下偏向鎖會在程序啟動后4s后才會開啟(如有必要可以使用JVM參數來關閉延遲-XX:BiasedLockingStartupDelay = 0)所以的上述代碼致使之中都沒有開啟偏向鎖,所以最終效果就是“無鎖->輕量級鎖”。
前文我們說過markword由64位(8個字節)組成,現實生活中我們通常把高位數字放在前面,在計算機中通常會把低位字節放在前面,這也就是為什么打印出來的內存布局中第一行的第一個字節最后兩個bit是鎖標志位,而不是第二行最后一個字節最后兩個bit是鎖標志位的原因。
我們可以通過Thread.sleep(5000)來使得虛擬機開啟偏向鎖:
public class JOLDemo {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}

3.2 升級為重量級鎖
這是一個相當復雜的過程,在JDK1.6之前,有兩種情況,某個線程的自旋次數超過10次(JVM調優可調),等待的自旋的線程數(JVM調優)超過了CPU核數的二分之一。滿足這兩個條件的任意一個,操作系統會把這些線程丟到等待隊列,膨脹為重量級鎖。在JDK1.6之后,出現了自適應自旋。什么意思呢?JDK根據運行的情況和每個線程運行的情況決定要不要升級。
public class JOLDemo {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(() -> {
synchronized (o) {
//由于是第一個線程獲取鎖,此處應該是偏向鎖
System.out.println(ClassLayout.parseInstance(o).toPrintable());
try {
//sleep 5s不釋放鎖
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(1000);
new Thread(() -> {
synchronized (o) {
//由于上一個線程長時間持有鎖,CAS超過閾值后膨脹為重量級鎖
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
}
}

本文參考至:
Java并發——Synchronized關鍵字和鎖升級,詳細分析偏向鎖和輕量級鎖的升級_tongdanping的博客-CSDN博客

浙公網安備 33010602011771號