Java鎖的邏輯(結合物件頭和ObjectMonitor)

大興神發表於2022-11-26

我們都知道在Java程式設計中多執行緒的同步使用synchronized關鍵字來標識,那麼這個關鍵字在JVM底層到底是如何實現的呢。
我們先來思考一下如果我們自己實現的一個鎖該怎麼做呢:

  1. 首先肯定要有個標記記錄物件是否已經上鎖,執行同步程式碼之前判斷這個標誌,如果物件已經上鎖執行緒就阻塞等待鎖的釋放。
  2. 其次要有一個結構體來維護這些等待中的執行緒,鎖釋放後來遍歷這些執行緒讓他們去搶鎖。

第一點Java使用物件頭來維護物件的上鎖狀態,第二點Java使用ObjectMonitor來維護等待中的執行緒及持有鎖的執行緒****。

物件頭

物件頭中記錄了鎖的狀態,Java中現在有三種鎖狀態偏向鎖、輕量級鎖、重量級鎖。其中重量級鎖就是用來和ObjectMonitor進行關聯的,最開始Java只有重量級鎖,但是重量級鎖在有鎖競爭的情況下需要阻塞執行緒,同時需要對ObjectMonitor的資料結構進行操作,比較耗費效能。後來Java為了提高鎖的效能,引入了偏向鎖和輕量級鎖。這裡需要注意偏向鎖和輕量級鎖與ObjectMonitor沒有任何關聯,後面會做詳細介紹。
image

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程式碼處,偏向鎖的邏輯如下:

  1. 檢查物件頭中的hashcode是否生成,生成過hashcode的物件無法進入偏向鎖(這是因為偏向鎖設計時,沒有地方用來備份hashcode)。
  2. 檢查物件頭中的鎖標誌位是否是01,如果不是說明物件處在其他鎖的狀態,則執行其他鎖的邏輯。
  3. 如果偏向鎖的執行緒ID是自己的執行緒ID則直接執行同步程式碼塊,說明之前此執行緒已經獲取到了鎖。
  4. 如果偏向鎖ID不是自己的執行緒ID,透過CAS演算法嘗試偏向鎖的執行緒ID,如果成功了就獲取到鎖,直接執行同步程式碼。如果失敗的話說明有執行緒獲取了偏向鎖,此時執行緒會請求那個持有鎖的執行緒釋放鎖。
  5. 如果持有鎖的執行緒還在同步程式碼中,則無法釋放鎖,這個時候鎖會膨脹為輕量級鎖。膨脹的的時候會修改物件頭為輕量級鎖。
  6. 同步程式碼執行完成後,執行緒並不會重置物件頭的資料,即不會釋放鎖,以便下次再次執行的時候可以直接進入同步程式碼。

我們可以看到,一段同步程式碼如果一直是由一個執行緒執行的時候,這個執行緒只需要做2和3中簡單的判斷就可繼續往下執行同步程式碼,最初的效能消耗只是第一次上鎖的時候需要修改物件頭。這就是偏向鎖的作用,可以大幅度提升synchronized鎖的效率。但是由於底層為了實現偏向鎖的邏輯過於複雜,在JDK15之後已經預設關閉偏向鎖了,在現代的程式中同一個執行緒一直持有一個鎖的情況已經不多了。具體的鎖的切換流程可以看這篇部落格《深入理解偏向鎖》

輕量級鎖

Java程式執行到synchronized程式碼處,輕量級鎖的邏輯如下:

  1. 檢查物件頭鎖標誌位是否是01,將物件頭複製到棧中進行備份
  2. 嘗試使用CAS演算法修改物件頭(這裡為了防止其他執行緒同時和當前執行緒都去修改物件頭搶鎖),這時候物件頭指向的是當前的棧地址,如果修改成功則獲取到鎖執行同步程式碼。
  3. 如果修改失敗,說明其他執行緒優先獲取到了鎖,當前執行緒自旋(迴圈)獲取鎖,超過一定的次數後如果還是無法獲取到鎖,則鎖膨脹為重量級鎖,膨脹的時候會修改物件頭和維護ObjectMonitor的資料結構。
  4. 同步程式碼執行完成之後,CAS把備份的物件頭寫回到物件頭中。如果修改失敗說明鎖已經膨脹為重量級鎖了,則執行重量級鎖的鎖釋放邏輯。

我們可以看到,輕量級鎖如果鎖的競爭比較低(執行緒比較少,同步程式執行速度較快)的情況下,執行緒可以不需要進入到阻塞狀態,透過自旋等待鎖的釋放。同時輕量級鎖也不需要維護ObjectMonitor的資料,進一步提升了效能。

重量級鎖

由於重量級鎖需要維護ObjectMonitor,所以效能不如輕量級鎖,輕量級鎖只需要修改物件頭即可,重量級鎖不但需要修改物件頭還要維護ObjectMonitor的資料結構。
Java程式執行到synchronized程式碼處,重量級鎖的邏輯如下:

  1. 透過物件頭中的ObjectMonitor的引用地址,找到ObjectMonitor物件,此時ObjectMonitor中儲存了無鎖狀態下物件頭的備份。
  2. 判斷_owner是否是當前執行緒,如果不是則說明鎖被其他執行緒持有,則阻塞當前執行緒(阻塞的邏輯應該和LockSupport.park()的邏輯是一樣的),並把當前執行緒加入到阻塞連結串列中。
  3. 如果_owner是當前執行緒,則_recursions加1記錄重入次數(比如遞迴的時候會重複獲取鎖),並執行同步程式碼。
  4. 同步程式碼執行完成後,_recursions減1(因為重量級鎖是可重入鎖,退出的時候可能退出多次),喚醒阻塞連結串列中的執行緒去搶鎖。如果沒有執行緒等待則修改物件頭為無鎖狀態,把備份的物件頭資料寫回到物件頭。這裡注意,持有鎖的時候如果呼叫hascode方法,修改應該也是備份的物件頭中的資料。

我們可以看到,重量級鎖由於需要維護ObjectMonitor所以效能不高,如果物件能夠一直處在輕量級鎖的狀態下效能會有大幅提升。
同時需要注意,當你在同步程式碼中呼叫wait的時候,因為需要維護wait執行緒佇列,輕量級鎖需要膨脹為重量級鎖。當你呼叫hashcode方法的時候,偏向鎖會膨脹為輕量級鎖。具體的鎖的切換流程可以看這篇部落格《深入理解偏向鎖》

不過這裡我有一個疑問,就是ObjectMonitor是如何和物件做關聯的,即重量級鎖修改物件頭的時候,物件對應的ObjectMonitor物件的記憶體地址是怎麼找到的,難道底層維護了一個ObjectMonitor的Map?我查了些資料和書籍都沒說明。

總結

我們可以看到當遇到synchronized程式碼塊的時候,物件頭可能處於偏向鎖、輕量級鎖、重量級鎖三種狀態,這三種鎖各有各的特點。

優勢 劣勢 觸發場景
偏向鎖 只需要修改一次物件頭 不支援呼叫hashcode方法,如果執行緒存在競爭,需要額外撤銷鎖,底層程式碼維護困難 單個執行緒長期重複持有鎖
輕量級鎖 自旋無需阻塞執行緒,減少執行緒上下文切換 如果始終獲取不到鎖,自旋會消耗cpu資源(感覺也不算缺點,高併發下物件會一直處在重量級鎖的狀態下,執行重量級鎖的邏輯即可) 少量執行緒交替持有鎖
重量級鎖 可以執行wait等操作 執行緒會阻塞,同時需要維護ObjectMonitor效能低 大量執行緒同時爭搶鎖

畢竟大量執行緒同時爭搶鎖的情況不多,如果物件一直處在輕量級鎖的狀態下,鎖的效能已經非常高,與JDK中的Lock的效能已經相差無幾,因為Lock的底層也是使用CAS演算法來維護鎖的狀態。

本文參考書籍:

  1. 《Java併發程式設計的藝術》這本書值得一讀,底層原理講的比較深入。

相關文章