本文分析的問題:
-
synchronized 位元組碼檔案分析之 monitorenter、monitorexit 指令
-
為什麼任何一個Java物件都可以成為一把鎖?
-
物件的記憶體結構
-
鎖升級過程
-
Monitor 是什麼、原始碼檢視
synchronized是基於monitor實現的,執行緒在獲取鎖的時候,實際上是獲取了一個 monitor 物件,然後用它來進行加鎖的。
位元組碼分析
synchronized的3種使用方式
-
作用於例項方法,對物件加鎖
-
作用於靜態方法,對類加鎖
-
作用於程式碼塊,對 () 裡的物件加鎖
先說結論:透過 monitorenter、monitorexit 指令來做
synchronized 關鍵字底層原理屬於 JVM 層面的東西。
程式碼塊
monitorenter、monitorexit 指令來做
程式碼:
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 關聯起來,透過物件頭裡的指標。
參考資料
大部分參考:
- https://www.bilibili.com/video/BV1ar4y1x727/
Mark Word 程式碼塊裡的結構參考影片的筆記(圖為自畫):
- https://www.bilibili.com/video/BV16J411h7Rd
少部分參考:
-
https://www.cnblogs.com/wuzhenzhao/p/10250801.html
-
https://segmentfault.com/a/1190000037645482
-
https://www.cnblogs.com/mic112/p/16388456.html
oopDesc、markOopDesc 和 Java 物件之間的關係參考:
-
https://www.cnblogs.com/mazhimazhi/p/13289686.html
-
https://blog.csdn.net/qq_31865983/article/details/99173570
為什麼每一個Java物件都可以成為一把鎖?
- https://blog.csdn.net/Leon_Jinhai_Sun/article/details/111416247
openjdk 原始碼位置參考:裡面也有 ObjectMonitor 原理
- https://www.cnblogs.com/webor2006/p/11442551.html