死磕synchronized底層實現

敖丙發表於2020-05-18

前言

多執行緒的東西很多,也很有意思,所以我最近的重心可能都是多執行緒的方向去靠了,不知道大家喜歡否?

閱讀本文之前閱讀以下兩篇文章會幫助你更好的理解:

Volatile

樂觀鎖&悲觀鎖

正文

場景

我們正常去使用Synchronized一般都是用在下面這幾種場景:

  • 修飾例項方法,對當前例項物件this加鎖

    public class Synchronized {
        public synchronized void husband(){
    
        }
    }
  • 修飾靜態方法,對當前類的Class物件加鎖

    public class Synchronized {
        public void husband(){
            synchronized(Synchronized.class){
    
            }
        }
    }
  • 修飾程式碼塊,指定一個加鎖的物件,給物件加鎖

    public class Synchronized {
        public void husband(){
            synchronized(new test()){
    
            }
        }
    }

其實就是鎖方法、鎖程式碼塊和鎖物件,那他們是怎麼實現加鎖的呢?

在這之前,我就先跟大家聊一下我們Java物件的構成

在 JVM 中,物件在記憶體中分為三塊區域:

  • 物件頭

    • Mark Word(標記欄位):預設儲存物件的HashCode,分代年齡和鎖標誌位資訊。它會根據物件的狀態複用自己的儲存空間,也就是說在執行期間Mark Word裡儲存的資料會隨著鎖標誌位的變化而變化。
    • Klass Point(型別指標):物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
  • 例項資料

    • 這部分主要是存放類的資料資訊,父類的資訊。
  • 對其填充

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

      Tip:不知道大家有沒有被問過一個空物件佔多少個位元組?就是8個位元組,是因為對齊填充的關係哈,不到8個位元組對其填充會幫我們自動補齊。

我們經常說到的,有序性、可見性、原子性,synchronized又是怎麼做到的呢?

有序性

我在Volatile章節已經說過了CPU會為了優化我們的程式碼,會對我們程式進行重排序。

as-if-serial

不管編譯器和CPU如何重排序,必須保證在單執行緒情況下程式的結果是正確的,還有就是有資料依賴的也是不能重排序的。

就比如:

int a = 1;
int b = a;

這兩段是怎麼都不能重排序的,b的值依賴a的值,a如果不先賦值,那就為空了。

可見性

同樣在Volatile章節我介紹到了現代計算機的記憶體結構,以及JMM(Java記憶體模型),這裡我需要說明一下就是JMM並不是實際存在的,而是一套規範,這個規範描述了很多java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節,Java記憶體模型是對共享資料的可見性、有序性、和原子性的規則和保障。

大家感興趣,也記得去了解計算機的組成部分,cpu、記憶體、多級快取等,會幫助更好的理解java這麼做的原因。

原子性

其實他保證原子性很簡單,確保同一時間只有一個執行緒能拿到鎖,能夠進入程式碼塊這就夠了。

這幾個是我們使用鎖經常用到的特性,那synchronized他自己本身又具有哪些特性呢?

可重入性

synchronized鎖物件的時候有個計數器,他會記錄下執行緒獲取鎖的次數,在執行完對應的程式碼塊之後,計數器就會-1,直到計數器清零,就釋放鎖了。

那可重入有什麼好處呢?

可以避免一些死鎖的情況,也可以讓我們更好封裝我們的程式碼。

不可中斷性

不可中斷就是指,一個執行緒獲取鎖之後,另外一個執行緒處於阻塞或者等待狀態,前一個不釋放,後一個也一直會阻塞或者等待,不可以被中斷。

值得一提的是,Lock的tryLock方法是可以被中斷的。

底層實現

這裡看實現很簡單,我寫了一個簡單的類,分別有鎖方法和鎖程式碼塊,我們反編譯一下位元組碼檔案,就可以了。

先看看我寫的測試類:

/**
 *@Description: Synchronize
 *@Author: 敖丙
 *@date: 2020-05-17
 **/
public class Synchronized {
    public synchronized void husband(){
        synchronized(new Volatile()){

        }
    }
}

編譯完成,我們去對應目錄執行 javap -c xxx.class 命令檢視反編譯的檔案:

MacBook-Pro-3:juc aobing$ javap -p -v -c Synchronized.class
Classfile /Users/aobing/IdeaProjects/Thanos/laogong/target/classes/juc/Synchronized.class
  Last modified 2020-5-17; size 375 bytes
  MD5 checksum 4f5451a229e80c0a6045b29987383d1a
  Compiled from "Synchronized.java"
public class juc.Synchronized
  minor version: 0
  major version: 49
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#14         // java/lang/Object."<init>":()V
   #2 = Class              #15            // juc/Synchronized
   #3 = Class              #16            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               LocalVariableTable
   #9 = Utf8               this
  #10 = Utf8               Ljuc/Synchronized;
  #11 = Utf8               husband
  #12 = Utf8               SourceFile
  #13 = Utf8               Synchronized.java
  #14 = NameAndType        #4:#5          // "<init>":()V
  #15 = Utf8               juc/Synchronized
  #16 = Utf8               java/lang/Object
{
  public juc.Synchronized();
    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   Ljuc/Synchronized;

  public synchronized void husband();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED  // 這裡
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // class juc/Synchronized
         2: dup
         3: astore_1
         4: monitorenter   // 這裡
         5: aload_1
         6: monitorexit    // 這裡
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit    // 這裡
        13: aload_2
        14: athrow
        15: return
      Exception table:
         from    to  target type
             5     7    10   any
            10    13    10   any
      LineNumberTable:
        line 10: 0
        line 12: 5
        line 13: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   Ljuc/Synchronized;
}
SourceFile: "Synchronized.java"

同步程式碼

大家可以看到幾處我標記的,我在最開始提到過物件頭,他會關聯到一個monitor物件。

  • 當我們進入一個人方法的時候,執行monitorenter,就會獲取當前物件的一個所有權,這個時候monitor進入數為1,當前的這個執行緒就是這個monitor的owner。
  • 如果你已經是這個monitor的owner了,你再次進入,就會把進入數+1.
  • 同理,當他執行完monitorexit,對應的進入數就-1,直到為0,才可以被其他執行緒持有。

所有的互斥,其實在這裡,就是看你能否獲得monitor的所有權,一旦你成為owner就是獲得者。

同步方法

不知道大家注意到方法那的一個特殊標誌位沒,ACC_SYNCHRONIZED

同步方法的時候,一旦執行到這個方法,就會先判斷是否有標誌位,然後,ACC_SYNCHRONIZED會去隱式呼叫剛才的兩個指令:monitorenter和monitorexit。

所以歸根究底,還是monitor物件的爭奪。

monitor

我說了這麼多次這個物件,大家是不是以為就是個虛無的東西,其實不是,monitor監視器原始碼是C++寫的,在虛擬機器的ObjectMonitor.hpp檔案中。

我看了下原始碼,他的資料結構長這樣:

  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;  // 執行緒重入次數
    _object       = NULL;  // 儲存Monitor物件
    _owner        = NULL;  // 持有當前執行緒的owner
    _WaitSet      = NULL;  // wait狀態的執行緒列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 單向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 處於等待鎖狀態block狀態的執行緒列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

這塊c++程式碼,我也放到了我的開源專案了,大家自行檢視。

synchronized底層的原始碼就是引入了ObjectMonitor,這一塊大家有興趣可以看看,反正我上面說的,還有大家經常聽到的概念,在這裡都能找到原始碼。

大家說熟悉的鎖升級過程,其實就是在原始碼裡面,呼叫了不同的實現去獲取獲取鎖,失敗就呼叫更高階的實現,最後升級完成。

1.5 重量級鎖

大家在看ObjectMonitor原始碼的時候,會發現Atomic::cmpxchg_ptr,Atomic::inc_ptr等核心函式,對應的執行緒就是park()和upark()。

這個操作涉及使用者態和核心態的轉換了,這種切換是很耗資源的,所以知道為啥有自旋鎖這樣的操作了吧,按道理類似死迴圈的操作更費資源才是對吧?其實不是,大家瞭解一下就知道了。

那使用者態和核心態又是啥呢?

Linux系統的體系結構大家大學應該都接觸過了,分為使用者空間(應用程式的活動空間)和核心。

我們所有的程式都在使用者空間執行,進入使用者執行狀態也就是(使用者態),但是很多操作可能涉及核心執行,比我I/O,我們就會進入核心執行狀態(核心態)。

這個過程是很複雜的,也涉及很多值的傳遞,我簡單概括下流程:

  1. 使用者態把一些資料放到暫存器,或者建立對應的堆疊,表明需要作業系統提供的服務。
  2. 使用者態執行系統呼叫(系統呼叫是作業系統的最小功能單位)。
  3. CPU切換到核心態,跳到對應的記憶體指定的位置執行指令。
  4. 系統呼叫處理器去讀取我們先前放到記憶體的資料引數,執行程式的請求。
  5. 呼叫完成,作業系統重置CPU為使用者態返回結果,並執行下個指令。

所以大家一直說,1.6之前是重量級鎖,沒錯,但是他重量的本質,是ObjectMonitor呼叫的過程,以及Linux核心的複雜執行機制決定的,大量的系統資源消耗,所以效率才低。

還有兩種情況也會發生核心態和使用者態的切換:異常事件和外圍裝置的中斷 大家也可以瞭解下。

1.6 優化鎖升級

那都說過了效率低,官方也是知道的,所以他們做了升級,大家如果看了我剛才提到的那些原始碼,就知道他們的升級其實也做得很簡單,只是多了幾個函式呼叫,不過不得不設計還是很巧妙的。

我們就來看一下升級後的鎖升級過程:

簡單版本:

升級方向:

Tip:切記這個升級過程是不可逆的,最後我會說明他的影響,涉及使用場景。

看完他的升級,我們就來好好聊聊每一步怎麼做的吧。

偏向鎖

之前我提到過了,物件頭是由Mark Word和Klass pointer 組成,鎖爭奪也就是物件頭指向的Monitor物件的爭奪,一旦有執行緒持有了這個物件,標誌位修改為1,就進入偏向模式,同時會把這個執行緒的ID記錄在物件的Mark Word中。

這個過程是採用了CAS樂觀鎖操作的,每次同一執行緒進入,虛擬機器就不進行任何同步的操作了,對標誌位+1就好了,不同執行緒過來,CAS會失敗,也就意味著獲取鎖失敗。

偏向鎖在1.6之後是預設開啟的,1.5中是關閉的,需要手動開啟引數是xx:-UseBiasedLocking=false。

偏向鎖關閉,或者多個執行緒競爭偏向鎖怎麼辦呢?

輕量級鎖

還是跟Mark Work 相關,如果這個物件是無鎖的,jvm就會在當前執行緒的棧幀中建立一個叫鎖記錄(Lock Record)的空間,用來儲存鎖物件的Mark Word 拷貝,然後把Lock Record中的owner指向當前物件。

JVM接下來會利用CAS嘗試把物件原本的Mark Word 更新會Lock Record的指標,成功就說明加鎖成功,改變鎖標誌位,執行相關同步操作。

如果失敗了,就會判斷當前物件的Mark Word是否指向了當前執行緒的棧幀,是則表示當前的執行緒已經持有了這個物件的鎖,否則說明被其他執行緒持有了,繼續鎖升級,修改鎖的狀態,之後等待的執行緒也阻塞。

自旋鎖

我不是在上面提到了Linux系統的使用者態和核心態的切換很耗資源,其實就是執行緒的等待喚起過程,那怎麼才能減少這種消耗呢?

自旋,過來的現在就不斷自旋,防止執行緒被掛起,一旦可以獲取資源,就直接嘗試成功,直到超出閾值,自旋鎖的預設大小是10次,-XX:PreBlockSpin可以修改。

自旋都失敗了,那就升級為重量級的鎖,像1.5的一樣,等待喚起咯。

至此我基本上吧synchronized的前後概念都講到了,大家好好消化。

資料參考:《高併發程式設計》《黑馬程式設計師講義》《深入理解JVM虛擬機器》

用synchronized還是Lock呢?

我們先看看他們的區別:

  • synchronized是關鍵字,是JVM層面的底層啥都幫我們做了,而Lock是一個介面,是JDK層面的有豐富的API。
  • synchronized會自動釋放鎖,而Lock必須手動釋放鎖。
  • synchronized是不可中斷的,Lock可以中斷也可以不中斷。
  • 通過Lock可以知道執行緒有沒有拿到鎖,而synchronized不能。
  • synchronized能鎖住方法和程式碼塊,而Lock只能鎖住程式碼塊。
  • Lock可以使用讀鎖提高多執行緒讀效率。
  • synchronized是非公平鎖,ReentrantLock可以控制是否是公平鎖。

兩者一個是JDK層面的一個是JVM層面的,我覺得最大的區別其實在,我們是否需要豐富的api,還有一個我們的場景。

比如我現在是滴滴,我早上有叫車高峰,我程式碼使用了大量的synchronized,有什麼問題?鎖升級過程是不可逆的,過了高峰我們還是重量級的鎖,那效率是不是大打折扣了?這個時候你用Lock是不是很好?

場景是一定要考慮的,我現在告訴你哪個好都是扯淡,因為脫離了業務,一切技術討論都沒有了價值。

我git上的腦圖我每次寫完我都會重新更新,大家可以沒事去看看。

我是敖丙,一個在網際網路苟且偷生的工具人。

你知道的越多,你不知道的越多人才們的 【三連】 就是丙丙創作的最大動力,我們下期見!

注:如果本篇部落格有任何錯誤和建議,歡迎人才們留言!

相關文章