1. 程式人生 > 程式設計 >深入分析synchronized實現原理

深入分析synchronized實現原理

EE30A7">實現原理

Synchronized可以保證一個在多執行緒執行中,同一時刻只有一個方法或者程式碼塊被執行,它還可以保證共享變數的可見性和原子性

在Java中每個物件都可以作為鎖,這是Synchronized實現同步的基礎。具體的表現為一下3種形式:

  1. 普通同步方法,鎖是當前例項物件;
  2. 靜態同步方法,鎖是當前類的Class物件;
  3. 同步方法快,鎖是Synchronized括號中配置的物件。

當一個執行緒試圖訪問同步程式碼塊時,它必須先獲取到鎖,當同步程式碼塊執行完畢或丟擲異常時,必須釋放鎖。那麼它是如何實現這一機制的呢?我們先來看一個簡單的synchronized的程式碼:

public class SyncDemo {

    public synchronized void play() {}

    public void learn() {
        synchronized(this) {

        }
    }
}複製程式碼

利用javap工具檢視生成的class檔案資訊分析Synchronized,下面是部分資訊

public com.zzw.juc.sync.SyncDemo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1,locals=1,args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/zzw/juc/sync/SyncDemo;

  public synchronized void play();
    descriptor: ()V
    flags: ACC_PUBLIC,ACC_SYNCHRONIZED
    Code:
      stack=0,args_size=1
         0: return
      LineNumberTable:
        line 10: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/zzw/juc/sync/SyncDemo;

  public void learn();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2,locals=3,args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_1
         5: monitorexit
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return
      Exception table:
         from    to  target type
             4     6     9   any
             9    12     9   any
複製程式碼

從上面利用javap工具生成的資訊我們可以看到同步方法是利用ACC_SYNCHRONIZED這個修飾符來實現的,同步程式碼塊是利用monitorenter和monitorexit這2個指令來實現的。

  • 同步程式碼塊:monitorenter指令插入到同步程式碼塊的開始位置,monitorexit指令插入到同步程式碼塊的結束位置,JVM需要保證每一個monitorenter都有一個monitorexit與之相對應。任何物件都有一個monitor與之相關聯,當且一個monitor被持有之後,他將處於鎖定狀態。執行緒執行到monitorenter指令時,將會嘗試獲取物件所對應的monitor所有權,即嘗試獲取物件的鎖;
  • 同步方法:synchronized方法則會被翻譯成普通的方法呼叫和返回指令如:invokevirtual、areturn指令,在JVM位元組碼層面並沒有任何特別的指令來實現被synchronized修飾的方法,而是在Class檔案的方法表中將該方法的access_flags欄位中的synchronized標誌位置1,表示該方法是同步方法並使用呼叫該方法的物件或該方法所屬的Class在JVM的內部物件表示Klass做為鎖物件

在繼續分析Synchronized之前,我們需要理解2個非常重要的概念:Java物件頭和Monitor

EE30A7">Java物件頭

Synchronized用的鎖是存放在Java物件頭裡面的。那麼什麼是物件頭呢?在Hotspot虛擬機器器中,物件頭包含2個部分:標記欄位(Mark Word)和型別指標(Kass point)。其中Klass Point是是物件指向它的類元資料的指標,虛擬機器器通過這個指標來確定這個物件是哪個類的例項,Mark Word用於儲存物件自身的執行時資料,它是實現輕量級鎖和偏向鎖的關鍵。這裡我們將重點闡述Mark Word。

EE30A7">Mark Word

Mark Word用於儲存物件自身的執行時資料,如雜湊碼(Hash Code)、GC分代年齡、鎖狀態標誌、執行緒持有鎖、偏向執行緒ID、偏向時間戳等,這部分資料在32位和64位虛擬機器器中分別為32bit和64bit。一個物件頭一般用2個機器碼儲存(在32位虛擬機器器中,一個機器碼為4個位元組即32bit),但如果物件是陣列型別,則虛擬機器器用3個機器碼來儲存物件頭,因為JVM虛擬機器器可以通過Java物件的元資料資訊確定Java物件的大小,但是無法從陣列的元資料來確認陣列的大小,所以用一塊來記錄陣列長度。在32位虛擬機器器中,Java物件頭的Makr Word的預設儲存結構如下:

鎖狀態
25bit
4bit
1bit 是否是偏向鎖 2bit鎖標誌位
無鎖狀態 物件的HashCode 物件分代年齡 0
01

在程式執行期間,物件頭中鎖表標誌位會發生改變。Mark Word可能發生的變化如下:Mark_Word_32

在64位虛擬機器器中,Java物件頭中Mark Work的長度是64位的,其結構如下:

Mark_Word_64

介紹了Mark Word 下面我們來介紹下一個重要的概率Monitor。

EE30A7">Monitor

Monitor是作業系統提出來的一種高階原語,但其具體的實現模式,不同的程式語言都有可能不一樣。Monitor 有一個重要特點那就是,同一個時刻,只有一個執行緒能進入到Monitor定義的臨界區中,這使得Monitor能夠達到互斥的效果。但僅僅有互斥的作用是不夠的,無法進入Monitor臨界區的執行緒,它們應該被阻塞,並且在必要的時候會被喚醒。顯然,monitor 作為一個同步工具,也應該提供這樣的機制。Monitor的機制如下圖所示:Monitor機制

從上圖中,我們來分析下Monitor的機制:Mointor可以看做是一個特殊的房間(這個房間就是我們在Java執行緒中定義的臨界區),Monitor在同一時間,保證只能有一個執行緒進入到這個房間,進入房間即表示持有Monitor,退出房間即表示釋放Monitor。當一個執行緒需要訪問臨界區中的資料(即需要獲取到物件的Monitro)時,他首先會在entry-set入口佇列中排隊等待(這裡並不是真正的按照排隊順序),如果沒有執行緒持有物件的Monitor,那麼entry-set佇列中的執行緒會和waite-set佇列中被喚醒的執行緒進行競爭,選出一個執行緒來持有物件Monitor,執行受保護的程式碼段,執行完畢後釋放Monitor,如果已經有執行緒持有物件的Monitor,那麼需要等待其釋放Monitor後再進行競爭。當一個執行緒擁有物件的Monitor後,這個時候如果呼叫了Object的wait方法,執行緒就釋放了Monitor,進入wait-set佇列,當Object的notify方法被執行後,wait-set中的執行緒就會被喚醒,然後在wait-set佇列中被喚醒的執行緒和entry-set佇列中的執行緒一起通過CPU排程來競爭物件的Monitor,最終只有一個執行緒能獲取物件的Monitor。

需要注意的是:

當一個執行緒在wait-set中被喚醒後,並不一定會立刻獲取Monitor,它需要和其他執行緒去競爭

如果一個執行緒是從wait-set佇列中喚醒後,獲取到的Monitor,它會去讀取它自己儲存的PC計數器中的地址,從它呼叫wait方法的地方開始執行。

EE30A7">鎖的優化和對比

在JavaSE6為了對鎖進行優化,引入了偏向鎖和輕量級鎖。在JavaSE6中鎖一共有4種狀態,它們從低到高一次是無狀態鎖、偏向鎖、輕量級鎖和重量級鎖。鎖的這幾種狀態會隨著競爭而依次升級,但是鎖是不能降級的。

EE30A7">偏向鎖

偏向鎖顧名思義就是偏向於第一個訪問鎖的執行緒,在執行的過程中同步鎖只有一個執行緒訪問,不存在多執行緒競爭的情況,則執行緒不會觸發同步,這種情況下會給執行緒加一個偏向鎖。偏向鎖的引入就是為了讓執行緒獲取鎖的代價更低。

  • 偏向鎖的獲取

(1)訪問Mark Word中偏向鎖的標識是否設定成1,鎖標誌位是否為01——確認為可偏向狀態。  (2)如果為可偏向狀態,則測試執行緒ID是否指向當前執行緒,如果是,進入步驟(5),否則進入步驟(3)。  (3)如果執行緒ID並未指向當前執行緒,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中執行緒ID設定為當前執行緒ID,然後執行(5);如果競爭失敗,執行(4)。  (4)如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全域性安全點(safepoint)時獲得偏向鎖的執行緒被掛起,偏向鎖升級為輕量級鎖,然後被阻塞在安全點的執行緒繼續往下執行同步程式碼。  (5)執行同步程式碼。

  • 偏向鎖的釋放
    偏向鎖的釋放在上面偏向鎖的獲取中的第4步已經提到過。偏向鎖只有在遇到其它執行緒競爭偏向鎖時,持有偏向鎖的執行緒才會釋放。執行緒是不會主動的去釋放偏向鎖的。偏向鎖的釋放需要等到全域性安全點(在這個時間點上沒有正在執行的位元組碼),它會首先去暫停擁有偏向鎖的執行緒,撤銷偏向鎖,設定物件頭中的Mark Word為無鎖狀態或輕量級鎖狀態,再恢復暫停的執行緒。複製程式碼
  • 偏向鎖的關閉
    偏向鎖在Java6和Java7中是預設開啟的,但它是在應用程式啟動幾秒後才啟用。如果想消除延時立即開啟,可以調整JVM引數來關閉延遲:-XX: BiasedLockingStartupDelay=0。如果你確定應用程式中沒有偏向鎖的存在,你也可以通過JVM引數關閉偏向鎖: -XX:UseBiasedLocking=false,使用改引數後,程式會預設進入到輕量級鎖狀態。複製程式碼
  • 偏向鎖的適用場景
    始終只有一個執行緒在執行同步塊,在它沒有執行完同步程式碼塊釋放鎖之前,沒有其它執行緒去執行同步塊來競爭鎖,在鎖無競爭的情況下使用。一旦有了競爭就升級為輕量級鎖,升級為輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖需要在全域性安全點上,這個時候會導致Stop The World,Stop The Wrold 會導致效能下降,因此在高併發的場景下應當禁用偏向鎖。複製程式碼
EE30A7">輕量級鎖

輕量級鎖是有偏向鎖競爭升級而來的。引入輕量級鎖的目的是在沒有多執行緒競爭的情況下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

  • 輕量級鎖的獲取
    (1)在程式碼進入同步程式碼塊時,如果同步物件沒有被鎖定(鎖標誌位為“01”狀態),虛擬機器器首先將在當前執行緒的棧幀中建了一個名為鎖記錄(Lock Record)的空間,用於儲存物件目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。
    (2)虛擬機器器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標,如果更新成功,則表示獲取到了鎖,並將鎖標誌位設定為“00”(表示物件處於輕量級鎖狀態)。如果失敗則執行(3)操作。
    (3)虛擬機器器檢查當前物件的Mark Wrod 是否指向當前執行緒的棧幀,如果是這說明當前執行緒已經持有了這個物件的鎖,直接進入同步塊繼續執行;否則說明這個鎖物件已經被其它執行緒持有,這是輕量級鎖就要膨脹為重量級鎖,鎖標誌的狀態值變更為“10”,後面等待鎖的執行緒也要進入阻塞狀態。複製程式碼
  • 輕量級鎖的釋放
    (1)使用CAS操作把物件當前的Mark Word和執行緒中複製的Displaced Mark Word替換回來,如果成功,則同步過程完成。
    (2)CAS替換失敗,說明有其他執行緒嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的執行緒。複製程式碼

輕量級鎖能提升同步效能的依據是“對於絕大部分的鎖,在整個同步週期都是不存在競爭的”。若果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發成了CAS操作,因此存在競爭的情況下,輕量級鎖比傳統的重量級做會更慢。

EE30A7">重量級鎖

重量級鎖通過物件內部的監視器(monitor)實現,其中monitor的本質是依賴於底層作業系統的Mutex Lock實現,作業系統實現執行緒之間的切換需要從使用者態到核心態的切換,切換成本非常高。

EE30A7">偏向鎖、輕量級鎖的狀態轉換

偏向鎖輕量級鎖的狀態轉換

EE30A7">其它優化

  • 自旋鎖
    執行緒的掛起和恢復需要CPU從使用者狀態切換到核心狀態,頻繁的掛起和恢復會給系統的併發效能帶來很大的壓力。同時我們發現在許多的應用上,共享該資料的鎖定只會持續很短的一段時間,為了這一段很短的時間,讓執行緒頻繁的掛起和恢復是很不值得的,因此引入了自旋鎖。
    自旋鎖的原理非常的簡單,若果那些持有鎖的執行緒能夠在很短的時間釋放資源,那麼那等待競爭鎖的執行緒就不需要做使用者狀態和核心狀態的切換進入阻塞掛起狀態,它們只需要“稍等一下”,等待持有鎖的執行緒釋放資源後立即獲取鎖。這裡需要注意的是,執行緒在自旋的過程中,是不會放棄CPU的執行時間的,因此如果鎖被佔用的時間很長,那麼自旋的執行緒不做任何有用的工作從而浪費了CPU的資源。所有自旋等待時間必須有一個限制,如果自旋超過了限定的次數任然沒有獲取鎖,則需要停止自旋進入阻塞狀態。虛擬機器器設定的自旋次數預設是10次,可以通過 -XX:PreBlockSpin來更改。複製程式碼
  • 自適自旋鎖
    上面說到自旋鎖的自旋次數是一個固定的值,但是這個自旋次數應該如何限定了,設定大了會讓執行緒一直佔用CPU時間浪費效能,設定低了會讓執行緒頻繁的進入掛起和恢復狀態也會浪費效能。因此JDK在1.6中引入了自適應自旋鎖,自適應說明自旋的時間不在是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定的。
    自適應自旋鎖的原理也非常簡單,當一個執行緒在一把鎖上自旋成功,那麼下一次在這個鎖上自旋的時間將更長,因為虛擬機器器認為上次自旋成功了,那麼這次自旋也有可能再次成功。反之,如果一個執行緒在一個鎖上很少自旋成功,那麼以後這個執行緒要獲取這個鎖時,自旋的此時將會減少甚至可能省略自旋的過程,直接進入阻塞狀態以免浪費CPU的資源。複製程式碼
  • 鎖消除
    鎖消除是指虛擬機器器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據是逃逸分析的資料支援。變數是否逃逸對於虛擬機器器來說需要使用資料流來分析,但是對於我們程式設計師應該是很清楚的,怎麼會在知道不存在資料競爭的情況下使用同步呢?但是程式有時並不是我們想的那樣,雖然我們沒有顯示的使用鎖,但是在使用一些Java 的API時,會存在隱式加鎖的情況。例如如下程式碼:複製程式碼
    
    public String concat(String s1,String s2){
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
    複製程式碼

我們知道每個sb.append()方法中都有一個同步快,鎖就是sb的物件。因此虛擬機器器在執行這段程式碼時,會監測到sb這個變數永遠不會“逃逸”到concat()方法之外,因此虛擬機器器就會消除這段程式碼中的鎖而直接執行了。

  • 鎖粗化
    我們知道在使用同步鎖的時候,需要儘量將同步塊的作用範圍限制的儘量小一些----只在共享資料的實際作用域中才進行同步,這樣做的目的是為了是同步的時間儘可能的縮短,如果存在鎖的競爭,那麼等待鎖的執行緒也能儘快的獲取到鎖。
    大多數情況下,上面的的原則都是正確的。但是如果一系列的連續操作都對同一個物件反覆的加鎖,甚至加鎖出現在迴圈體中,那麼即時沒有競爭,頻繁的進行互斥同步操作也會導致不必須的效能損耗。所以引入了鎖粗化的概率。
    那麼什麼是鎖粗化呢?鎖粗化就是將連線加鎖、解鎖的過程連線在一起,擴充套件(粗化)成為一個同步範圍更大的鎖。以上面程式碼為例,就是擴充套件到第一個append()操作之前,直至最後一個append()操作之後,這樣只需要加鎖一次就可以了。複製程式碼
EE30A7">總結

本文重點探究了Synchronized的實現原理,以及JDK引入偏向鎖和輕量級鎖對synchronized所做的優化處理,和一些其他的鎖的優化處理。我們最後來總結一下Synchronized的執行過程:

  1. 檢測Mark Word裡面是不是當前執行緒的ID,如果是,表示當前執行緒處於偏向鎖 。
  2. 如果不是,則使用CAS將當前執行緒的ID替換Mard Word,如果成功則表示當前執行緒獲得偏向鎖,置偏向標誌位1 。
  3. 如果失敗,則說明發生競爭,撤銷偏向鎖,進而升級為輕量級鎖。
  4. 當前執行緒使用CAS將物件頭的Mark Word替換為鎖記錄指標,如果成功,當前執行緒獲得鎖 。
  5. 如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。
  6. 如果自旋成功則依然處於輕量級狀態。
  7. 如果自旋失敗,則升級為重量級鎖。