【Java併發程式設計】Synchronized關鍵字實現原理

炒燜煎糖板栗發表於2022-06-06

想必在面試中經常會被問到Synchronized關鍵字,它有什麼特性,原理什麼

它的主要特性是同步鎖、非公平鎖、阻塞鎖、可以保證執行緒安全(可見性、原子性、有序性)

JDK1.6之後對Synchronized有優化,有個鎖升級過程

Synchronized之保障執行緒安全

多執行緒情況下保障執行緒安全的方法有很多,一般都是通過加鎖去競爭同一個資源,來達到互斥的效果,那麼Synchronized是如何保障執行緒安全的呢

原子性

它的主要含義是要麼全部成功要麼全部失敗,不允許部分成功部分失敗,多執行緒中原子性是指一個或者多個操作在CPU中執行的過程中出現了被中斷的情況

原子性產生的原因主要是有兩個:

  • CPU時間切換

    CPU處於空閒狀態就會把時間片分配給其他執行緒進行處理,有兩個執行緒對變數進行修改,會有一個A、執行緒先得到CPU的執行權,它將變數載入到暫存器後CPU切換為另一個B執行緒執行,B執行緒同樣載入變數到暫存器,最後把結果寫回記憶體,這時候兩個變數值可能會一致

  • 程式本身執行不具備原子性

    這個可以用常見的i++來說明,i++本身不具備原子性,因為它分為了三個操作,先獲取值,加一,賦值。這裡每一步都是原子性,可是組合在一起就不具備原子性

解決原子性的辦法有兩個

  • 通過一個互斥條件來達到同時一刻只有一個執行緒執行

  • 使操作具有原子性,不允許執行過程被中斷

為了保證原子性可以在方法上加上Synchronized關鍵字

可見性

為什麼會存在可見性問題?

快取記憶體

它的本質是因為,CPU是計算機的核心,它在做運算的時候無法避免從記憶體中讀取資料和指令,即使儲存在磁碟的資料也要載入到記憶體中CPU才能訪問,CPU與記憶體之間無法避免IO操作。CPU向記憶體發起讀取操作,需要等待記憶體返回結果,此時CPU處於等待狀態,如果等待返回之後CPU再執行其他指令會浪費CPU資源。因此在硬體、作業系統、編譯器都做了不少優化,正因為這些優化導致出現了可見性問題

例如加入了CPU快取記憶體,高速的快取的作用就是CPU在讀取資料的時候會先從快取記憶體中讀取,如果快取記憶體中沒有就會從記憶體中讀取

快取記憶體又分為三部分:L1、L2、L3

image-20220529174835137

每塊CPU裡有多個核心,而每個核心都維護了自己的快取

L1和2屬於CPU核內私有快取,L3屬於共享快取,三塊快取從儲存的資料大小排序來說L3>L2>L1,從訪問速度來說L1>L2>L3

訪問資料從L1中開始查詢,然後是L2,最後訪問L3如果還沒有命中就會從記憶體中載入資料,載入資料會從L3到L2最後到L1

快取一致性問題

雖然有快取記憶體提高了訪問速度,但是一個CPU有多核,每一個執行緒可能執行在不同的CPU核內,如果多個執行緒同時訪問資料,那麼同一份資料就有可能被快取到多個CPU核內,就會存在快取一致性問題:兩個執行緒同時載入一塊資料到CPU快取記憶體中時,如何保證一個資料被修改後在其他快取中的值也能保持一致,而不是獲取到的初始值。CPU解決這個問題使用到了

匯流排鎖

作業系統使用匯流排鎖可以解決這個快取一致性問題,它的原理就是在CPU與記憶體傳輸的通道上加了一個LOCK#訊號,這個訊號確保同一時刻只有當前CPU才能訪問共享記憶體,使得其他處理器對記憶體的操作請求都會被阻塞,但是這樣又會使CPU的使用效率下降。

快取鎖

為了CPU使用效率下降解決這個問題,引入了快取鎖,當資料已經存在快取記憶體中的某個CPU核內私有區域,不使用匯流排鎖而使用快取一致性解決問題

快取一致性

快取鎖就是通過快取一致性協議來保證一致性的,不同的CPU支援的快取一致性協議不同,比較常見就是MSIMESIMOSIMESIF,最常用的就是MESI(Modify Exclusive Shared Invalid),它表示四種狀態:

  • M(Modify) 表示共享資料只快取在當前 CPU 快取中, 並且是被修改狀態,也就是快取的資料和主記憶體中的資料不一致
  • E(Exclusive) 表示快取的獨佔狀態,資料只快取在當前 CPU 快取中,並且沒有被修改
  • S(Shared) 表示資料可能被多個 CPU 快取,並且各個快取中的資料和主記憶體資料一致
  • I(Invalid) 表示快取已經失效

這四種狀態會基於快取行的狀態而變化, 不同的狀態會有不同的監聽任務

  • 一個處於M狀態的快取行,必須時刻監聽所有試圖讀取該快取行對應的主存地址的操作,如果監聽到,則必須在此操作執行前把其快取行中的資料寫回記憶體
  • 一個處於S狀態的快取行,必須時刻監聽使該快取行無效或者獨享該快取行的請求,如果監聽到,則必須把其快取行狀態設定為I
  • 一個處於E狀態的快取行,必須時刻監聽其他試圖讀取該快取行對應的主存地址的操作,如果監聽到,則必須把其快取行狀態設定為S

監聽過程使基於嗅探協議完成的,該協議要求每個CPU都可以監聽到匯流排上的資料事件變化並作出反應,這個快取一致性原理就是

  1. 首先CPU0傳送一個指令從主記憶體中讀取x變數,然後載入到了快取記憶體中,這時候快取的狀態為E

    image-20220529174800719

  2. 如果CPU1這時候也要讀取x變數的值,就會檢測到本地含有該快取發生衝突,CPU0會通過嗅探協議裡面的Read Response訊息響應給CPU1,這時候x變數存在於CPU0和CPU1中,快取的狀態變成了S

    image-20220529174738475

  3. CPU0拿到x變數的值以後進行修改為x=20,寫入主記憶體中,這時候快取的狀態變為了E,快取行變為共享狀態,同時還需要傳送一個Invalidate訊息給其他快取,其他快取CPU1收到後快取狀態變為Invaild,CPU1裡面的x變數值快取失效,需要從主記憶體中重新獲取值

    image-20220529175627202

這個就是基於快取一致性保證快取的一致性原理

synchronized就是基於該原理,對進入同一個鎖(監視器)的執行緒保證可見性,在修改了本地記憶體中的變數後,解鎖前會將本地記憶體修改的內容重新整理到主記憶體中,確保了共享變數的值是最新的,也就保證了可見性

Happens-Before

在JMM記憶體模型中還定義了一個Happens-Before模型用來保證可見性,這個模型主要描述的就是兩個指令操作之間的關係,如果 A happens-before B,意思就是A發生在B之前,那麼A的結果對B可見,它主要有如下常見6種規則。

  • 程式順序規則:一個執行緒中,按照程式順序,前面的操作 Happens-Before 於後續的任意操作
  • 傳遞性規則:如果A happens-before B,且B happens-before C,那麼A happens-before C
  • Volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀
  • 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖
  • start規則:這條是關於執行緒啟動的。它是指主執行緒 A 啟動子執行緒 B 後,子執行緒 B 能夠看到主執行緒在啟動子執行緒 B 前的操作
  • join規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回

我們只需要理解Happens-Before規則,既可以編寫執行緒安全的程式了

有序性

CPU為了提升效能會對編譯器、處理器以及程式碼指令重排序,這種排序在單執行緒下沒問題結果不會受到影響,但如果是多執行緒操作下就不一定了,可能出現髒資料

就拿最容易復原的指令重排序如何影響程式執行的結果

int x=0;
int y=0;

void handleA(){
  int x=10;
  int y=20;
}

void handleB(){
  if(y==20){
     assert(x==10);
  }
}

指令重排序就是程式的執行順序和程式碼的編寫順序不一定一致,兩個執行緒同時執行可能會出現在handleB方法裡面y== 20的情況,但是x==10斷言失敗,出現這種情況的原因是因為在執行handleA方法的時候,因為x、y沒有依賴關係,有可能先賦值y=20,這時候剛好handleB()方法判斷成功,而x這時候還沒有賦值,導致斷言失敗,這就是多執行緒環境下的重排序問題,也會導致可見性問題

as-if-serial語義

它表示所有的程式指令都可以因為優化而被重排序,但是要保證在單執行緒環境下,重排序之後的執行結果和程式程式碼本身的執行結果一致,CPU指令重排序、Java編譯器都需要保證在單執行緒環境下as-if-serial語義是正確的。存在依賴關係的不會被排序

int x=10;  //1
int y=20;  //2
int c=x+y; //3

按照正常執行順序就是1、2、3,經過重排序之後可能是2、1、3,但絕對不會是3、2、1,因為as-if-serial語義可以保證排序後和之前結果一致

synchronized能夠保證有序性的是因為單執行緒獨佔CPU,根據as-if-serial語義,無論編譯器和處理器怎麼優化或指令重排,單執行緒下的執行結果一定是正確的。

Synchronized原理

Synchronized有兩種加鎖方式:修飾方法、程式碼塊,這兩種方式實現的底層有些不同,但同樣的是monitor物件頭是實現Synchronized的關鍵

Synchronized修飾方法

public class Teacher {

    public static int i = 0;

    public static synchronized void syncLock() {
        i++;
    }
}

通過Java- v Teacher.class反編譯之後

image-20220603223722970

發現這個方法有三個標識,其中比較醒目的就是ACC_SYNCHRONIZED,它是用來標記當前方法為同步方法

Synchronized修飾程式碼塊

public class Teacher {
    public static int i = 0;
  
    public static void main(String[] args) {
        synchronized (Teacher.class){
        }
    }
}

通過Java- v Teacher.class反編譯之後

image-20220603224237786

發現會在同步塊的前後分別形成monitorentermonitorexit這兩個指令包裹起來,後面還多一個monitorexit,這個作用是防止程式碼塊裡面有異常無法釋放鎖,所有會用第二個monitorexit指令來保證釋放,這兩個指令都是屬於Monitor物件,底層是C ++的ObjectMonitor實現,也是依賴於作業系統的mutex lock來實現的。

倘若執行緒獲取不到鎖,通過一定次數的自旋最後阻塞升級為重量級鎖,未搶佔到鎖的執行緒進入等待佇列,那麼這裡又是如何實現的呢,參考ObjectMonitor的實現

ObjectMonitor原始碼

ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0,  
  _recursions   = 0;      
  _object       = NULL;  
  _owner        = NULL;  
  _WaitSet      = NULL; 
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;  
  FreeNext      = NULL ;  
  _EntryList    = NULL ;   
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
}  

裡面主要引數是:

  • WaitSet:阻塞後等待喚醒的佇列,為雙向迴圈連結串列
  • EntryList:準備獲取鎖的執行緒佇列
  • owner:標識擁有鎖的執行緒
  • count:執行緒重入次數

1、首先執行緒會進入EntryList,然後嘗試獲取Monitor物件,獲取成功後把owner標記為當前執行緒,然後count次數+1,執行完畢後會釋放Monitor物件,並把owner設定為null,然後count減1,只有當count等於0才能夠獲取到鎖

2、假如執行緒進入EntryList後獲取Monitor失敗,就會進入WaitSet的尾部節點中,等待Monitor物件釋放後,會根據操作喚醒一個或全部執行緒進入EntryList中,處於EntryList中的執行緒都會搶佔鎖

Monitor物件存在於每個Java物件的物件頭Mark Word中(儲存的指標的指向),它也被稱之為“監視器鎖”,這就是為什麼任意物件都可以作為鎖的原因

Synchronized鎖物件

Java物件記憶體佈局

一個物件初始化之後會被儲存在堆記憶體中,一個物件又分為三部分:物件頭例項資料對其填充

image-20220530214749295

物件頭

其中物件頭又分為三部分

  • Mark Word:記錄了物件和鎖相關的資訊,主要包含了GC分代年齡、鎖的狀態標記、HashCode、epoch等等資訊
  • Klass Pointer:代表指向類的指標,通過指標來找到具體的例項
  • Length:表示陣列的長度,只有陣列物件才會有這個屬性值

例項資料

例項資料表示一個類裡面所有的成員的變數

public class Student{
  int age=0;
  boolean state=false;
}

例如這些成員變數就儲存在例項資料裡面,例項資料佔用的空間是由成員變數的型別決定的比如int佔4個位元組

對齊填充

對齊填充沒有什麼實際含義,主要是使得當前例項變數佔用空間是8的倍數,這樣做的目的是為了減少CPU訪問記憶體的頻率,為什麼會頻繁的訪問記憶體?這個問題涉及到了快取記憶體中的快取行,CPU每次從記憶體載入8個位元組的的資料到快取行中,也意味著快取記憶體儲存的是連續的資料,每個快取行大小是64位,意思如果是一個8個位元組的變數,需要儲存8個才能把該快取行佔滿

image-20220530221355529

但是存在這樣一種情況,兩個執行緒同時去讀取該快取上不同的值Long2、Long8,就會使同時快取該快取行,為了保證快取一致性就會使一部分快取失效,導致一個執行緒需要重新去獲取,重新載入到快取行,如果執行緒訪問頻繁就會使快取反覆失效,形成偽共享問題,為了減少CPU訪問記憶體的頻率,那麼必須要變數不在於同一快取行中,使用對其填充使兩個變數分開,在一個變數前後填充7個填充變數,就可以使兩個值分佈於不同快取行,比如在Long2前後填充七個

image-20220530225444281

之所以要做前後填充是為了使無論Long2處於什麼位置都可以保證它處於不同的快取行,避免出現偽共享問題

還有一種作用,假如需要讀取Long型別的資料的時候,它分佈在兩個快取行中,如果沒有對其填充需要讀取快取行A和快取行B才可以獲得真正的資料

image-20220530223953114

使用對其填充之後,在快取行B中可以直接讀取到全部資料,減少了CPU訪問次數

image-20220530224508765

在對齊填充的佈局中,雖然做了無效填充,但是訪問記憶體次數少了,本質上來說是一種時間換空間的設計方式。

一個類物件在JVM中物件儲存的佈局

public  class Student(){
    private  String name;
    
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
    }
}

image-20220601232609202

使用ClassLayout檢視物件佈局

  • OFFSET:偏移地址
  • SIZE:佔用記憶體大小
  • TYPE DESCRIPTION:型別描述
  • value:記憶體中儲存的值

物件欄位代表為:

image-20220602231921534

  • 物件頭:TYPE DESCRIPTION(object header)型別的,總共3個佔用12位元組的記憶體,前兩行SIZE加起來為8位元組的代表Mark Word,第三行4位元組的代表型別指標Klass Pointer,不壓縮會佔用8個位元組
  • 對齊填充:TYPE DESCRIPTION((loss due to the next object alignment))型別的,本身4個位元組,填充了12個位元組總共16位元組,主要為了保證是8的倍數
  • 例項資料:Instance size,總共16個位元組

image-20220601231734526

Java鎖結構資訊

image-20220603122059576

Java鎖包含在物件頭裡面,Mark Word中記錄了物件和鎖的資訊,鎖的標記和相關資訊都儲存在裡面

鎖狀態 偏向鎖標記 鎖標記
無鎖 0 01
偏向鎖 1 01
輕量級鎖 00
重量級鎖 10
GC標記 11

Mark Word使用2bit來儲存鎖的標記,也就是兩位數最多隻能儲存4個數:00、01、10、11。而鎖的狀態有五種,超出一種就多使用了1個bit的偏向鎖來表達。

Synchronized鎖升級

在JDK1.6之前Synchronized只有重量級鎖,沒有獲得鎖的執行緒會阻塞,直到被喚醒才能再次獲得鎖,JDK1.6之後對鎖做了很多優化引入了偏向鎖、輕量級鎖、重量級鎖

無鎖

public class Student {
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("10機制hashCode:"+stu.hashCode());
        System.out.println("16機制hashCode:"+Integer.toHexString(stu.hashCode()));
        System.out.println("2機制hashCode:"+Integer.toBinaryString(stu.hashCode()));
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }
}

image-20220604113447827

兩行Value裡面儲存了8個位元組的Mark Word,相當於如下兩行資料

01 f2 b2 8d (00000001 11110010 10110010 10001101) (-1917652479)
56 00 00 00 (01010110 00000000 00000000 00000000) (86)

這裡實際上包含了同樣結果的兩種資料格式二進位制和16進位制,去掉括號外面的資料,兩行整合成一行

二進位制

(00000001 11110010 10110010 10001101)  (01010110 00000000 00000000 00000000)

16進位制

01 f2 b2 8d  56 00 00 00

因為是小端儲存,所以需要倒過來觀看,資料順序應該反過來

二進位制

(00000000 00000000 00000000 01010110) (10001101 10110010 11110010 00000001) 

16進位制

00 00 00 56 8d b2 f2 01

56 8d b2 f2就是代表16機制hashCode

這樣才是一個方便閱讀的Mark Word結構,根據64位虛擬機器的Mark Word結構示意圖

image-20220606221428144

最後三位為【001】,0代表偏向鎖標記為,01表示鎖標記

發現hashCode部分剛好等於列印出來的二進位制HashCode:1010110 10001101 10110010 11110010,HashCode之所以不為空是因為呼叫了HashCode方法才顯示出來

無鎖升級偏向鎖

    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("=====加鎖之前======");
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        synchronized (stu){
            System.out.println("=====加鎖之後======");
            System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }
}

按照上訴步驟找到鎖標記

image-20220603131907108

最後三位為【000】,其中最後兩位是【00】,按照之前的儲存狀態定義這是輕量級鎖,本身沒有存在鎖競爭很明顯不對,原因是因為JVM開啟了偏向鎖延遲載入,我們啟動程式的時候偏向鎖還沒開啟,在程式啟動時新增引數:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 //關閉偏向鎖的延遲

image-20220603132438444

再次檢視程式執行結果

image-20220603132636508

加鎖之後鎖標記是【101】表示偏向鎖是符合預期的,但發現沒加鎖之前鎖也就是偏向鎖,本應該是無鎖

image-20220603141631976

發現沒加鎖之前沒有執行緒ID,加鎖之後才有執行緒ID, thread指標 和 epoch 都是0,說明當前並沒有執行緒獲得鎖,表示可偏向的狀態,所以無鎖也是一個特殊的偏向鎖,當有執行緒獲取到時才會真正變為偏向鎖

image-20220606221443232

偏向鎖的主要作用就是當同步程式碼塊被一個執行緒多次訪問,只有第一次訪問的時候需要記錄執行緒的ID,後續就會一直持有著鎖而不需要再次加鎖釋放鎖,因為只有一個執行緒那麼該執行緒在後續多次訪問就會自動獲得鎖,為了提高一個執行緒執行的效能,而不需要每次都去修改物件頭的執行緒ID還有鎖標誌才能夠獲得鎖

偏向鎖流程

image-20220605103823530

偏向鎖獲取流程:

  1. 首先檢視Mark Word中的鎖標記以及執行緒ID是否為空,如果鎖標記是101代表是可偏向狀態
  2. 如果是可偏向狀態,再檢視執行緒ID是當前的執行緒,直接執行同步程式碼塊
  3. 如果是可偏向狀態但是執行緒ID為空或者執行緒ID已被其他執行緒持有,那麼就需要通過CAS操作去修改Mark Word中執行緒ID為當前執行緒還有鎖標記,然後執行同步程式碼塊
  4. CAS修改失敗的話,就會開始撤銷偏向鎖,撤銷偏向鎖需要達到全域性安全點,然後檢查執行緒的狀態
  5. 如果執行緒還存活檢查執行緒是否在執行同步程式碼塊中的程式碼,如果是升級為輕量級鎖進行CAS競爭
  6. 如果沒有執行緒存活,直接把偏向鎖撤銷到無鎖狀態,然後另一個執行緒會升級到輕量級鎖

偏向鎖撤銷:

一種出現競爭出現才釋放鎖的機制,另外有執行緒來競爭鎖,不能再使用偏向鎖了,需要升級為輕量級鎖,原來的偏向鎖需要撤銷,就會出現兩種情況:

  1. 執行緒還沒有執行完,其他執行緒就來競爭,導致需要撤銷偏向鎖,此時當前執行緒升級為持有輕量級鎖,繼續執行程式碼
  2. 執行緒執行完畢退出了同步程式碼塊,將物件頭設定為無鎖並且撤銷偏向鎖重新偏向

偏向鎖批量重偏向:

當一個執行緒建立了大量物件並執行了初始的同步操作,後來另一個執行緒也來將這些物件作為鎖物件進行操作,會導偏向鎖重偏向的操作,過程比較耗時,所以當撤銷次數達到20次以上的時候,20這個值可以修改,會觸發重偏向,直接把偏向鎖偏向執行緒2

偏向鎖就是一段時間內,只由一個執行緒來獲得和釋放鎖,加鎖的方式就是通過把執行緒ID儲存到鎖物件的Mark Word中

偏向鎖升級輕量級鎖

public class Student {
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("=====加鎖之前======");
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        synchronized (stu){
            System.out.println("=====加鎖之後======");
            System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }

        Thread thread=new Thread(){
            @Override
            public void run() {
                synchronized (stu){
                    System.out.println("====輕量級鎖====");
                    System.out.println(ClassLayout.parseInstance(stu).toPrintable());

                }
            }
        };
        thread.start();
    }
}

image-20220603142836434

很明顯由特殊狀態的無鎖->偏向鎖->輕量級鎖

偏向鎖在不影響效能的情況下獲得了鎖,這時候如果還有一個執行緒來獲取鎖,如果沒有搶佔到就會自旋一定的次數,這個次數可以通過JVM引數控制,搶佔到了鎖就不需要阻塞,輕量級鎖也稱為自旋鎖

image-20220606221500096

這個自旋也是有代價的,如果執行緒數過多,一直都在使用自旋搶佔執行緒會浪費CPU效能,所以自旋的次數必須要有個限制,JDK1.6中預設是10次,JDK1.6之後使用的自適應自旋鎖,意味著自旋的次數並不是固定的,而是根據同一個鎖上次自旋的時間,如果很少自旋成功,那麼下次會減少自旋的次數甚至不自旋,如果自旋成功,會認為下次也可以自旋成功,會增加自旋的次數

輕量級鎖流程

image-20220605120022714

輕量級鎖獲取流程:

  1. 一個執行緒進入同步程式碼塊,JVM會給每一個執行緒分配一個Lock Record,官方稱之為“Dispalced Mark Word”,用於儲存鎖物件的Mark Word,可以理解為快取一樣儲存了鎖物件
  2. 複製鎖物件的Mark Word到Lock Record中去
  3. 使用CAS將鎖物件的Mark Word替換為指向Lock Record的指標,如果成功表示輕量級鎖佔鎖成功,執行同步程式碼塊
  4. 如果CAS失敗,說明當前lock鎖物件已經被佔領,當前執行緒就會使用自旋來獲取鎖

輕量級鎖釋放:

  1. 會把Dispalced Mark Word儲存鎖物件的Mark Word替換到鎖物件的Mark Work中,會使用CAS完成這一步操作
  2. 如果CAS成功,輕量級鎖釋放完成
  3. 如果CAS失敗,說明釋放鎖的時候發生了競爭觸發鎖膨脹,膨脹完之後呼叫重量級的釋放鎖方法

輕量級鎖加鎖的原理就是,JVM會為每一個執行緒分配一個棧幀用於儲存鎖的空間,裡面有個Lock Record資料結構,也就是BaseObjectLock物件,會把鎖物件裡面的Mark Word複製到自己的BaseObjectLock物件裡面,然後使用CAS把物件的Mark Word更新為指向Lock Record的指標,如果成功就獲取鎖,如果失敗表示已經有其他執行緒獲取到了鎖,然後繼續使用自旋來獲取鎖

輕量級鎖每次都需要釋放鎖,而偏向鎖只有存在競爭的時候才釋放鎖為了避免反覆切換

輕量級鎖升級重量級鎖

package com.ylc;

import org.openjdk.jol.info.ClassLayout;

public class Student {
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("=====加鎖之前======");
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        synchronized (stu){
            System.out.println("=====加鎖之後======");
            System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }

        Thread thread=new Thread(){
            @Override
            public void run() {
                synchronized (stu){
                    System.out.println("====輕量級鎖====");
                    System.out.println(ClassLayout.parseInstance(stu).toPrintable());

                }
            }
        };
        thread.start();

        for (int i=0;i<3;i++){
            new Thread(()->{
                synchronized (stu){
                    System.out.println("====重量級鎖====");
                    System.out.println(ClassLayout.parseInstance(stu).toPrintable());
                }
            }).start();
        }
    }
}

image-20220603144712306

鎖的標誌為【010】代表重量級鎖

image-20220606221519936

倘若通過自旋重試一定次數還獲取不到鎖,那麼就只能阻塞等待執行緒喚醒了,最後升級為重量級鎖

重量級鎖流程

image-20220605191403392

重量級鎖獲取流程

  1. 首先會進行鎖膨脹
  2. 然後會建立一個ObjectMonitor物件,通過該把該物件的指標儲存到鎖物件裡面
  3. 如果獲取鎖失敗或者物件本身就處於鎖定狀態,會進入阻塞狀態,等待CPU喚醒執行緒重新競爭鎖
  4. 如果物件無鎖就會獲取鎖

重量級鎖釋放流程

  1. 會把ObjectMonitor中的的持有鎖物件owner置為null
  2. 然後從阻塞佇列裡面喚醒一個執行緒
  3. 喚醒的執行緒重新競爭鎖,如果沒有搶佔到繼續等待

由此可以發現Synchronized底層的鎖機制是通過JVM層面根據執行緒競爭情況來實現的

Synchronized鎖消除

Java虛擬機器在JIT編譯時會去除沒有競爭的鎖,消除沒有必要的鎖,可以節省鎖的請求時間

public class Student {
    public static void main(String[] args) {
       for (int i=0;i<=10;i++){
           new Thread(()->{
               Student.lock();
           }).start();
        }
    }
  
      public  static void lock(){
        Object o=new Object();
            synchronized (o){
                System.out.println("hashCode:"+o.hashCode());
            }
        }
    
}

每次都加了鎖,可是都不是同一把鎖,無法產生競爭這樣的鎖沒有意義,相當於會無視synchronized (o)的存在

Synchronized鎖粗化

鎖粗化就是將多次連線在一起的加鎖、解鎖操作合併為一次操作。將多個聯絡的鎖擴充套件為一個範圍更大的鎖

public class Student {
    public static void main(String[] args) {
        Object o=new Object();
        new Thread(()->{
            synchronized (o){
                System.out.println("加一次鎖");
            }
            synchronized (o){
                System.out.println("加兩次鎖");
            }
            synchronized (o){
                System.out.println("加三次鎖");
            }
            synchronized (o){
                System.out.println("加四次鎖");
            }
        }).start();
    }
}

把小鎖範圍擴大,優化後變成

public class Student {
    public static void main(String[] args) {
              Object o=new Object();
        new Thread(()->{
            synchronized (o){
                System.out.println("加一次鎖");
            }
        }).start();
    }
}

鎖的優缺點對比

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

相關文章