我們都知道在Java編程中多執行緒的同步使用synchronized關鍵字來標識,那么這個關鍵字在JVM底層到底是如何實作的呢,
我們先來思考一下如果我們自己實作的一個鎖該怎么做呢:
- 首先肯定要有個標記記錄物件是否已經上鎖,執行同步代碼之前判斷這個標志,如果物件已經上鎖執行緒就阻塞等待鎖的釋放,
- 其次要有一個結構體來維護這些等待中的執行緒,鎖釋放后來遍歷這些執行緒讓他們去搶鎖,
第一點Java使用物件頭來維護物件的上鎖狀態,第二點Java使用ObjectMonitor來維護等待中的執行緒及持有鎖的執行緒****,
物件頭
物件頭中記錄了鎖的狀態,Java中現在有三種鎖狀態偏向鎖、輕量級鎖、重量級鎖,其中重量級鎖就是用來和ObjectMonitor進行關聯的,最開始Java只有重量級鎖,但是重量級鎖在有鎖競爭的情況下需要阻塞執行緒,同時需要對ObjectMonitor的資料結構進行操作,比較耗費性能,后來Java為了提高鎖的性能,引入了偏向鎖和輕量級鎖,這里需要注意偏向鎖和輕量級鎖與ObjectMonitor沒有任何關聯,后面會做詳細介紹,

ObjectMonitor
Java會為每一個物件和物件的Class物件分配一個ObjectMonitor物件,他是一個C++結構體,ObjectMonitor用來維護當前持有鎖的執行緒,阻塞等待鎖釋放的執行緒鏈表,呼叫了wait阻塞等待notify的執行緒鏈表,這里不做過多描述,具體的維護邏輯可以搜索其他博客,
//結構體如下
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; //執行緒的重入次數
_object = NULL;
_owner = NULL; //標識擁有該monitor的執行緒
_WaitSet = NULL; //等待執行緒組成的雙向回圈鏈表,_WaitSet是第一個節點
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多執行緒競爭鎖進入時的單向鏈表
FreeNext = NULL ;
_EntryList = NULL ; //_owner從該雙向回圈鏈表中喚醒執行緒結點,_EntryList是第一個節點
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
Java中的鎖的邏輯
下面來描述一下Java中synchronized關鍵字上鎖的的邏輯,這里的細節有很多,我們只描述大概的程序,
同時我們還要注意物件頭中存盤的hashcode的變化,物件剛開始創建的時候物件頭中的hashcode還未生成,只有程式呼叫hashcode方法時候才會將hashcode存盤到物件頭中,這樣可以保證不管用什么hashcode演算法,同一個物件的hashcode在他的生命周期中都不會改變,
這里強調一下,如果物件處在重量級鎖的時候,它就無法再次進入到輕量級鎖狀態,如果物件處在輕量級鎖,它就無法進入到偏向鎖的狀態,只能等待物件進入無鎖狀態之后,再次進行判斷,
偏向鎖
Java程式執行到synchronized代碼處,偏向鎖的邏輯如下:
- 檢查物件頭中的hashcode是否生成,生成過hashcode的物件無法進入偏向鎖(這是因為偏向鎖設計時,沒有地方用來備份hashcode),
- 檢查物件頭中的鎖標志位是否是01,如果不是說明物件處在其他鎖的狀態,則執行其他鎖的邏輯,
- 如果偏向鎖的執行緒ID是自己的執行緒ID則直接執行同步代碼塊,說明之前此執行緒已經獲取到了鎖,
- 如果偏向鎖ID不是自己的執行緒ID,通過CAS演算法嘗試偏向鎖的執行緒ID,如果成功了就獲取到鎖,直接執行同步代碼,如果失敗的話說明有執行緒獲取了偏向鎖,此時執行緒會請求那個持有鎖的執行緒釋放鎖,
- 如果持有鎖的執行緒還在同步代碼中,則無法釋放鎖,這個時候鎖會膨脹為輕量級鎖,膨脹的的時候會修改物件頭為輕量級鎖,
- 同步代碼執行完成后,執行緒并不會重置物件頭的資料,即不會釋放鎖,以便下次再次執行的時候可以直接進入同步代碼,
我們可以看到,一段同步代碼如果一直是由一個執行緒執行的時候,這個執行緒只需要做2和3中簡單的判斷就可繼續往下執行同步代碼,最初的性能消耗只是第一次上鎖的時候需要修改物件頭,這就是偏向鎖的作用,可以大幅度提升synchronized鎖的效率,但是由于底層為了實作偏向鎖的邏輯過于復雜,在JDK15之后已經默認關閉偏向鎖了,在現代的程式中同一個執行緒一直持有一個鎖的情況已經不多了,具體的鎖的切換流程可以看這篇博客《深入理解偏向鎖》,
輕量級鎖
Java程式執行到synchronized代碼處,輕量級鎖的邏輯如下:
- 檢查物件頭鎖標志位是否是01,將物件頭復制到堆疊中進行備份
- 嘗試使用CAS演算法修改物件頭(這里為了防止其他執行緒同時和當前執行緒都去修改物件頭搶鎖),這時候物件頭指向的是當前的堆疊地址,如果修改成功則獲取到鎖執行同步代碼,
- 如果修改失敗,說明其他執行緒優先獲取到了鎖,當前執行緒自旋(回圈)獲取鎖,超過一定的次數后如果還是無法獲取到鎖,則鎖膨脹為重量級鎖,膨脹的時候會修改物件頭和維護ObjectMonitor的資料結構,
- 同步代碼執行完成之后,CAS把備份的物件頭寫回到物件頭中,如果修改失敗說明鎖已經膨脹為重量級鎖了,則執行重量級鎖的鎖釋放邏輯,
我們可以看到,輕量級鎖如果鎖的競爭比較低(執行緒比較少,同步程式執行速度較快)的情況下,執行緒可以不需要進入到阻塞狀態,通過自旋等待鎖的釋放,同時輕量級鎖也不需要維護ObjectMonitor的資料,進一步提升了性能,
重量級鎖
由于重量級鎖需要維護ObjectMonitor,所以性能不如輕量級鎖,輕量級鎖只需要修改物件頭即可,重量級鎖不但需要修改物件頭還要維護ObjectMonitor的資料結構,
Java程式執行到synchronized代碼處,重量級鎖的邏輯如下:
- 通過物件頭中的ObjectMonitor的參考地址,找到ObjectMonitor物件,此時ObjectMonitor中存盤了無鎖狀態下物件頭的備份,
- 判斷_owner是否是當前執行緒,如果不是則說明鎖被其他執行緒持有,則阻塞當前執行緒(阻塞的邏輯應該和LockSupport.park()的邏輯是一樣的),并把當前執行緒加入到阻塞鏈表中,
- 如果_owner是當前執行緒,則_recursions加1記錄重入次數(比如遞回的時候會重復獲取鎖),并執行同步代碼,
- 同步代碼執行完成后,_recursions減1(因為重量級鎖是可重入鎖,退出的時候可能退出多次),喚醒阻塞鏈表中的執行緒去搶鎖,如果沒有執行緒等待則修改物件頭為無鎖狀態,把備份的物件頭資料寫回到物件頭,這里注意,持有鎖的時候如果呼叫hascode方法,修改應該也是備份的物件頭中的資料,
我們可以看到,重量級鎖由于需要維護ObjectMonitor所以性能不高,如果物件能夠一直處在輕量級鎖的狀態下性能會有大幅提升,
同時需要注意,當你在同步代碼中呼叫wait的時候,因為需要維護wait執行緒佇列,輕量級鎖需要膨脹為重量級鎖,當你呼叫hashcode方法的時候,偏向鎖會膨脹為輕量級鎖,具體的鎖的切換流程可以看這篇博客《深入理解偏向鎖》,
不過這里我有一個疑問,就是ObjectMonitor是如何和物件做關聯的,即重量級鎖修改物件頭的時候,物件對應的ObjectMonitor物件的記憶體地址是怎么找到的,難道底層維護了一個ObjectMonitor的Map?我查了些資料和書籍都沒說明,
總結
我們可以看到當遇到synchronized代碼塊的時候,物件頭可能處于偏向鎖、輕量級鎖、重量級鎖三種狀態,這三種鎖各有各的特點,
| 鎖 | 優勢 | 劣勢 | 觸發場景 |
|---|---|---|---|
| 偏向鎖 | 只需要修改一次物件頭 | 不支持呼叫hashcode方法,如果執行緒存在競爭,需要額外撤銷鎖,底層代碼維護困難 | 單個執行緒長期重復持有鎖 |
| 輕量級鎖 | 自旋無需阻塞執行緒,減少執行緒背景關系切換 | 如果始侄訓取不到鎖,自旋會消耗cpu資源(感覺也不算缺點,高并發下物件會一直處在重量級鎖的狀態下,執行重量級鎖的邏輯即可) | 少量執行緒交替持有鎖 |
| 重量級鎖 | 可以執行wait等操作 | 執行緒會阻塞,同時需要維護ObjectMonitor性能低 | 大量執行緒同時爭搶鎖 |
畢竟大量執行緒同時爭搶鎖的情況不多,如果物件一直處在輕量級鎖的狀態下,鎖的性能已經非常高,與JDK中的Lock的性能已經相差無幾,因為Lock的底層也是使用CAS演算法來維護鎖的狀態,
本文參考書籍:
- 《Java并發編程的藝術》這本書值得一讀,底層原理講的比較深入,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/538435.html
標籤:其他
上一篇:每日演算法之重建二叉樹
