並發編程學習筆記02-Java並發機制的底層原理之synchronized

  • 2020 年 1 月 22 日
  • 筆記

並發學習系列以閱讀《Java並發編程的藝術》一書的筆記為藍本,彙集一些閱讀過程中找到的解惑資料而成。這是一個邊看邊寫的系列,有興趣的也可以先自行購買此書學習。

synchronized實現同步的基礎:Java中的每一個對象都可以作為鎖。具體表現為三種形式:

  • 對於普通同步方法,鎖是當前實例對象。
  • 對於靜態同步方法,鎖是當前類的Class對象。
  • 對於同步方法塊,鎖是synchronized括弧里配置的對象。

當一個執行緒試圖訪問同步程式碼塊時,它首先必須得到鎖,退出或拋出異常是必須釋放鎖。

JVM基於進入和退出Monitor對象來實現方法同步和程式碼塊同步,兩者實現細節不同,但都可使用 monitorenetermonitorexit 這兩個指令來實現。

  • monitorenter指令是在編譯後插入到同步塊的開始位置。
  • monitorexit指令是插入到方法結束處和異常處。
  • JVM要保證每個monitorenter必須有對應的monitorexit與之配對。
  • 任何對象都有一個monitor與之關聯,且當一個monitor被持有後,它將處於鎖定狀態。
  • 程式執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖

Java對象頭

synchronized用的鎖是存在Java對象頭里的。

  • 若對象是數組類型,則虛擬機用3字寬(Word)存儲對象頭;
  • 若對象是非數組類型,則用2字寬存儲對象頭。

在32位虛擬機中,1字寬等於4位元組,即32bit。依次,在64位虛擬機中,1子寬為64bit。

Java對象頭組成如下:

長度

內容

說明

備註

32/64 bit

Mark Word

存儲對象的hashCode或鎖資訊等

默認存儲對象的HashCode、分代年齡和鎖標記位。

32/64 bit

Class Metadata Address

存儲對象的類型數據的指針

該指針指向對象的類元數據,JVM通過這個指針確定對象是哪個類的實例。

32/64 bit

Array Length

數組的長度(如果當前對象是數組)

j當且僅當對象是數組時才會有這部分。

32位JVM的Mark Word的默認存儲結構如下:

鎖狀態

25bit

4bit

1bit 是否是偏向鎖

2bit 鎖標記位

無鎖狀態

對象的HashCode

對象的分代年齡

0

01

運行期間,Mark Word 里存儲的數據會隨著鎖標誌位的變化而變化。可能會變成存儲以下4種數據:

在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下所示:

由此可見無鎖狀態和偏向鎖狀態時鎖標誌位均是01,只是在前面的1bit區域區分了當前是無鎖狀態還是偏向鎖狀態。

UseCompressedOops

64位的JVM將會比32位的JVM多耗費50%的記憶體。

從JDK 1.6 update14開始,64 bit JVM正式支援了

-XX:+UseCompressedOops 

這個可以壓縮指針,起到節約記憶體佔用的新參數。

oop即ordinary object pointer普通對象指針。開啟該選項後,下列指針將壓縮至32位:

  • 1.每個Class的屬性指針(即靜態變數)
  • 2.每個對象的屬性指針(即對象變數)
  • 3.普通對象數組的每個元素指針

當然不是所有指針都被壓縮,一些特殊類型的指針JVM不會優化,比如:指向PermGen的Class對象指針(JDK8中指向元空間的Class對象指針)、本地變數、堆棧元素、入參、返回值和NULL指針等。

被壓縮的還有數組對象頭的Array Length 部分。

該指令原理是:解釋器在解釋位元組碼時,植入壓縮指令。

關於JVM中的對象相關內容可查看:JVM-HotSpot虛擬機對象探秘

鎖的升級與對比

Java SE 1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了「偏向鎖」和「輕量級鎖」,以及鎖升級的概念。

JDK1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態偏向鎖狀態輕量級鎖狀態重量級鎖狀態

這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級。這種策略的目的是為了提高獲得鎖和釋放鎖的效率

幾種鎖的優缺點對比如下:

優點

缺點

適用場景

偏向鎖

加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距。

如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗。

適用於只有一個執行緒訪問同步塊的場景。

輕量級鎖

競爭的執行緒不會阻塞,提高了程式的響應速度。

如果始終得不到鎖競爭的執行緒,使用自旋會消耗CPU。

追求響應時間;同步塊執行速度非常快。

重量級鎖

執行緒競爭不使用自旋,不會消耗CPU。

執行緒阻塞,響應時間緩慢。

追求吞吐量,同步塊執行速度較長

偏向鎖

大多數情況下鎖不僅不存在多執行緒競爭,而且總是由同一個執行緒多次獲得,故為了讓執行緒獲得鎖的代價更低而引入了偏向鎖

當一個執行緒訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄中存儲鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否存儲著指向當前執行緒的偏向鎖。

  • 若測試成功,表示執行緒已經獲得了鎖。
  • 若測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1: 若沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前執行緒。

偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的位元組碼)。

  • 首先暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著。
  • 如果執行緒不處於活動狀態,則將對象頭設置成無鎖狀態;
  • 如果執行緒仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄。
  • 棧中的鎖記錄和對象頭的Mark Word 要麼重新偏向其他執行緒,要麼恢復到無鎖或標記對象不適合作為偏向鎖,最後喚醒暫停的執行緒。

關閉偏向鎖

偏向鎖在Java 6 和 Java 7里是默認啟用的。但它在應用程式啟動幾秒後才激活。可以通過JVM參數來關閉延遲:

-XX:BiasedLockingStartupDelay=0

如果你確定應用程式里所有的鎖通常情況下處於競爭狀態,可以通過JVM參數關閉偏向鎖:

-XX:-UseBiasedLocking=false

設置後,程式默認會進入輕量級鎖狀態。

輕量級鎖

執行緒在執行同步塊之前,JVM會先在當前執行緒的棧幀中創建用於存儲鎖記錄的空間,並將對象中的Mark Word複製到鎖記錄中,官方稱為 Displaced Mark Word。

然後執行緒嘗試使用CAS將對象頭中的Mark Word替換位指向鎖記錄的指針:

  • 如果成功,當前執行緒獲得鎖;
  • 如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。

輕量級鎖解鎖時,會使用原子的CAS操作將Displaced Mark Word 替換會到對象頭:

  • 若成功,表示沒有競爭發生。
  • 若失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞了),一旦所升級成重量級鎖,就不會再回到輕量級鎖狀態 。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞,當持有鎖的執行緒被釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭。

重量級鎖

重量鎖在JVM中又叫對象監視器(Monitor),它很像C中的Mutex,除了具備Mutex互斥的功能,它還負責實現了Semaphore的功能,也就是說它至少包含一個競爭鎖的隊列,和一個訊號阻塞隊列(wait隊列),前者負責做互斥,後一個用於做執行緒同步。

參考資料

  1. JVM優化之壓縮普通對象指針(CompressedOops)
  2. Java對象頭詳解
  3. 讓我們來一起聊聊Java中的鎖