從物件頭出發瞭解Synchronized關鍵字

無聊夫斯基發表於2018-08-19

寫這篇文章的目的源自於看《併發程式設計藝術》的時候,書上說synchronized關鍵字的鎖是放在物件頭裡的。索性帶著這個問題把這個關鍵字相關的內容梳理一下。

什麼是synchronized關鍵字?

synchronized關鍵字是Java併發程式設計中非常重要的一個工具。它的主要目的是在同一時間只能允許一個執行緒去訪問一段特定的程式碼,從而保護一些變數或者資料不會被其他執行緒所修改。這感覺就像一群人搶著去上廁所,而你運氣好搶到了,啪把門一鎖,廁所的那一平方天地在那段時間就只屬於你,即使門外的人排隊都排到了地中海(此處排除有人暴力拆廁所的情況)。

使用synchronized關鍵字後,都以物件作為鎖,一般有以下三種實現形式。

  1. 對於同步方法,鎖是當前例項物件。
public synchronized void test1() {
    i++;
}
複製程式碼
  1. 對於靜態同步方法,鎖是當前類的Class物件。
public static synchronized void test2() {
    i++;
}
複製程式碼
  1. 對於同步程式碼塊,鎖是synchronized關鍵字括號內的物件。
public void test2() {
    synchronized(this){
        i++;
    }
}
複製程式碼
什麼是物件頭?

在JVM中,物件在記憶體中的佈局分為3塊:物件頭、例項資料和對齊填充。先說說例項資料,它儲存著物件真正的有效資訊(程式程式碼中定義的各種型別的欄位內容),無論是從父類繼承來的欄位還是子類中定義的。然後再是對齊填充,它並沒有什麼特殊的含義,僅僅只是起佔位符的作用。原因呢是因為JVM要求物件的起始地址必須是8個位元組的整數倍(物件的大小必須是8個位元組的整數倍)。而物件頭已經是8的整數倍了,如果例項資料沒有對齊就需要對齊填充來補全。

重點來了,synchronized使用的鎖都放在物件頭裡,JVM中用2個字寬來儲存物件頭(如果物件是陣列則分配3個字寬,多的一個字寬用於儲存陣列的長度)。而物件頭包含兩部分資訊,分別為Mark Word和型別指標。Mark Word主要用於儲存物件自身的執行時資料,例如物件的hashCode、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒的ID、偏向時間戳等。而型別指標用於標識JVM通過這個指標來確定這個物件是哪個類的例項。

由於物件需要儲存的執行時資料過多,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲更多的資訊。物件在不同的狀態下,Mark Word會儲存不同的內容(只放32位虛擬機器的圖表)。

鎖狀態 25bit 4bit 1bit(是否是偏向鎖) 2bit(鎖的標誌位)
無鎖狀態 物件的hashcode 物件分代年齡 0 01
偏向鎖 執行緒ID + epoch 物件分代年齡 1 01

鎖狀態 30bit 2bit(鎖的標誌位)
輕量級鎖 指向棧中鎖記錄的指標 00
重量級鎖(synchronized) 指向互斥量(重量級鎖)的指標 10
GC標誌 11
monitor物件

這邊也就主要分析一下重量級鎖,標誌位為10,指標指向monitor物件的起始地址,而每一個物件都存在著一個monitor與之關聯。在Hot Spot中,monitor是由ObjectMonitor類來實現的。先來看一下ObjectMonitor的資料結構。

ObjectMonitor() {
    _header       = NULL;//markOop物件頭
    _count        = 0;
    _waiters      = 0,//等待執行緒數
    _recursions   = 0;//重入次數
    _object       = NULL;//監視器鎖寄生的物件。鎖不是平白出現的,而是寄託儲存於物件中。
    _owner        = NULL;//指向獲得ObjectMonitor物件的執行緒或基礎鎖
    _WaitSet      = NULL;//處於wait狀態的執行緒,會被加入到waitSet;
    _WaitSetLock  = 0;
    _Responsible  = NULL;
    _succ         = NULL;
    _cxq          = NULL;
    FreeNext      = NULL;
    _EntryList    = NULL;//處於等待鎖block狀態的執行緒,會被加入到entryList;
    _SpinFreq     = 0;
    _SpinClock    = 0;
    OwnerIsThread = 0;
    _previous_owner_tid = 0;//監視器前一個擁有者執行緒的ID
}
複製程式碼

從物件頭出發瞭解Synchronized關鍵字
其中有兩個佇列 _EntryList和 _WaitSet,它們是用來儲存ObjectMonitor物件列表, _owner指向持有ObjectMonitor物件的執行緒。 當多個執行緒訪問同步程式碼時,執行緒會進入_EntryList區,當執行緒獲取物件的monitor後(對於執行緒獲得鎖的優先順序,還有待考究)進入 _Owner區並且將 _owner指向獲得鎖的執行緒(monitor物件被執行緒持有), _count++,其他執行緒則繼續在 _EntryList區等待。若執行緒呼叫wait方法,則該執行緒進入 _WaitSet區等待被喚醒。執行緒執行完後釋放monitor鎖並且對ObjectMonitor中的值進行復位。上面說到synchronized使用的鎖都放在物件頭裡,大概指的就是Mark Word中指向互斥量的指標指向的monitor物件記憶體地址了。 由以上可知為什麼Java中每一個物件都可以作為鎖物件了。

monitor指令

JVM通過進入和退出monitor物件來實現方法和程式碼塊的同步,但是實現細節不一。可以使用javap -verbose XXX.class命令看程式碼被編譯成位元組碼後是如何實現同步的。

 Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field i:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return
複製程式碼

將含有synchronized程式碼塊的程式碼反編譯後,可以看到monitorenter和monitorexit兩條指令。monitorenter處於程式碼塊開始的位置,而monitorexit與之匹配在程式碼結束或者異常處。任何物件都有個monitor與之對應,當monitor被持有後,它就處於鎖定狀態。執行緒執行到monitorenter指令時,會嘗試去獲得物件的鎖(即monitor的所有權)。

public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 6: 0
        line 7: 10
複製程式碼

方法的同步是隱式的,JVM中使用 method_info 型資料中方法訪問標誌的 ACC_SYNCHRONIZED做區分。當執行緒執行程式碼時若發現方法的訪問標誌中有ACC_SYNCHRONIZED,則當前執行緒持有monitor物件。接下來執行的細節與同步程式碼塊無異。以上便是synchronized關鍵字修飾的同步方法和同步程式碼塊實現的基本原理了。

synchronized重入鎖

第一次聽說重入鎖是ReentrantLock,後來知道synchronized關鍵字支援隱式重入。顧名思義,重入鎖就是支援重進入的鎖,支援一個執行緒可以對資源重複加鎖。對於一個synchronized加持的程式碼塊,其他執行緒試圖訪問該程式碼塊時,執行緒會阻塞。若是持有鎖的執行緒再次請求自己持有的鎖時,則能成功獲得。

public synchronized void test1() {
    i++;
}

public void test2() {
    synchronized(this){
        test1();
    }
}
複製程式碼

當前執行緒獲得鎖後,通過cas將_owner指向當前執行緒,若當前執行緒再次請求獲得鎖, _owner指向不變,執行_recursions++記錄重入的次數,若嘗試獲得鎖失敗,則在_EntryList區域等待。這種感覺有點像盜夢空間裡的夢中夢,可以重複的進入自己的夢裡,若想正常的醒過來,只能按原路返回(_recursions--)。

馬後炮

我買的書上沒有關於synchronized關鍵字比較底層的解釋,只能站在網上其他博主的肩膀上,通過他們文章中對於底層C++程式碼的解釋大致的瞭解了一下其原理。

最後還是那句話,學習的最終目的並不是為了面試,面試只是一個激勵學習的動機。把握面試題,享受學習新知識的樂趣。

參考:

只會一點Java -> jdk原始碼剖析二: 物件記憶體佈局、synchronized終極原理
《深入理解Java虛擬機器》
《併發程式設計的藝術》

相關文章