Java併發--synchronized實現原理及鎖優化

注:本文中的部分內容摘抄自他人博客,如有侵權,請聯繫我,侵刪~

本篇博客主要講述 synchronized 關鍵字的實現原理以及 JDK 1.6 後對 synchronized 的種種優化。synchronized 的使用不再贅述。


博主目前依舊存在的疑惑

請在閱讀完此篇博客之後,幫助博主回答這三個問題:

  1. 多線程爭奪 Monitor 的具體過程是怎樣的?是根據 ObjectMonitor 中的 _count 值判斷當前 Monitor 是否被鎖定嗎?
  2. JVM 如果檢測到在單線程環境下執行同步代碼(StringBuffer),是會進行鎖消除呢,還是會使用偏向鎖?
  3. 對於偏向鎖的撤銷過程及膨脹過程,博主只是在一些博客的基礎上給出了自己的理解!不權威,建議閱讀源碼,博主對這部分知識的講解持懷疑態度,如果在閱讀的過程中發現博主對偏向鎖的撤銷與膨脹理解有誤,請指出,感激不盡~(網上基本上沒有從源碼角度分析的,對於偏向鎖撤銷與升級的詳細過程也是衆說紛紜)

引言

我們先來看一份代碼:

public class SynchronizedTest {
    public synchronized void test1() {

    }

    public void test2() {
        synchronized (this) {

        }
    }
}

對其進行 javap 反編譯分析:

javap -c SynchronizedTest.class

Compiled from "SynchronizedTest.java"
public class org.xiyoulinux.SynchronizedTest {
  public org.xiyoulinux.SynchronizedTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public synchronized void test1();
    Code:
       0: return

  public void test2();
    Code:
       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 的輸出結果,我們做一個簡單的總結:

同步方法:synchronized 方法會被翻譯成普通的方法調用。在 JVM 字節碼層面並沒有任何特別的指令來實現被 synchronized 修飾的方法。在 Class 文件的方法表中將該方法的 access_flags 字段中的 synchronized 標誌位置 1,表示該方法是同步方法並使用調用該方法的對象(對象鎖)或該方法所屬的 Class(類鎖) 做爲鎖對象。

同步塊:monitorenter 指令插入到同步代碼塊的開始位置,monitorexit 指令插入到同步代碼塊的結束位置,JVM 需要保證每一個 monitorenter 都有一個 monitorexit 與之相對應。任何對象都有一個 monitor 與之相關聯,當且一個 monitor 被持有之後,他將處於鎖定狀態。線程執行到 monitorenter 指令時,將會嘗試獲取對象所對應的 monitor 所有權,即嘗試獲取對象的鎖。(關於上述字節碼中一個 monitorenter 指令爲什麼對應兩個 monitorexit 指令我們稍後進行說明)


synchronized底層語義原理

Java對象頭

要深入理解 synchronized 的實現原理,先來了解一下 Java 對象頭。

對象在堆中由三部分組成:

  1. 對象頭
  2. 實例變量
  3. 填充數據
  • 實例變量:存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按4字節對齊。
  • 填充數據:由於虛擬機要求對象起始地址必須是 8 字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊。
  • 對象頭:HotSpot 虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Class Point(類型指針)。其中 Class Point 是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,Mark Word 用於存儲對象自身的運行時數據,它是實現輕量級鎖和偏向鎖的關鍵。它還用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等等。

Java 對象頭一般佔有兩個字寬(在 32 位虛擬機中,1 個字寬等於 4 字節,也就是 32bit),但是如果對象是數組類型,則需要三個字寬,因爲 JVM 虛擬機可以通過 Java 對象的元數據信息確定 Java 對象的大小,但是無法從數組的元數據來確認數組的大小,所以用一塊來記錄數組長度。

對象頭的存儲結構如下:

長度 內容 說明
32/64 bit Mark Word 存儲對象的 hashCode 或鎖信息等。
32/64 bit Class Metadata Address 存儲到對象類型數據的指針
32/64 bit Array length 數組的長度(如果當前對象是數組)

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

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

由於對象頭的信息是與對象自身定義的數據沒有關係的額外存儲成本,因此考慮到 JVM 的空間效率,Mark Word 被設計成爲一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象本身的狀態複用自己的存儲空間,如 32 位 JVM 下,除了上述列出的 Mark Word 默認存儲結構外,還有如下可能變化的結構:

此處輸入圖片的描述


Monitor(管程)

  • 什麼是 Monitor(管程)?

我們可以把它理解爲一個同步工具,也可以描述爲一種同步機制,它通常被描述爲一個對象。所有的 Java 對象都是天生的 Monitor,在 Java 的設計中 ,每一個 Java 對象都帶了一把看不見的鎖,它叫做內置鎖或者 Monitor 鎖。

觀察 Mark Word 存儲結構的那張圖(上圖):

這裏我們主要分析一下重量級鎖也就是通常說 synchronized 的對象鎖,鎖標識位爲 10,其中指針指向的是 monitor 對象(也稱爲管程或監視器鎖)的起始地址。每個對象都存在着一個 monitor 與之關聯,對象與其 monitor 之間的關係存在多種實現方式,如 monitor 可以與對象一起創建銷燬或當線程試圖獲取對象鎖時自動生成,但當一個 monitor 被某個線程持有後,它便處於鎖定狀態。在 Java 虛擬機(HotSpot)中,monitor 是由 ObjectMonitor 實現的,其主要數據結構如下:(位於 HotSpot 虛擬機源碼 ObjectMonitor.cpp 文件,C++實現)

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;          // 記錄個數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;       // 處於 wait 狀態的線程,會被加入到 _WaitSet
    _WaitSetLock  = 0;
    _Responsible  = NULL;
    _succ         = NULL;
    _cxq          = NULL;
    FreeNext      = NULL;
    _EntryList    = NULL;       // 處於等待鎖 block 狀態的線程,會被加入到該列表
    _SpinFreq     = 0;
    _SpinClock    = 0;
    OwnerIsThread = 0;
}

ObjectMonitor 中有兩個隊列,_WaitSet 和 _EntryList,用來保存 ObjectWaiter 對象列表( 每個等待鎖的線程都會被封裝成 ObjectWaiter 對象),_owner 指向持有 ObjectMonitor 對象的線程,當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的 monitor 後會把 monitor 中的 _owner 變量設置爲當前線程,同時 monitor 中的計數器 _count 加 1。若線程調用 wait() 方法,將釋放當前持有的 monitor,_owner 變量恢復爲 null,_count 自減 1,同時該線程進入 _WaitSet 集合中等待被喚醒。若當前線程執行完畢也將釋放 monitor(鎖)並復位變量的值,以便其它線程進入獲取 monitor(鎖)。

由此看來,monitor 對象存在於每個 Java 對象的對象頭中(存儲的是指針),synchronized 便是通過這種方式獲取鎖的,也是爲什麼 Java 中任意對象可以作爲鎖的原因,同時也是 notify/notifyAll/wait 等方法存在於頂級對象 Object 中的原因(鎖可以是任意對象,所以可以被任意對象調用的方法是定義在 object 類中)。


synchronized方法底層原理

我們在引言部分對 synchronized 方法已經做了一個簡單的總結,現在對它進行一點補充:

在 Java 早期版本中,synchronized 屬於重量級鎖,效率低下,因爲監視器鎖(monitor)是依賴於底層操作系統的 Mutex Lock 來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的 synchronized 效率低的原因。慶幸的是在 Java 6 之後 Java 官方從 JVM 層面對 synchronized 進行了較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。Java 6 之後,爲了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了輕量級鎖和偏向鎖,關於鎖優化的內容,我們稍後再談。


synchronized代碼塊底層原理

在引言部分,我們對 synchronized 代碼塊也做了一個簡單的總結。同樣,對其做一點補充:

當執行 monitorenter 指令時,當前線程將試圖獲取對象鎖所對應的 monitor 的持有權,當對象鎖的 monitor 的進入計數器爲 0,那線程可以成功取得 monitor,並將計數器值設置爲 1,取鎖成功。如果當前線程已經擁有對象鎖的 monitor 的持有權,那它可以重入這個 monitor,重入時計數器的值會加 1。倘若其他線程已經擁有對象鎖的 monitor 的所有權,那當前線程將被阻塞,直到正在執行的線程執行完畢,即 monitorexit 指令被執行,執行線程將釋放 monitor 並設置計數器值爲 0,其他線程將有機會持有 monitor。值得注意的是編譯器將會確保無論方法通過何種方式完成,方法中調用過的每條 monitorenter 指令都有執行其對應 monitorexit 指令,無論這個方法是正常結束還是異常結束。爲了保證在方法異常完成時 monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器可處理所有的異常,它的目的就是用來執行 monitorexit 指令。從字節碼中也可以看出多了一個 monitorexit 指令。


鎖優化

自旋鎖與自適應自旋

如前面所述,synchronized 在 JDK 1.6 之前之所以被稱爲“重量級鎖”,是因爲對於互斥同步的性能來說,影響最大的就是阻塞的實現。掛起線程與恢復線程的操作都需要轉入內核態中完成。從用戶態轉入內核態是比較耗費系統性能的。

研究表明,大多數情況下,線程持有鎖的時間都不會太長,如果直接掛起操作系統層面的線程可能會得不償失,畢竟操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。自旋鎖會假設在不久將來,當前的線程可以獲得鎖,因此虛擬機會讓當前想要獲取鎖的線程做幾個空循環,使當前線程不放棄處理器的執行時間(這也是稱爲自旋的原因),在經過若干次循環後,如果得到鎖,就順利進入臨界區。

但是自旋不能代替阻塞,首先,自旋鎖需要多處理器或一個處理器擁有多個核心的 CPU 環境,這樣才能保證兩個及以上的線程並行執行(一個是獲取鎖的執行線程,一個是進行自旋的線程)。除了對處理器數量的要求外,自旋雖然避免了線程切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間比較短,自旋的效果就比較好,否則只是白白佔用了 CPU 資源,帶來性能上的浪費。

那麼自旋就需要有一定的限度,如果自旋超過了一定的次數後,還沒有成功獲取鎖,就只能進行掛起了,這個次數默認是 10。

在 JDK 1.4.2 中引入了自旋鎖,在 JDK 1.6 中引入了自適應自旋鎖。自適應意味自旋的時間不再固定:

如果同一個鎖對象上,自旋等待剛剛成功獲取鎖,並且持有鎖的線程正在運行,那麼虛擬機就會認爲此次自旋也很有可能成功,進而它將允許自旋等待持續相對更長的時間,比如 100 個循環。如果對於某個鎖,自旋很少成功獲取過,那麼在以後獲取這個鎖時將可能自動省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越精準,虛擬機也就會越來越“聰明”。


鎖消除

消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java 虛擬機在 JIT 編譯時(關於 JIT 編譯可以參考我的這篇博客:JVM–解析運行期優化與JIT編譯器),通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間。

鎖消除的主要判定依據來源於逃逸分析技術的支持(關於逃逸分析技術可以參考周志明老師所出的《深入理解 Java 虛擬機》一書中第 11 章內容或自行百度)。

也許你會有疑惑,變量是否逃逸,程序員本身應該就可以判斷,怎麼會存在明知道不存在數據爭用的情況下還使用同步?來看如下代碼:

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

由於 String 是一個不可變類,因此對字符串的連接操作總是通過新生成的 String 對象來進行的,在 JDK 1.5 之前,javac 編譯器會對 String 連接進行自動優化,將連接轉換爲 StringBuffer 對象的連續 append 操作,在 JDK 1.5 之後,會轉化爲 StringBuilder 對象的連續 append 操作。也就是說,上述代碼經過 javac 優化之後,有可能變爲下面這樣:

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

    return sb.toString();
}

StringBuffer 是一個線程安全的類,在它的 append 方法中有一個同步塊,鎖對象就是 sb,但是虛擬機觀察變量 sb,發現它是一個局部變量,本身線程安全,並不需要額外的同步機制。因此,這裏雖然有鎖,但可以被安全的清除,在 JIT 編譯之後,這段代碼就會忽略掉所有的同步而直接執行。這就是鎖消除。


鎖粗化

原則上,我們在使用同步塊的時候,總是建議將同步塊的作用範圍限制的儘量小—使需要同步的操作數量儘可能變小,在存在鎖競爭的情況下,等待鎖的線程可以儘快的拿到鎖。

大部分情況下,上述原則都正確,但是存在特殊情況,如果一系列操作下來,都對同一個對象反覆加鎖與解鎖,甚至加鎖與解鎖操作出現在循環體中,那即使沒有線程競爭,頻繁的進行互斥同步操作也會導致不必要的性能損耗。

如上述代碼中的 append 方法。如果虛擬機探測到了這樣的操作,就會把加鎖的同步範圍擴展(粗化)到整個操作序列的外部。以上述代碼爲例,就是擴展到第一個 append 操作之前直至最後一個 append 操作之後,這樣只需要加鎖一次。


偏向鎖

偏向鎖會偏向第一個獲取它的線程,如果在接下來的執行過程中,該鎖沒有被其他線程獲取,則持有偏向鎖的線程將永遠不需要進行同步。

HotSpot 的作者經過以往的研究發現大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得(比如在單線程中使用 StringBuffer 類),爲了讓線程獲得鎖的代價更低而引入了偏向鎖。當鎖對象第一次被線程獲取的時候,虛擬機把對象頭中的標誌位設爲“01”,即偏向模式。同時使用 CAS 操作把獲取這個鎖的線程 ID 記錄在對象的 Mark Word 中,如果 CAS 操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不用進行任何同步操作。

當有另一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。

此處輸入圖片的描述

如上圖,當線程 2 爭奪鎖對象時,偏向模式宣告結束。由線程 2 通知線程 1 進行偏向鎖的撤銷,此時線程 1 在全局安全點(沒有字節碼執行的地方)處進行暫停,進行解鎖操作。

偏向鎖只能被第一個獲取它的線程進行 CAS 操作,一旦出現線程競爭鎖對象,其它線程無論何時進行 CAS 操作都會失敗。

在解鎖成功之後,JVM 將判斷當前線程的狀態,如果還沒有執行完同步代碼塊,則直接將偏向鎖膨脹爲輕量級鎖,然後繼續執行同步代碼塊,否則將偏向鎖先撤銷爲無鎖狀態,當下一次執行同步代碼塊的時候再由 JVM 將其膨脹爲輕量級鎖。

使用偏向鎖的優點在於在沒有多線程競爭的情況下,只需進行一次 CAS 操作,就可執行同步代碼塊,但是我們也必須保證撤銷偏向鎖所耗費的性能資源要低於省去加鎖取鎖所節省下來的性能資源。


輕量級鎖

偏向鎖一旦受到多線程競爭,就會膨脹爲輕量級鎖。

偏向鎖在執行同步塊的時候不用做任何同步操作,而輕量級鎖是在多線程交替執行同步代碼塊,不產生線程阻塞的情況下使用 CAS 操作去消除同步使用的互斥量。

輕量級鎖加鎖:線程在執行同步塊之前,如果同步對象沒有被鎖定,JVM 會先在當前線程的棧楨中創建用於存儲鎖記錄(Lock Record)的空間,並將對象頭中的 Mark Word 複製到鎖記錄中,官方稱爲 Displaced Mark Word。然後線程嘗試使用 CAS 將對象頭中的 Mark Word 替換爲指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖,如果自旋還是無法獲取到鎖,輕量級鎖便會膨脹爲重量級鎖。

輕量級鎖解鎖:輕量級解鎖時,會使用 CAS 操作來將 Displaced Mark Word 替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖:

此處輸入圖片的描述

如上圖,當線程 1 還在使用輕量級鎖執行同步代碼塊的時候,線程 2 嘗試爭奪輕量級鎖,就會失敗,失敗之後線程 2 並不會直接將輕量級鎖膨脹爲重量級鎖,而是先進行自旋等待,如果成功獲取到鎖,則不進行鎖的膨脹。在線程 2 成功將鎖升級之後,線程 2 進行阻塞。線程 1 執行完同步代碼塊之後嘗試 CAS 解鎖,解鎖失敗,發現有線程對鎖進行過競爭,則釋放鎖並喚醒等待線程。


補充

鎖的升級

鎖主要存在四種狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨着競爭的激烈而逐漸升級。鎖可以升級不可降級,這種策略是爲了提高獲得鎖和釋放鎖的效率。


各個狀態鎖的優缺點對比

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

總結

  1. synchronized 的底層實現主要依靠 Monitor(管程);
  2. 從管程我們需要延伸至 Java 對象頭這一部分;
  3. 瞭解過 Java 對象頭之後,我們可以對 Monitor 的底層實現(ObjectMonitor)再進行簡單的瞭解;
  4. 熟悉多線程爭奪 Monitor 的過程;
  5. 最後分類討論同步方法與同步塊;
  6. 熟悉鎖粗化、鎖消除、自旋與自適應自旋等相關概念;
  7. 熟悉偏向鎖、輕量級鎖、重量級鎖的相關概念;
  8. 熟悉偏向鎖、輕量級鎖解鎖的過程;
  9. 熟悉偏向鎖、輕量級鎖、重量級鎖膨脹的過程。

參考閱讀

《深入理解Java虛擬機》–周志明

深入理解Java併發之synchronized實現原理

聊聊併發(二)Java SE 1.6中的Synchronized

死磕 Java 併發 - 深入分析 synchronized 的實現原理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章