深入淺出 synchronized

佔小狼發表於2016-08-18

synchronized可以保證方法或程式碼塊在執行時,同一時刻只有一個執行緒可以進入到臨界區(互斥性),同時它還保證了共享變數的記憶體可見性。

Java中的每個物件都可以作為鎖。

  1. 普通同步方法,鎖是當前例項物件。
  2. 靜態同步方法,鎖是當前類的class物件。
  3. 同步程式碼塊,鎖是括號中的物件。

先看一個場景
等待 / 通知機制
直接上程式碼:

其相關方法定義在java.lang.Object上,執行緒A在獲取鎖後呼叫了物件lock的wait方法進入了等待狀態,執行緒B呼叫物件lock的notifyAll()方法,執行緒A收到通知後從wait方法處返回繼續執行,執行緒B對共享變數flag的修改對執行緒A來說是可見的。

整個執行過程需要注意一下幾點:

  1. 使用wait()、notify()和notifyAll()時需要先對呼叫物件加鎖,呼叫wait()方法後會釋放鎖。
  2. 呼叫wait()方法之後,執行緒狀態由RUNNING變為WAITING,並將當前執行緒放置到物件的等待佇列中。
  3. notify()或notifyAll()方法呼叫後,等待執行緒不會立刻從wait()中返回,需要等該執行緒釋放鎖之後,才有機會獲取鎖之後從wait()返回。
  4. notify()方法將等待佇列中的一個等待執行緒從等待佇列中移動到同步佇列中;notifyAll()方法則是把等待佇列中的所有執行緒都移動到同步佇列中;被移動的執行緒狀態從WAITING變為BLOCKED。
  5. 從wait()方法返回的前提是,改執行緒獲得了呼叫物件的鎖。

那麼,它是如何實現執行緒之間的互斥性和可見性?

互斥性

先看一段程式碼:

上述程式碼中,使用了同步程式碼塊和同步方法,通過使用javap工具檢視生成的class檔案資訊來分析synchronized關鍵字的實現細節。

從生成的class資訊中,可以清楚的看到

  1. 同步程式碼塊使用了 monitorentermonitorexit 指令實現。
  2. 同步方法中依靠方法修飾符上的 ACC_SYNCHRONIZED 實現。

無論哪種實現,本質上都是對指定物件相關聯的monitor的獲取,這個過程是互斥性的,也就是說同一時刻只有一個執行緒能夠成功,其它失敗的執行緒會被阻塞,並放入到同步佇列中,進入BLOCKED狀態。


我們繼續深入瞭解一下鎖的內部機制
一般鎖有4種狀態:無鎖狀態,偏向鎖狀態,輕量級鎖狀態,重量級鎖狀態。

在進一步深入之前,我們先認識下兩個概念:物件頭和monitor。

什麼是物件頭?
在hotspot虛擬機器中,物件在記憶體的分佈分為3個部分:物件頭,例項資料,和對齊填充。
mark word被分成兩部分,lock word和標誌位。
Klass ptr指向Class位元組碼在虛擬機器內部的物件表示的地址。
Fields表示連續的物件例項欄位。

深入淺出 synchronized

物件.png

mark word 被設計為非固定的資料結構,以便在及小的空間記憶體儲更多的資訊。比如:在32位的hotspot虛擬機器中:如果物件處於未被鎖定的情況下。mark word 的32bit空間中有25bit儲存物件的雜湊碼、4bit儲存物件的分代年齡、2bit儲存鎖的標記位、1bit固定為0。而在其他的狀態下(輕量級鎖、重量級鎖、GC標記、可偏向)下物件的儲存結構為

深入淺出 synchronized

Paste_Image.png

什麼是monitor?
monitor是執行緒私有的資料結構,每一個執行緒都有一個可用monitor列表,同時還有一個全域性的可用列表,先來看monitor的內部

深入淺出 synchronized

Paste_Image.png
  • Owner:初始時為NULL表示當前沒有任何執行緒擁有該monitor,當執行緒成功擁有該鎖後儲存執行緒唯一標識,當鎖被釋放時又設定為NULL;
  • EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor失敗的執行緒。
  • RcThis:表示blocked或waiting在該monitor上的所有執行緒的個數。
  • Nest:用來實現重入鎖的計數。
  • HashCode:儲存從物件頭拷貝過來的HashCode值(可能還包含GC age)。
  • Candidate:用來避免不必要的阻塞或等待執行緒喚醒,因為每一次只有一個執行緒能夠成功擁有鎖,如果每次前一個釋放鎖的執行緒喚醒所有正在阻塞或等待的執行緒,會引起不必要的上下文切換(從阻塞到就緒然後因為競爭鎖失敗又被阻塞)從而導致效能嚴重下降。Candidate只有兩種可能的值:0表示沒有需要喚醒的執行緒,1表示要喚醒一個繼任執行緒來競爭鎖。

那麼monitor的作用是什麼呢?在 java 虛擬機器中,執行緒一旦進入到被synchronized修飾的方法或程式碼塊時,指定的鎖物件通過某些操作將物件頭中的LockWord指向monitor 的起始地址與之關聯,同時monitor 中的Owner存放擁有該鎖的執行緒的唯一標識,確保一次只能有一個執行緒執行該部分的程式碼,執行緒在獲取鎖之前不允許執行該部分的程式碼。


接下去,我們可以深入瞭解下在鎖各個狀態下,底層是如何處理多執行緒之間對鎖的競爭。

偏向鎖

下述程式碼中,當執行緒訪問同步方法method1時,會在物件頭(SynchronizedTest.class物件的物件頭)和棧幀的鎖記錄中儲存鎖偏向的執行緒ID,下次該執行緒在進入method2,只需要判斷物件頭儲存的執行緒ID是否為當前執行緒,而不需要進行CAS操作進行加鎖和解鎖(因為CAS原子指令雖然相對於重量級鎖來說開銷比較小但還是存在非常可觀的本地延遲)。

輕量級鎖

利用了CPU原語Compare-And-Swap(CAS,彙編指令CMPXCHG)。

執行緒可以通過兩種方式鎖住一個物件:

  1. 通過膨脹一個處於無鎖狀態(狀態位001)的物件獲得該物件的鎖;
  2. 物件處於膨脹狀態(狀態位00),但LockWord指向的monitor的Owner欄位為NULL,則可以直接通過CAS原子指令嘗試將Owner設定為自己的標識來獲得鎖。

獲取鎖(monitorenter)的大概過程:

  1. 物件處於無鎖狀態時(LockWord的值為hashCode等,狀態位為001),執行緒首先從monitor列表中取得一個空閒的monitor,初始化Nest和Owner值為1和執行緒標識,一旦monitor準備好,通過CAS替換monitor起始地址到LockWord進行膨脹。如果存在其它執行緒競爭鎖的情況而導致CAS失敗,則回到monitorenter重新開始獲取鎖的過程即可。
  2. 物件已經膨脹,monitor中的Owner指向當前執行緒,這是重入鎖的情況(reentrant),將Nest加1,不需要CAS操作,效率高。
  3. 物件已經膨脹,monitor中的Owner為NULL,此時多個執行緒通過CAS指令試圖將Owner設定為自己的標識獲得鎖,競爭失敗的執行緒則進入第4種情況。
  4. 物件已經膨脹,同時Owner指向別的執行緒,在呼叫作業系統的重量級的互斥鎖之前自旋一定的次數,當達到一定的次數如果仍然沒有獲得鎖,則開始準備進入阻塞狀態,將rfThis值原子加1,由於在加1的過程中可能被其它執行緒破壞物件和monitor之間的聯絡,所以在加1後需要再進行一次比較確保lock word的值沒有被改變,當發現被改變後則要重新進行monitorenter過程。同時再一次觀察Owner是否為NULL,如果是則呼叫CAS參與競爭鎖,鎖競爭失敗則進入到阻塞狀態。

釋放鎖(monitorexit)的大概過程:

  1. 檢查該物件是否處於膨脹狀態並且該執行緒是這個鎖的擁有者,如果發現不對則丟擲異常。
  2. 檢查Nest欄位是否大於1,如果大於1則簡單的將Nest減1並繼續擁有鎖,如果等於1,則進入到步驟3。
  3. 檢查rfThis是否大於0,設定Owner為NULL然後喚醒一個正在阻塞或等待的執行緒再一次試圖獲取鎖,如果等於0則進入到步驟4。
  4. 縮小(deflate)一個物件,通過將物件的LockWord置換回原來的HashCode等值來解除和monitor之間的關聯來釋放鎖,同時將monitor放回到執行緒私有的可用monitor列表。

重量級鎖

當鎖處於這個狀態下,其他執行緒試圖獲取鎖都會被阻塞住,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒。

記憶體可見性

對java記憶體模型不熟悉的同學,可以參考這邊文章java記憶體模型

  1. 執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。
  2. 執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效,從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

深入淺出 synchronized