Java多執行緒之synchronized詳解

bmilk 發表於 2020-06-29

目錄

  • synchronized簡介
  • 同步的原理
  • 物件頭與鎖的實現
  • 鎖的優化與升級
  • Monitor Record
  • 鎖的對比

synchronized簡介

synchronized關鍵字,一般稱之為“同步鎖”或者重量級鎖(JAVA SE 1.6之後引入了偏向鎖輕量級鎖)。它具有可重入性.
根據鎖的鎖的“物件”不同可以分為物件鎖和類鎖:

  • 物件鎖:

    • 對於普通的同步方法,鎖的是當前例項的物件
    • 對於同步方法塊,如果synchronized括號裡配置的是類的例項物件,則鎖的是配置的物件
  • 類鎖:Class物件鎖

    • 對於靜態同步方法,鎖的是當前類(具體說是當前類的Class物件)
    • 對於同步方法塊,如果synchronized括號裡配置的是類的Class物件,則鎖的是當前類
      類鎖其實也鎖的是一個物件,不過是特殊的Class物件,所以類鎖並不是真實存在的。但是他們之間有不同的目的
  • 物件鎖用來控制例項方法之間的同步

  • 類鎖是用來控制靜態方法(或者靜態變數互斥體)之間的同步的。

同步的原理

JVM基於進入和退出Monitor物件來實現方法的同步和程式碼塊同步。每個物件都有一個Monitor與之關聯,當其被佔用就會處於鎖定的狀態。
Monitor並不是一個物件,只是習慣了這樣一個稱呼,他被儲存在物件頭的Mark Word中。
在Java虛擬機器(HotSpot)中,Monitor是由ObjectMonitor實現的。

程式碼塊的同步

測試程式碼如下:

public class SynchronizedTest {
    private void test2(){
        synchronized (this){
            System.out.println(Thread.currentThread().getName()+"獲取鎖"+this.toString());
        }
    }
}

檢視編譯後的位元組碼檔案如下(省略部分內容):

...
 2 astore_1
 3 monitorenter  
 4 getstatic #2 <java/lang/System.out>
 ....
38 invokevirtual #11 <java/io/PrintStream.println>
41 aload_1
42 monitorexit
43 goto 51 (+8)
46 astore_2
47 aload_1
48 monitorexit
...

在編譯後的位元組碼檔案中出現了monitorentermonitorexit兩個指令,作用如下:

  • monitorenter指令會嘗試獲取``monitor`的所有權,即會嘗試獲取物件的鎖(儲存在物件頭中)。過程如下:

    • 如果monitor的進入數位0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者。
    • 如果執行緒已經佔有了該monitor,則是重新進入,將monitor的進入數加1.
    • 如果其他執行緒已經佔有了monitor則該西安城進入阻塞狀態,直到monitor的進入數為0,再嘗試獲取monitor所有權
  • monitorexit指令的執行執行緒必須是monitor的持有者。指令執行時monitor的進入數減1,如果減1後計數器為0,
    則該執行緒將不再持有這個monitor,其他被這個monitor阻塞的執行緒可以嘗試去獲取這個 monitor 的所有權。

    • monitorexit指令出現了兩次,第1次為同步正常退出釋放鎖;第2次為發生非同步退出釋放鎖;

Synchronized的底層是通過一個monitor的物件來完成,其實wait/notify等方法也依賴於monitor物件,
這就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,否則會丟擲java.lang.IllegalMonitorStateException的異常的原因。

同步方法

原始碼如下

public class SynchronizedTest {
    public   synchronized  void test() {
        System.out.println(Thread.currentThread().getName()+"獲取鎖"+this.toString());
    }
}

編譯後位元組碼檔案如下(省略部分內容):

public synchronized void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
...

對於同步方法,在位元組碼檔案中沒有使用monitorentermonitorexit來完成同步(理論上可以),但是多了一個ACC_SYNCHRONIZED的標記,
對於靜態方法還會多出ACC_STATIC標記。JVM就是根據該標記來實現方法同步的。

當方法呼叫時,呼叫指令會檢查方法的ACC_SYNCHRONIZED訪問標記是否被設定,如果設定了執行執行緒將先法獲取monitor,獲取成功才能執行方法體,
方法體執行完成後釋放monitor,在方法執行期間,任何一個其他的執行緒都無法再獲取同一個monitor物件。

總結

兩種同步方式本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過位元組碼來完成。
兩個指令的執行是JVM通過呼叫作業系統的互斥原語mutex來實現,
被阻塞的執行緒會被掛起、等待重新排程,會導致“使用者態和核心態”兩個態之間來回切換,對效能有較大影響。

物件頭與鎖的實現

JVM中,物件在記憶體中的佈局分為三個部分:物件頭、例項資料、填充資訊

  • 物件頭:Java物件頭一般佔2個機器碼(在32位虛擬機器中,一個機器碼佔4個位元組,64位機器中佔8個位元組),
    對於陣列型別需要額外的一個機器碼來儲存陣列的長度,也就是需要3個機器碼。

  • 例項資料: 存放類的屬性資料資訊,包括父類的屬性資訊

  • 填充資訊:由於虛擬機器要求,物件的起始地址必須是8位元組的整數倍,填充資料不是必須的,僅僅用於位元組對齊

synchronized用的鎖就存放在物件頭裡面。在Hospot虛擬機器中,物件頭主要包括以下資訊:

  • Mark Word(標記欄位):用於儲存物件自身執行時的資料,他是實現偏向鎖和輕量級鎖的關鍵。
  • Class Pointer(型別指標):物件指向他的類後設資料的指標,虛擬機器可以通過這個指標確定物件是那個類的例項。

Mark Word用於儲存物件自身執行時的資料,如雜湊碼(HashCode),GC分代資訊,鎖狀態標誌,執行緒持有的鎖,偏向執行緒ID,偏向時間戳等,
下圖是無所狀態下Mark Word的儲存結構:
Java多執行緒之synchronized詳解

Java多執行緒之synchronized詳解
物件頭的資訊是與物件自身定義的資料無關的額外的儲存成本,考慮到虛擬機器的空間效率,mark word被設計成一個非固定的資料結構,
以便在極小的空間記憶體儲儘量多的資訊,隨著物件狀態的改變複用自己的儲存空間。當物件狀態改變時可能會變為以下四種結構:
Java多執行緒之synchronized詳解

Java多執行緒之synchronized詳解

鎖的優化與升級

JDK5引入了CAS原子操作,JDK6synchronized進行了較大的改動,包括JDK5引入的CAS自選之外,還增加了自適應的CAS自旋、
鎖消除、鎖粗化、偏向鎖、輕量級鎖等等優化策略。由於此關鍵字的的優化使得新跟那個極大提高了、同時語義清晰、操作簡單、無需手動關係,
所以推薦情況下儘量使用此關鍵字,同時在效能上此關鍵字還有優化的空間。

  • 鎖的四種狀態——無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態
  • 鎖的升級——鎖的升級是單向的、也就是說只能從低到高升級,不會出現鎖的降級。
  • JDK6中預設是開啟偏向鎖和輕量級鎖,可通過設定虛擬機器引數:-XX:-UseBiasedLocking來禁用偏向鎖。

自旋鎖

讀執行緒可以通過三種方式實現:

  • 使用者態執行緒
  • 核心執行緒
  • 混合實現
    Java執行緒是通過混合實現的。因此Java執行緒的阻塞和喚醒需要從使用者態轉為核心態,而且對於臨界區比較小的程式碼,
    物件鎖狀態只會持續很短的時間,而為此頻繁的阻塞和喚醒後續的執行緒是一件非常不划算的事情。因此引入了自旋鎖:
  • 自旋鎖:當一個執行緒嘗試獲取某個鎖時,如果該鎖已經被其他執行緒佔用,就會一直迴圈檢測是否被釋放,而不是進入執行緒掛起或者睡眠狀態。

自旋鎖適用於保護臨界區很小的情況,臨界區很小話,所佔用的時間就很短。自旋鎖索然可以避免執行緒切換帶來的開銷。但是CPU這段時間一直時空轉,
因此浪費了這段時間的CPU的計算能力。如果持有鎖的執行緒很快就釋放了鎖,那麼自旋的效率就非常好,反之,自旋的執行緒就會白白消耗掉處理器的時間。
因此自旋的次數必須要有一個限度,如果自選超過了定義的是按仍然沒有獲取到鎖,執行緒就應該被掛起

自旋鎖在JDK 1.4.2中引入,預設關閉,但是可以使用-XX:+UseSpinning開開啟;
JDK1.6中預設開啟,同時自旋的預設次數為10次,可以通過引數-XX:PreBlockSpin來調整。
假如將引數調整為10,但是系統很多執行緒都是等你剛剛退出的時候就釋放了鎖(假如多自旋一兩次就可以獲取鎖),是不是很尷尬。
於是JDK1.6引入自適應的自旋鎖,讓虛擬機器會變得越來越聰明。

自適應性自旋鎖

所謂自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

上一個執行緒如果通過自旋成功獲取了鎖,那麼當前執行緒就會有很大的概率也自旋成功,所以在一定程度商會增加自旋的次數。
反之,如果對於某個鎖,很少能有執行緒通過自旋成功獲取鎖,那麼以後有執行緒嘗試獲取這個鎖的時候可能會減少自選的次數甚至省略掉自旋過程,以免浪費處理器資源。

鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。

如果一段程式碼中,堆上的資料都不會逃逸出去從而被其他執行緒訪問,那麼就可以把他們當作執行緒私有的資料對待,自然加鎖也就不需要進行。

例如下面程式碼:

public String concatString(String s1,String s2){
    return s1+s2;
}

上面的程式碼看起來和加鎖沒有什麼關係,但是String是一個不可變類,在JDK1.5之前會轉化成StringBuffer物件的連續append()
操作,在JDK1.5之後會轉化為StringBuilder都物件連續的append()操作;程式碼如下:

public String concatString(String s1,String s2){
    StringBuffer sb=new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
} 

StringBuffer#append()中有一個同步塊,鎖的物件就是sb。虛擬機器觀察sb,很快就會發現它的動態作用域被限制在concatString()方法內部。
因此這裡雖然有鎖,但是可以被安全的消除掉,在即時編譯之後,這段程式碼會忽略掉所有的同步而直接執行了。

StringBuffer#append()程式碼如下:

public synchronized StringBuffer append(StringBuffer sb) {
    toStringCache = null;
    super.append(sb);
    return this;
}

鎖粗化

在使用同步鎖的時候,需要讓同步塊作用範圍儘可能的小——僅在共享資料的是作用域才進行同步,
這樣做可以式臨界區內的操作儘可能的小,如果存在競爭那兒等待鎖的執行緒也能儘快獲得鎖。儘管這種想法是正確的,
但是如果一系列的連續的加鎖解鎖操作,可能會導致不必要要的效能損失,所以引入了鎖粗化

  • 鎖粗化:將多個連續的加鎖、解鎖的操作連線在一起,擴充套件成一個作用範圍更大的鎖。
public String concatString(String s1){
    StringBuffer sb=new StringBuffer();
    for(ing i=0;i<10;i++){
        sb.append(s1)
    }
    return sb.toString();
} 

sb.append()的每次擦做都需要加鎖,JVM檢測到同一個物件的連續的加鎖解鎖操作,
會將其合併成一個範圍更大鎖,加鎖加鎖的過程將會被移到for之外。

偏向鎖

HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多執行緒競爭,
而且總是由同一執行緒多次獲得,
為了讓執行緒獲得鎖的代價更低,引進了偏向鎖

偏向鎖是在單執行緒只想程式碼塊時使用的機制,或者說在沒有競爭的情況下才有用
在多執行緒競爭的情況下(即:執行緒A尚未執行完同步程式碼塊,執行緒B發出了申請鎖的申請),
則一定轉化為輕量級鎖或者重量級鎖。

引入偏向鎖的目的時:為了在沒有多縣城競爭的情況下儘量減少不必要的輕量級鎖的的執行。
因為輕量級鎖的加鎖解鎖操作時需要依賴CAS原子指令的,而偏向鎖只需要在置換ThreadId
的時候依賴一次CAS原子指令。輕量級鎖是為了在縣城交替執行同步塊時提高效能,
而偏向鎖則時在只有一個執行緒執行同步塊時進一步提高效能

偏向鎖獲得和撤銷

  • 偏向鎖的核心思想:一旦執行緒第一次獲得監視器物件,之後讓監視器物件“偏向”這個執行緒,
    之後多次呼叫則可以避免CAS操作。

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧楨中的鎖記錄儲存偏向的執行緒ID
以後該執行緒進入和退出時不需要花費CAS操作來爭奪鎖資源,只需要檢查是否為偏向鎖、
鎖標識以及ThreadId即可,處理流程如下。

  1. 檢查Mark Word是否是可偏向狀態,即是否為偏向鎖1,鎖標識位為01;
  2. 若為可偏向狀態,則測試執行緒ID是否為當前執行緒ID,如果是,則執行第5,否則執行3
  3. 如果測試執行緒ID不是當前執行緒ID,則通過CAS操作競爭鎖,競爭成功,
    則將MarkWord的偏向執行緒ID替換為當前執行緒ID,否則執行4
  4. 通過CAS競爭失敗,證明當前存在多執行緒競爭的情況,當到達全域性安全點,
    獲得偏向鎖的執行緒被掛起,偏向鎖升級為輕量級鎖,然後被阻塞在安全點的執行緒繼續往下執行同步程式碼塊
  5. 執行同步程式碼塊

偏向鎖是一種樂觀鎖,採用了一種競爭出現才會釋放鎖的機制,執行緒是不會主動去釋放偏向鎖,
需要等待其他執行緒來競爭,偏向鎖的撤銷需要等到全域性安全點(這個時間點上沒有任何正在執行的程式碼)
。步驟如下

  1. 暫停擁有偏向鎖的執行緒
  2. 判斷鎖物件是否還處於被鎖定的狀態,否,則恢復到無所狀態(01),以允許其他執行緒競爭,
    是則掛起持有鎖的當前寫執行緒。並將指向當前執行緒的鎖即可路地址的指標放入物件頭MarkWord
    升級為輕量級鎖狀態(00),然後恢復持有鎖的當前執行緒,進入輕量級鎖競爭模式

這裡當前執行緒被掛起再恢復的過程中沒有發生鎖的轉移,仍然在當前執行緒手中,只是穿插了個“將物件頭中的執行緒ID
變更為指向鎖記錄地址的指標”這麼個事。

流程圖如下:
Java多執行緒之synchronized詳解

偏向鎖的關閉

在JDK5中偏向鎖預設是關閉的,而JDK以後的版本中偏向鎖已經預設開啟。
但是他應用程式啟動後幾秒鐘內才會啟用,如果有必要可以使用引數-XX:BiasedLockingStartupDelap=0來關閉延遲。
如果併發數較大同時同步程式碼塊執行時間較長,則被多個執行緒同時訪問的概率就很大,
就可以使用引數-XX:-UseBiasedLocking=false來禁止偏向鎖(但這是個JVM引數,不能針對某個物件鎖來單獨設定)。

輕量級鎖

輕量級鎖考慮的是競爭鎖物件的執行緒不多,而且執行緒持有鎖的時間也不長的情景。
因為阻塞執行緒需要CPU從使用者態轉到核心態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,
那這個代價就有點得不償失了,因此這個時候就乾脆不阻塞這個執行緒,讓它自旋這等待鎖釋放。
當關閉偏向鎖功能或者多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:

輕量級鎖加鎖

  1. 線上程進入同步程式碼塊的時候,如果統不獨額物件沒有被鎖定(鎖標誌為為01,是否是偏向鎖為0),
    則虛擬機器首先在當前執行緒的棧中建立儲存鎖物件的Mark Word的拷貝的鎖記錄(Lock Record)空間,
    官方把這個拷貝稱之為Displaced Mark Word,此時執行緒堆疊與物件頭的狀態如下圖
    執行緒堆疊
    物件頭
  2. 將物件頭中的Mark Word複製到鎖記錄(Lock Record)中
    Java多執行緒之synchronized詳解
// 將Mark Word儲存在鎖記錄中
lock->set_displaced_header(mark);
class BasicLock VALUE_OBJ_CLASS_SPEC {
 friend class VMStructs;
 private:
  volatile markOop _displaced_header;
 public:
  void         set_displaced_header(markOop header)   { _displaced_header = header; }
  ......  
};
  1. 拷貝成功後,虛擬機器將使用CAS操作嘗試將物件Mark Word替換為指向鎖記錄
    (當前執行緒的Lock Record)的指標,並將Lock Record中的owner指標指向object mark word
    如果成功則指向步驟(4),否則指向步驟(5).
    Java多執行緒之synchronized詳解
// lock: 指向Lock Record的指標
// obj()->mark_addr(): 鎖物件的Mark Word地址
// mark: 鎖物件的Mark Word
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
   TEVENT (slow_enter: release stacklock) ;
   return ;
}
  1. 如果這個更新操作成功了,那麼當前執行緒就擁有了該物件的鎖,並且Mark Word鎖標誌為設定為"00",
    即標識此物件處於輕量級鎖定狀態,此時執行緒堆疊與物件頭的狀態如下圖:
    Java多執行緒之synchronized詳解

  2. 如果這個更新操作失敗了,虛擬機器首先會檢查物件Mark Word中的Lock Word是否是指向
    當前的棧楨範圍內,是則執行步驟(6),否則執行步驟(7)

  3. 如果是指向當前執行緒的棧楨的地址範圍則表明該執行緒已經獲得了這個物件的鎖,現在是重入的獲得鎖。
    但是執行緒在每次獲取鎖的時候都會建立鎖記錄(Lock Record)的空間。所以鎖重入的時候也會建立鎖記錄空間。
    但是除了第一次設定Displaced Mark Word,其餘的設定為null

    Java多執行緒之synchronized詳解

// Mark Word 處於加鎖狀態,當前執行緒持有的鎖(Mark Word 指向的是當前執行緒的棧幀地址範圍)
if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
  assert(lock != mark->locker(), "must not re-lock the same lock");
  assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
  // 鎖重入,將 Displaced Mark Word 設定為 null
  lock->set_displaced_header(NULL);
  return;
}
  1. 如果鎖物件的Mark Word中的Lock Word不是指向當前執行緒的棧楨範圍,則表明存在的多個執行緒競爭,
    當前執行緒會自選執行步驟(3),若自旋結束時仍未獲得鎖,輕量級鎖就要膨脹為重量級鎖,鎖標誌狀態值變為“10”,
    Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,當前執行緒以及後面等待鎖的執行緒也要進入阻塞狀態
// The object header will never be displaced to this lock,
// so it does not matter what the value is, except that it
// must be non-zero to avoid looking like a re-entrant lock,
// and must not look locked either.
lock->set_displaced_header(markOopDesc::unused_mark());
ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);

輕量級鎖解鎖

解鎖過程如下:

  1. 通過CAS操作嘗試用執行緒中複製的Displaced Mark Word替換當前的Mark Word
  2. 如果替換成功,整個同步過程就完成了,恢復到無鎖狀態(01)
  3. 如果替換失敗,則說明有其他執行緒嘗試過獲取該鎖(此鎖已經膨脹),要在釋放同時喚醒被掛起的執行緒

整個流程如下:
Java多執行緒之synchronized詳解

重量級鎖

Synchronized是通過與物件關聯的監視器鎖(Monitor)來實現的。
但是監視器鎖本質又是依賴於底層的作業系統的Mutex Lock來實現的。
而作業系統實現執行緒之間的切換這就需要從使用者態轉換到核心態,這個成本非常高,
狀態之間的轉換需要相對比較長的時間,這就是為什麼Synchronized效率低的原因。
因此,這種依賴於作業系統Mutex Lock所實現的鎖我們稱之為 “重量級鎖”。

鎖的變化過程

Java多執行緒之synchronized詳解

Monitor Record

Monitor Record是執行緒私有的資料結構,每一個執行緒都有一個可用的Monitor Record列表,同
時還有一個全域性可用列表,每一個被鎖住的物件的Mark Word都會和一個Lock Record關聯(
物件頭的MarkWord中的Lock Word Point指向與之關聯的Lock Record的起始地址)。
同時Lock Record中有一個Owner欄位存放擁有該鎖的執行緒ID,表示該鎖被這個執行緒佔有。

Lock Record的資料結構

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

鎖的對比

型別 優點 缺點 使用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行同步方法相比,僅存在納秒級的差距 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於一個執行緒訪問同步塊場景
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度 如果始終得不到鎖競爭的執行緒,使用自旋會消耗cpu 追求響應時間,同步塊執行速度非常快
重量級鎖 執行緒競爭不使用自旋,不消耗cpu 執行緒阻塞,響應時間緩慢 追求吞吐量,同步塊執行速度較長

參考資料