synchronized 的超多幹貨!

程式設計師cxuan發表於2021-06-02

前言

synchronized 這個關鍵字的重要性不言而喻,幾乎可以說是併發、多執行緒必須會問到的關鍵字了。synchronized 會涉及到鎖、升級降級操作、鎖的撤銷、物件頭等。所以理解 synchronized 非常重要,本篇文章就帶你從 synchronized 的基本用法、再到 synchronized 的深入理解,物件頭等,為你揭開 synchronized 的面紗。

淺析 synchronized

synchronized 是 Java 併發模組非常重要的關鍵字,它是 Java 內建的一種同步機制,代表了某種內在鎖定的概念,當一個執行緒對某個共享資源加鎖後,其他想要獲取共享資源的執行緒必須進行等待,synchronized 也具有互斥和排他的語義。

什麼是互斥?我們想必小時候都玩兒過磁鐵,磁鐵會有正負極的概念,同性相斥異性相吸,相斥相當於就是一種互斥的概念,也就是兩者互不相容。

synchronized 也是一種獨佔的關鍵字,但是它這種獨佔的語義更多的是為了增加執行緒安全性,通過獨佔某個資源以達到互斥、排他的目的。

在瞭解了排他和互斥的語義後,我們先來看一下 synchronized 的用法,先來了解用法,再來了解底層實現。

synchronized 的使用

關於 synchronized 想必你應該都大致瞭解過

  • synchronized 修飾例項方法,相當於是對類的例項進行加鎖,進入同步程式碼前需要獲得當前例項的鎖
  • synchronized 修飾靜態方法,相當於是對類物件進行加鎖
  • synchronized 修飾程式碼塊,相當於是給物件進行加鎖,在進入程式碼塊前需要先獲得物件的鎖

下面我們針對每個用法進行解釋

synchronized 修飾例項方法

synchronized 修飾例項方法,例項方法是屬於類的例項。synchronized 修飾的例項方法相當於是物件鎖。下面是一個 synchronized 修飾例項方法的例子。

public synchronized void method()
{
   // ...
}

像如上述 synchronized 修飾的方法就是例項方法,下面我們通過一個完整的例子來認識一下 synchronized 修飾例項方法

public class TSynchronized implements Runnable{

    static int i = 0;

    public synchronized void increase(){
        i++;
        System.out.println(Thread.currentThread().getName());
    }


    @Override
    public void run() {
        for(int i = 0;i < 1000;i++) {
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        TSynchronized tSynchronized = new TSynchronized();
        Thread aThread = new Thread(tSynchronized);
        Thread bThread = new Thread(tSynchronized);
        aThread.start();
        bThread.start();
        aThread.join();
        bThread.join();
        System.out.println("i = " + i);
    }
}

上面輸出的結果 i = 2000 ,並且每次都會列印當前現成的名字

來解釋一下上面程式碼,程式碼中的 i 是一個靜態變數,靜態變數也是全域性變數,靜態變數儲存在方法區中。increase 方法由 synchronized 關鍵字修飾,但是沒有使用 static 關鍵字修飾,表示 increase 方法是一個例項方法,每次建立一個 TSynchronized 類的同時都會建立一個 increase 方法,increase 方法中只是列印出來了當前訪問的執行緒名稱。Synchronized 類實現了 Runnable 介面,重寫了 run 方法,run 方法裡面就是一個 0 - 1000 的計數器,這個沒什麼好說的。在 main 方法中,new 出了兩個執行緒,分別是 aThread 和 bThread,Thread.join 表示等待這個執行緒處理結束。這段程式碼主要的作用就是判斷 synchronized 修飾的方法能夠具有獨佔性。

synchronized 修飾靜態方法

synchronized 修飾靜態方法就是 synchronized 和 static 關鍵字一起使用

public static synchronized void increase(){}

當 synchronized 作用於靜態方法時,表示的就是當前類的鎖,因為靜態方法是屬於類的,它不屬於任何一個例項成員,因此可以通過 class 物件控制併發訪問。

這裡需要注意一點,因為 synchronized 修飾的例項方法是屬於例項物件,而 synchronized 修飾的靜態方法是屬於類物件,所以呼叫 synchronized 的例項方法並不會阻止訪問 synchronized 的靜態方法。

synchronized 修飾程式碼塊

synchronized 除了修飾例項方法和靜態方法外,synchronized 還可用於修飾程式碼塊,程式碼塊可以巢狀在方法體的內部使用。

public void run() {
  synchronized(obj){
    for(int j = 0;j < 1000;j++){
      i++;
    }
  }
}

上面程式碼中將 obj 作為鎖物件對其加鎖,每次當執行緒進入 synchronized 修飾的程式碼塊時就會要求當前執行緒持有obj 例項物件鎖,如果當前有其他執行緒正持有該物件鎖,那麼新到的執行緒就必須等待。

synchronized 修飾的程式碼塊,除了可以鎖定物件之外,也可以對當前例項物件鎖、class 物件鎖進行鎖定

// 例項物件鎖
synchronized(this){
    for(int j = 0;j < 1000;j++){
        i++;
    }
}

//class物件鎖
synchronized(TSynchronized.class){
    for(int j = 0;j < 1000;j++){
        i++;
    }
}

synchronized 底層原理

在簡單介紹完 synchronized 之後,我們就來聊一下 synchronized 的底層原理了。

我們或許都有所瞭解(下文會細緻分析),synchronized 的程式碼塊是由一組 monitorenter/monitorexit 指令實現的。而Monitor 物件是實現同步的基本單元。

啥是 Monitor 物件呢?

Monitor 物件

任何物件都關聯了一個管程,管程就是控制物件併發訪問的一種機制管程 是一種同步原語,在 Java 中指的就是 synchronized,可以理解為 synchronized 就是 Java 中對管程的實現。

管程提供了一種排他訪問機制,這種機制也就是 互斥。互斥保證了在每個時間點上,最多隻有一個執行緒會執行同步方法。

所以你理解了 Monitor 物件其實就是使用管程控制同步訪問的一種物件。

物件記憶體佈局

hotspot 虛擬機器中,物件在記憶體中的佈局分為三塊區域:

  • 物件頭(Header)
  • 例項資料(Instance Data)
  • 對齊填充(Padding)

這三塊區域的記憶體分佈如下圖所示

我們來詳細介紹一下上面物件中的內容。

物件頭 Header

物件頭 Header 主要包含 MarkWord 和物件指標 Klass Pointer,如果是陣列的話,還要包含陣列的長度。

img

在 32 位的虛擬機器中 MarkWord ,Klass Pointer 和陣列長度分別佔用 32 位,也就是 4 位元組。

如果是 64 位虛擬機器的話,MarkWord ,Klass Pointer 和陣列長度分別佔用 64 位,也就是 8 位元組。

在 32 位虛擬機器和 64 位虛擬機器的 Mark Word 所佔用的位元組大小不一樣,32 位虛擬機器的 Mark Word 和 Klass Pointer 分別佔用 32 bits 的位元組,而 64 位虛擬機器的 Mark Word 和 Klass Pointer 佔用了64 bits 的位元組,下面我們以 32 位虛擬機器為例,來看一下其 Mark Word 的位元組具體是如何分配的。

img

img

用中文翻譯過來就是

img

  • 無狀態也就是無鎖的時候,物件頭開闢 25 bit 的空間用來儲存物件的 hashcode ,4 bit 用於存放分代年齡,1 bit 用來存放是否偏向鎖的標識位,2 bit 用來存放鎖標識位為 01。
  • 偏向鎖 中劃分更細,還是開闢 25 bit 的空間,其中 23 bit 用來存放執行緒ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標識, 0 表示無鎖,1 表示偏向鎖,鎖的標識位還是 01。
  • 輕量級鎖中直接開闢 30 bit 的空間存放指向棧中鎖記錄的指標,2bit 存放鎖的標誌位,其標誌位為 00。
  • 重量級鎖中和輕量級鎖一樣,30 bit 的空間用來存放指向重量級鎖的指標,2 bit 存放鎖的標識位,為 11
  • GC標記開闢 30 bit 的記憶體空間卻沒有佔用,2 bit 空間存放鎖標誌位為 11。

其中無鎖和偏向鎖的鎖標誌位都是 01,只是在前面的 1 bit 區分了這是無鎖狀態還是偏向鎖狀態。

關於為什麼這麼分配的記憶體,我們可以從 OpenJDK 中的markOop.hpp類中的列舉窺出端倪

img

來解釋一下

  • age_bits 就是我們說的分代回收的標識,佔用4位元組
  • lock_bits 是鎖的標誌位,佔用2個位元組
  • biased_lock_bits 是是否偏向鎖的標識,佔用1個位元組。
  • max_hash_bits 是針對無鎖計算的 hashcode 佔用位元組數量,如果是 32 位虛擬機器,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機器,64 - 4 - 2 - 1 = 57 byte,但是會有 25 位元組未使用,所以 64 位的 hashcode 佔用 31 byte。
  • hash_bits 是針對 64 位虛擬機器來說,如果最大位元組數大於 31,則取 31,否則取真實的位元組數
  • cms_bits 我覺得應該是不是 64 位虛擬機器就佔用 0 byte,是 64 位就佔用 1byte
  • epoch_bits 就是 epoch 所佔用的位元組大小,2 位元組。

在上面的虛擬機器物件頭分配表中,我們可以看到有幾種鎖的狀態:無鎖(無狀態),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進行優化後新增加的,其目的就是為了大大優化鎖的效能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那麼大了。其實從鎖有無鎖定來講,還是隻有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現就是增加了鎖的獲取效能而已,並沒有出現新的鎖。

所以我們的重點放在對 synchronized 重量級鎖的研究上,當 monitor 被某個執行緒持有後,它就會處於鎖定狀態。在 HotSpot 虛擬機器中,monitor 的底層程式碼是由 ObjectMonitor 實現的,其主要資料結構如下(位於 HotSpot 虛擬機器原始碼 ObjectMonitor.hpp 檔案,C++ 實現的)

這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的執行緒都會被封裝稱為 ObjectWaiter 物件。

_Owner 是指向了 ObjectMonitor 物件的執行緒,而 _WaitSet 和 _EntryList 就是用來儲存每個執行緒的列表。

那麼這兩個列表有什麼區別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。

鎖的兩個列表

當多個執行緒同時訪問某段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的 monitor 之後,就會進入 _Owner 區域,並把 ObjectMonitor 物件的 _Owner 指向為當前執行緒,並使 _count + 1,如果呼叫了釋放鎖(比如 wait)的操作,就會釋放當前持有的 monitor ,owner = null, _count - 1,同時這個執行緒會進入到 _WaitSet 列表中等待被喚醒。如果當前執行緒執行完畢後也會釋放 monitor 鎖,只不過此時不會進入 _WaitSet 列表了,而是直接復位 _count 的值。

Klass Pointer 表示的是型別指標,也就是物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

你可能不是很理解指標是個什麼概念,你可以簡單理解為指標就是指向某個資料的地址。

例項資料 Instance Data

例項資料部分是物件真正儲存的有效資訊,也是程式碼中定義的各個欄位的位元組大小,比如一個 byte 佔 1 個位元組,一個 int 佔用 4 個位元組。

對齊 Padding

對齊不是必須存在的,它只起到了佔位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求物件的起始地址必須是 8 位元組的整數倍,也就是說物件的位元組大小是 8 的整數倍,不夠的需要使用 Padding 補全。

鎖的升級流程

先來個大體的流程圖來感受一下這個過程,然後下面我們再分開來說

img

無鎖

無鎖狀態,無鎖即沒有對資源進行鎖定,所有的執行緒都可以對同一個資源進行訪問,但是隻有一個執行緒能夠成功修改資源。

img

無鎖的特點就是在迴圈內進行修改操作,執行緒會不斷的嘗試修改共享資源,直到能夠成功修改資源並退出,在此過程中沒有出現衝突的發生,這很像我們在之前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的效能是非常高的。

偏向鎖

HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,還存在鎖由同一執行緒多次獲得的情況,偏向鎖就是在這種情況下出現的,它的出現是為了解決只有在一個執行緒執行同步時提高效能。

img

可以從物件頭的分配中看到,偏向鎖要比無鎖多了執行緒IDepoch,下面我們就來描述一下偏向鎖的獲取過程

偏向鎖獲取過程

  1. 首先執行緒訪問同步程式碼塊,會通過檢查物件頭 Mark Word 的鎖標誌位判斷目前鎖的狀態,如果是 01,說明就是無鎖或者偏向鎖,然後再根據是否偏向鎖 的標示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執行下一步
  2. 執行緒使用 CAS 操作來嘗試對物件加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那麼當前執行緒就會獲得物件的偏向鎖,此時會在物件頭的 Mark Word 中記錄當前執行緒 ID 和獲取鎖的時間 epoch 等資訊,然後執行同步程式碼塊。

全域性安全點(Safe Point):全域性安全點的理解會涉及到 C 語言底層的一些知識,這裡簡單理解 SafePoint 是 Java 程式碼中的一個執行緒可能暫停執行的位置。

等到下一次執行緒在進入和退出同步程式碼塊時就不需要進行 CAS 操作進行加鎖和解鎖,只需要簡單判斷一下物件頭的 Mark Word 中是否儲存著指向當前執行緒的執行緒ID,判斷的標誌當然是根據鎖的標誌位來判斷的。如果用流程圖來表示的話就是下面這樣

img

關閉偏向鎖

偏向鎖在Java 6 和Java 7 裡是預設啟用的。由於偏向鎖是為了在只有一個執行緒執行同步塊時提高效能,如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態。

關於 epoch

偏向鎖的物件頭中有一個被稱為 epoch 的值,它作為偏差有效性的時間戳。

輕量級鎖

輕量級鎖是指當前鎖是偏向鎖的時候,資源被另外的執行緒所訪問,那麼偏向鎖就會升級為輕量級鎖,其他執行緒會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高效能,下面是詳細的獲取過程。

輕量級鎖加鎖過程

  1. 緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執行下一步
  2. 如果使用 CAS 操作替換 ThreadID 失敗(這時候就切換到另外一個執行緒的角度)說明該資源已被同步訪問過,這時候就會執行鎖的撤銷操作,撤銷偏向鎖,然後等原持有偏向鎖的執行緒到達全域性安全點(SafePoint)時,會暫停原持有偏向鎖的執行緒,然後會檢查原持有偏向鎖的狀態,如果已經退出同步,就會喚醒持有偏向鎖的執行緒,執行下一步
  3. 檢查物件頭中的 Mark Word 記錄的是否是當前執行緒 ID,如果是,執行同步程式碼,如果不是,執行偏向鎖獲取流程 的第2步。

如果用流程表示的話就是下面這樣(已經包含偏向鎖的獲取)

img

重量級鎖

重量級鎖其實就是 synchronized 最終加鎖的過程,在 JDK 1.6 之前,就是由無鎖 -> 加鎖的這個過程。

重量級鎖的獲取流程

  1. 接著上面偏向鎖的獲取過程,由偏向鎖升級為輕量級鎖,執行下一步
  2. 會在原持有偏向鎖的執行緒的棧中分配鎖記錄,將物件頭中的 Mark Word 拷貝到原持有偏向鎖執行緒的記錄中,然後原持有偏向鎖的執行緒獲得輕量級鎖,然後喚醒原持有偏向鎖的執行緒,從安全點處繼續執行,執行完畢後,執行下一步,當前執行緒執行第 4 步
  3. 執行完畢後,開始輕量級解鎖操作,解鎖需要判斷兩個條件
    • 判斷物件頭中的 Mark Word 中鎖記錄指標是否指向當前棧中記錄的指標

img

  • 拷貝在當前執行緒鎖記錄的 Mark Word 資訊是否與物件頭中的 Mark Word 一致。

如果上面兩個判斷條件都符合的話,就進行鎖釋放,如果其中一個條件不符合,就會釋放鎖,並喚起等待的執行緒,進行新一輪的鎖競爭。

  1. 在當前執行緒的棧中分配鎖記錄,拷貝物件頭中的 MarkWord 到當前執行緒的鎖記錄中,執行 CAS 加鎖操作,會把物件頭 Mark Word 中鎖記錄指標指向當前執行緒鎖記錄,如果成功,獲取輕量級鎖,執行同步程式碼,然後執行第3步,如果不成功,執行下一步
  2. 當前執行緒沒有使用 CAS 成功獲取鎖,就會自旋一會兒,再次嘗試獲取,如果在多次自旋到達上限後還沒有獲取到鎖,那麼輕量級鎖就會升級為 重量級鎖

img

如果用流程圖表示是這樣的

img

根據上面對於鎖升級細緻的描述,我們可以總結一下不同鎖的適用範圍和場景。

synchronized 程式碼塊的底層實現

為了便於方便研究,我們把 synchronized 修飾程式碼塊的示例簡單化,如下程式碼所示

public class SynchronizedTest {

    private int i;

    public void syncTask(){
        synchronized (this){
            i++;
        }
    }

}

我們主要關注一下 synchronized 的位元組碼,如下所示

從這段位元組碼中我們可以知道,同步語句塊使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步程式碼塊的開始位置,monitorexit 指令指向同步程式碼塊的結束位置。

那麼為什麼會有兩個 monitorexit 呢?

不知道你注意到下面的異常表了嗎?如果你不知道什麼是異常表,那麼我建議你讀一下這篇文章

看完這篇Exception 和 Error,和麵試官扯皮就沒問題了

synchronized 修飾方法的底層原理

方法的同步是隱式的,也就是說 synchronized 修飾方法的底層無需使用位元組碼來控制,真的是這樣嗎?我們來反編譯一波看看結果

public class SynchronizedTest {

    private int i;

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

這次我們使用 javap -verbose 來輸出詳細的結果

從位元組碼上可以看出,synchronized 修飾的方法並沒有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 標識,該標識指明瞭此方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。這就是 synchronized 鎖在同步程式碼塊上和同步方法上的實現差別。

我自己肝了六本 PDF,全網傳播超過10w+ ,微信搜尋「程式設計師cxuan」關注公眾號後,在後臺回覆 cxuan ,領取全部 PDF,這些 PDF 如下

六本 PDF 連結

相關文章