synchronized原理-位元組碼分析、物件記憶體結構、鎖升級過程、Monitor

lz-zxy發表於2024-05-10

本文分析的問題:

  1. synchronized 位元組碼檔案分析之 monitorenter、monitorexit 指令

  2. 為什麼任何一個Java物件都可以成為一把鎖?

  3. 物件的記憶體結構

  4. 鎖升級過程

  5. Monitor 是什麼、原始碼檢視

synchronized是基於monitor實現的,執行緒在獲取鎖的時候,實際上是獲取了一個 monitor 物件,然後用它來進行加鎖的。

位元組碼分析

synchronized的3種使用方式

  1. 作用於例項方法,對物件加鎖

  2. 作用於靜態方法,對類加鎖

  3. 作用於程式碼塊,對 () 裡的物件加鎖

先說結論:透過 monitorentermonitorexit 指令來做

synchronized 關鍵字底層原理屬於 JVM 層面的東西。

程式碼塊

monitorentermonitorexit 指令來做

程式碼:

public void m1(){
    synchronized (this){

    }
}

編譯後使用 javap -v xxx.class 命令檢視:

  • 一般情況下就是 1 個 monitorenter 對應 2 個 monitorexit

    • 正常處理正常釋放時有一個 monitorexit,考慮到有異常時,鎖應該也要被釋放,所以也會有一個 monitorexit
  • 極端情況下:如果方法裡丟擲異常了,就只會有一個 monitorexit 指令

包含一個 monitorenter 指令以及兩個 monitorexit 指令,這是為了保證鎖在同步程式碼塊正常執行以及出現異常

的這兩種情況下都能被正確釋放

異常情況下:只有一個 monitorexit 指令

例項方法

ACC_SYNCHRONIZED 這個標識來做

程式碼:

public synchronized void m1(){

}

結果:在方法下沒有那兩個指令,取而代之的是 ACC_SYNCHRONIZED 這個標識。該標識指明瞭該方法是一個同步方法

JVM透過該 ACC_SYNCHRONIZED 標識來辨別一個方法是否是一個同步方法,從而執行相應的同步呼叫

靜態方法

ACC_SYNCHRONIZED這個標識來做

程式碼:

public static synchronized void m1(){
    
}

結果:可以看到還是透過 ACC_SYNCHRONIZED 這個標識來做。

ACC_STATIC 這個標識是用來區分例項方法和靜態方法的,就算不加 synchronized 也會有。

物件記憶體結構

為了可以更加直觀的看到物件結構,我們可以藉助 openjdk 提供的 JOL 工具進行分析。

JOL分析工具

JOL(Java 物件佈局)用於分析物件在JVM的大小和分佈

官網:https://openjdk.org/projects/code-tools/jol/

  <!-- 
      https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli
      定位:分析物件在JVM的大小和分佈 
  -->
  <dependency>
      <groupId>org.openjdk.jol</groupId>
      <artifactId>jol-cli</artifactId>
      <version>0.14</version>
  </dependency>

物件記憶體結構

可以看到總共分為三部分:物件頭、例項資料、對齊填充

物件頭

在64位系統中,Mark Word 佔了 8 個位元組,型別指標佔了 8 個位元組,一共是 16 個位元組

Mark Word

MarkWord 中存了一些資訊:hashCode的值、gc相關、鎖相關

具體結構

  • unused:未使用的位置

  • hashcode:hashcode 的值

  • age:GC年齡(4位最大隻能表示15)

  • biased_lock:是否是偏向鎖(0不是;1是)

  • thread:執行緒id

  • epoch:偏向時間戳

  • ptr_to_lock_record:儲存指向棧幀中的鎖記錄(LockRecord)的指標

  • ptr_to_heavyweight_monitor:指向重量級鎖的指標(也就是指向 ObjectMonitor 的指標)

  • 鎖標誌位:01 代表有鎖(透過偏向鎖標誌位 1/0 表示是否是偏向鎖);00 代表輕量級鎖;10 代表重量級鎖

markOop.hpp 中的 C++ 原始碼檢視:

//  32 bits:  32位的
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:  64位的
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

原始碼地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/objectMonitor.hpp

Class Pointer

物件指向它的類後設資料的指標,虛擬機器透過這個指標來確定這個物件哪個類的例項

我們怎麼知道建立的這個物件是什麼型別的,就透過這個指標指向方法區的類元資訊(kclass pointer)。

可能會進行指標壓縮。

例項資料

存放類的屬性(Field)資料資訊,包括父類的屬性資訊

對齊填充

虛擬機器要求物件起始地址必須是 8 位元組的整數倍,所以填充資料不是必須存在的,僅僅是為了位元組對齊,這部分記憶體按 8 位元組補充對齊

比如:

  • 只有一個類的話,類裡面是空的那就是 16 位元組 = MarkWord + 型別指標(不考慮指標壓縮的情況下)。這時不需要對齊填充來對齊,因為 16 位元組本身就是 8 的整數倍。
  • 但假如此時有了屬性,int = 4位元組,boolean = 1 位元組,加起來 = 16+4+1 = 21 位元組,這時就不是 8 的整數 倍的,這時就需要對齊填充來補齊了
class Person {
    int age;
    boolean isFlag;
}

使用JOL工具證明

簡單使用:

public static void main(String[] args) {
    // 獲取JVM詳細資訊
    System.out.println(VM.current().details());
    // 物件頭大小 開啟指標壓縮是12=MarkWord(8)+ClassPointer(4),沒開啟是16=MarkWord(8)+ClassPointer(8)
    System.out.println(VM.current().objectHeaderSize());
    // 對齊填充     為什麼都是8的倍數?
    System.out.println(VM.current().objectAlignment());
}

驗證物件記憶體結構

程式碼:

public static void main(String[] args) {
    Object obj = new Object();
    System.out.println(obj + " 十六進位制雜湊:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

結果:

  • Object物件,總共佔16位元組
  • 物件頭佔 12 個位元組,其中:mark-word 佔 8 位元組、Klass Point 佔 4 位元組
  • 最後 4 位元組,用於資料填充對齊
指標壓縮

不是說型別指標是8位元組嗎,到這裡怎麼變為4位元組了?

那是因為被 指標壓縮 了(開啟後效能會更好)

命令檢視: java -XX:+PrintCommandLineFlags -version

這個引數就是壓縮指標的引數:-XX:+UseCompressedClassPointers +代表開啟,- 代表關閉

關閉後,執行後,再次檢視:-XX:-UseCompressedClassPointers

這次就沒有對齊填充了。

鎖升級過程

無鎖 ---> 偏向鎖 ---> 輕量級鎖 -> 重量級鎖

出現的背景

之前 synchronized 是重量級鎖,依靠 Monitor 機制實現。

Monitor 是依賴於底層的作業系統的 Mutex Lock(互斥鎖)來實現的執行緒同步。這種機制需要使用者態和核心態之

間來切換。但Mutex是系統方法,由於許可權的關係,應用程式呼叫系統方法時需要切換到核心態來執行。

所以為了是減少使用者態和核心態之間的切換。因為這兩種狀態之間的切換的開銷比較高。

先來一個 狗 的類,用來建立物件

class Dog {

}

無鎖

建立一個物件,沒有一個執行緒來佔有它,這時就是無鎖

無鎖時 Mark Word 的結構:

| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal	      |

測試程式碼:

public static void main(String[] args) {
    Dog dog = new Dog();
    dog.hashCode();
    System.out.println(ClassLayout.parseInstance(dog).toPrintable());

    System.out.println("======================================================");
    System.out.println(dog);
    System.out.println("十六進位制:" + Integer.toHexString(dog.hashCode()));
    System.out.println("二進位制:" + Integer.toBinaryString(dog.hashCode()));
}

對照上面的結構來看:從後往前按照 Mark Word 結構來看。每8bit從前往後看。

偏向鎖

當第一個執行緒來獲取到它時(沒有競爭/一次就競爭成功),這時它就是偏向鎖,只偏向於這一個執行緒(CAS)

偏向鎖、輕量級鎖的 Mark Word 的結構:

|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |      Biased        |  
|--------------------------------------------------------------------|--------------------|
|             ptr_to_lock_record:62                          | 00    | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|

測試程式碼:

public static void main(String[] args) {
    Dog dog = new Dog();

    synchronized (dog){
        System.out.println(ClassLayout.parseInstance(dog).toPrintable());
    }
}

結果:

這裡的鎖為什麼直接是輕量級鎖呢?

因為偏向鎖預設是延遲開啟的,所以進入了輕量級鎖狀態。

使用 java -XX:+PrintFlagsInitial | grep BiasedLock 命令在 Git Bash 下執行:

所以需要新增引數 -XX:BiasedLockingStartupDelay=0,讓其在程式啟動時立刻啟動。或者讓程式睡了 5 秒後再執行。

新增引數/睡5秒後執行,發現偏向鎖出現了:

1 代表是偏向鎖,01 代表有鎖

輕量級鎖

其實就是自旋鎖(底層是CAS)

其它執行緒來競爭鎖,並且競爭失敗,會到一個全域性安全點來把這個鎖升級為輕量級鎖

輕量級鎖的 Mark Word 結構:

|             ptr_to_lock_record:62                          | 00    | Lightweight Locked |

測試程式碼:透過呼叫 hashCode() 來得到輕量級鎖(因為偏向鎖裡沒有地方儲存 hashCode 的值)

public static void main(String[] args) throws InterruptedException {
    Dog dog = new Dog();
    dog.hashCode();
    synchronized (dog) {
        System.out.println(ClassLayout.parseInstance(dog).toPrintable());
    }
}

結果:

自旋次數

JDK6之前

  • 預設啟用,預設情況下自旋的次數是10次,或者自旋執行緒數超過CPU核數一半

JDK6之後 自適應自旋鎖

  • 執行緒如果自旋成功了,那下次自旋的最大次數會增加,因為JVM認為既然上次成功了,那麼這一次也很大機率會成功。

  • 反之,如果很少會自旋成功,那麼下次會減少自旋的次數其至不自旋,避免CPU空轉。

  • 自適應意味著自旋的次數不是固定不變的,而是根據:同一個鎖上一次自旋的時間。擁有鎖執行緒的狀態來決定。

重量級鎖

自旋到一定次數後,還沒獲取到鎖,會將其升級為重量級鎖。那就是阻塞了,使用者態和核心態之間的切換

基於 Monitor 的實現,monitorenter 和 monitorexit 指令來實現

重量級鎖的 Mark Word 的結構:

|       	  ptr_to_heavyweight_monitor:62                    | 10	   | Heavyweight Locked |

測試程式碼:


結果:

鎖升級後,hash值去哪?

  • 無鎖:就存在 Mark Word 裡

  • 偏向鎖:沒有地方存 hash 值了

    • 如果在 synchronized 前呼叫了 hashCOde() ,此時偏向鎖會升級為輕量級鎖

    • 如果在 synchronized 中呼叫了 hashCode() ,此時偏向鎖會升級為重量級鎖

  • 輕量級鎖:棧幀中的鎖記錄(Lock Record)裡

  • 重量級鎖:Mark Word 儲存重量級鎖的指標,底層實現 ObjectMonitor 類裡有欄位記錄加鎖狀態的 Mark Word 資訊

必須說的 monitor

在 HotSpot 虛擬機器中,monitor 是由 C++ 中的 ObjectMonitor 實現。

什麼是 Monitor

Monitor 是管程,是同步監視器,是一種同步機制。為了保證資料的安全性

Monitor 有什麼用

提供了一種互斥機制。限制同一時刻只能有一個執行緒進入 Monitor 的臨界區,保護資料安全。

用於保護共享資料,避免多執行緒併發訪問導致資料不一致。

synchronized 的重量級鎖就是用 Monitor 來實現的

Monitor 的原始碼分析

執行緒在獲取鎖的時候,實際上是獲取了一個 monitor 物件

每個 Java 物件都自帶了一個 monitor 物件,所以每個 Java 物件都可以成為鎖。

原始碼:Java 中的每個物件都繼承自 Object 類,而每個 Java 物件在 JVM 內部都有一個 C++ 物件 oopDesc 與其對應,而對應的 oopDesc 內有一個屬性是 markOopDesc 物件(這個物件就是 Java 裡的 Mark Word),這個 markOopDesc 內有一個 monitor() 方法返回了 ObjectMonitor 物件(hotspot中,這個物件實現了 monitor )

oopDesc

這個類是每個 Java 物件的基類。每個 Java 物件在虛擬機器內部都會繼承這個 C++ 物件。

原始碼地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.hpp

markOopDesc

這個類也是 oopDesc 的子類。這個類就是 Mark Word 物件頭。

裡面有一個 monitor() 方法返回了 ObjectMonitor 物件。

monitor():

原始碼地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markOop.hpp

ObjectMonitor

在 hotspot 虛擬機器中,ObjectMonitor 是 Monitor 的實現。

原始碼地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/objectMonitor.hpp

為什麼任何一個Java物件都可以成為一把鎖

synchronized是基於monitor實現的,執行緒在獲取鎖的時候,實際上是獲取了一個 monitor 物件。而Java 中的每個物件都繼承自 Object 類,虛擬機器原始碼中 oopDesc 是每個 Java 物件的頂層父類,這個父類內有個屬性是 markOopDesc 物件,也就是物件頭。這個物件頭是儲存鎖的地方,裡面有一個 ObjectMonitor 。而 monitor 的實現就是 ObjectMonitor 物件監視器。

每一個被鎖住的物件又都會和 Monitor 關聯起來,透過物件頭裡的指標。

參考資料

大部分參考:

  1. https://www.bilibili.com/video/BV1ar4y1x727/

Mark Word 程式碼塊裡的結構參考影片的筆記(圖為自畫):

  1. https://www.bilibili.com/video/BV16J411h7Rd

少部分參考:

  1. https://www.cnblogs.com/wuzhenzhao/p/10250801.html

  2. https://segmentfault.com/a/1190000037645482

  3. https://www.cnblogs.com/mic112/p/16388456.html

oopDesc、markOopDesc 和 Java 物件之間的關係參考:

  1. https://www.cnblogs.com/mazhimazhi/p/13289686.html

  2. https://blog.csdn.net/qq_31865983/article/details/99173570

為什麼每一個Java物件都可以成為一把鎖?

  • https://blog.csdn.net/Leon_Jinhai_Sun/article/details/111416247

openjdk 原始碼位置參考:裡面也有 ObjectMonitor 原理

  1. https://www.cnblogs.com/webor2006/p/11442551.html

相關文章