【得物技術】深入理解synchronzied底層原理

得物技術發表於2021-10-03

一、synchronized簡介

synchronized是Java中的關鍵字,是一種同步鎖。在多執行緒程式設計中,有可能會出現多個執行緒同時爭搶同一個共享資源的情況,這個資源一般被稱為臨界資源。這種共享資源可以被多個執行緒同時訪問,且又可以同時被多個執行緒修改,然而執行緒的執行是需要CPU的資源排程,其過程是不可控的,所以需要採用一種同步機制來控制對共享資源的訪問,於是執行緒同步鎖——synchronized就應運而生了。

二、如何解決執行緒併發安全問題

多執行緒併發讀寫訪問臨界資源的情況下,是會存線上程安全問題的,可以採用的同步互斥訪問的方式,就是在同一時刻,只能有同一個執行緒能夠訪問到臨界資源。當多個執行緒執行同一個方法時,該方法內部的區域性變數並不是臨界資源,因為這些區域性變數會在類載入的時候存在每個執行緒的私有棧的區域性變數表中,因此不屬於共享資源,所有不會導致執行緒安全問題。

三、synchronized用法

synchronized關鍵字最主要有以下3種使用方式:

  1. 修飾類方法,作用於當前類加鎖,如果多個執行緒不同物件訪問該方法,則無法保證同步。
  2. 修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖,鎖的是包含這個方法的類,也就是類物件,這樣如果多個執行緒不同物件訪問該靜態方法,也是可以保證同步的。
  3. 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。

四、Synchronized原理分析

可以先通過一個簡單的案例看一下同步程式碼塊:

public class SynchTestDemo {
    
    public void print() {
        synchronized ("得物") {
            System.out.println("Hello World");
        }
    }
    
}

synchronized屬於Java關鍵字,沒辦法直接看到其底層原始碼,所以只能通過class檔案進行反彙編。

先通過javac SynchTestDemo.java指令直接SynchTestDemo.java檔案編譯成SynchTestDemo.class檔案;再通過javap -v SynchTestDemo.class指令再對SynchTestDemo.class檔案進行反彙編,可以得到下面的位元組碼指令:

這些反編譯的位元組碼指令這裡就不詳細解釋了,對照著JVM指令手冊也能看懂是什麼意思。通過上圖反編譯的結果可以看出,monitorexit指令實際上是執行了兩次,第一次是正常情況下釋放鎖,第二次為發生異常情況時釋放鎖,這樣做的目的在於保證執行緒不死鎖。

monitorenter

首先可以看一下JVM規範中對於monitorenter的描述

翻譯過來就是:任何一個物件都有一個monitor與其相關聯,當且有一個monitor被持有後,它將處於鎖定的狀態,其他執行緒無法來獲取該monitor。當JVM執行某個執行緒的某個方法內部的monitorenter時,他會嘗試去獲取當前對應的monitor的所有權。其過程如下:

  1. 如果monitor的進入數為0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者;
  2. 如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加1;
  3. 如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權;

monitorexit

也可以先看一下JVM規範中對monitorexit的描述

翻譯過來就是:

  1. 能執行monitorexit指令的執行緒一定是擁有當前物件的monitor的所有權的執行緒;
  2. 執行monitorexit時會將monitor的進入數減1。當monitor的進入數減為0時,當前執行緒退出monitor,不再擁有monitor的所有權,此時其他被這個monitor阻塞的執行緒可以嘗試去獲取這個monitor的所有權;

synchronized關鍵字被編譯成位元組碼後會被翻譯成monitorenter和monitorexit兩條指令分別在同步塊邏輯程式碼的起始位置與結束位置,如下圖所示:

每個同步物件都有一個自己的Monitor(監視器鎖),加鎖過程如下圖所示:

通過上面的描述可以看出synchronized的實現原理:synchronized的底層實際是通過一個monitor物件來實現的,其實wait/notify方法也是依賴於monitor物件來實現的,這就是為什麼只有在同步程式碼塊或者方法中才能呼叫該方法,否則就會丟擲出java.lang.IllegalMonitorStateException的異常的原因。

下面可以再通過一個簡單的案例看一下同步方法:

public class SynchTestDemo {
    
    public synchronized void print() {
        System.out.println("Hello World");
    }
    
}

與上面同理可以檢視到,該方法的位元組碼指令:

從位元組碼反編譯的可以看出,同步方法並沒有通過指令monitorenter和monitorexit來實現的,但是相對於普通方法來說,其常量池多了了 ACC_SYNCHRONIZED 標示符。JVM實際就是根據該識別符號來實現方法的同步的。

當方法被呼叫時,會檢查ACC_SYNCHRONIZED標誌是否被設定,若被設定,執行緒會先獲取monitor,獲取成功才能執行方法體,方法執行完成後會再次釋放monitor。在方法執行期間,其他執行緒都無法獲得同一個monitor物件。

其實兩種同步方式從本質上看是沒有區別的,兩個指令的執行都是JVM呼叫作業系統的互斥原語mutex來實現的,被阻塞的執行緒會被掛起、等待重新排程,會導致執行緒在“使用者態”和“核心態”進行切換,就會對效能有很大的影響。

五、什麼是monitor?

monitor通常被描述為一個物件,可以將其理解為一個同步工具,或者可以理解為一種同步機制。所有的Java物件自打new出來的時候就自帶了一把鎖,就是monitor鎖,也就是物件鎖,存在於物件頭(Mark Word),鎖標識位為10,指標指向的是monitor物件起始地址。在Java虛擬機器(HotSpot)中,Monitor是由其底層實際是由C++物件ObjectMonitor實現的:

ObjectMonitor() {
    _header = NULL;
    _count = 0;                        //用來記錄該執行緒獲取鎖的次數
    _waiters = 0,
    _recursions = 0;                 // 執行緒的重入次數 
    _object = NULL;                 // 儲存該monitor的物件
    _owner = NULL;                     // 標識擁有該monitor的執行緒
    _WaitSet = NULL;                 // 處於wait狀態的執行緒,會被加入到_WaitSet
    _WaitSetLock = 0 ;
    _Responsible = NULL;
    _succ = NULL;
    _cxq = NULL;                     // 多執行緒競爭鎖時的單向佇列
    FreeNext = NULL;
    _EntryList = NULL;                 // 處於等待鎖block狀態的執行緒,會被加入到該列表
    _SpinFreq = 0;
    _SpinClock = 0;
    OwnerIsThread = 0;
}
  1. _owner:初始時為NULL。當有執行緒佔有該monitor時,owner標記為該執行緒的唯一標識。當執行緒釋放monitor時,owner又恢復為NULL。owner是一個臨界資源,JVM是通過CAS操作來保證其執行緒安全的;
  2. _cxq:競爭佇列,所有請求鎖的執行緒首先會被放在這個佇列中(單向連結)。cxq是一個臨界資源,JVM通過CAS原子指令來修改cxq佇列。修改前cxq的舊值填入了node的next欄位,_cxq指向新值(新執行緒)。因此_cxq是一個後進先出的stack(棧);
  3. _EntryList:_cxq佇列中有資格成為候選資源的執行緒會被移動到該佇列中;
  4. _WaitSet:因為呼叫wait方法而被阻塞的執行緒會被放在該佇列中。

舉個例子具體分析一下_cxq佇列與_EntryList佇列的區別:

public void print() throws InterruptedException {
    synchronized (obj) {
        System.out.println("Hello World");
        //obj.wait();
    }
 }

若多執行緒執行上面這段程式碼,剛開始t1執行緒第一次進同步程式碼塊,能夠獲得鎖,之後馬上又有一個t2執行緒也準備執行這段程式碼,t2執行緒是沒有搶到鎖的,t2這個執行緒就會進入_cxq這個佇列進行等待,此時又有一個執行緒t3準備執行這段程式碼,t3當然也會沒有搶到這個鎖,那麼t3也就會進入_cxq進行等待。接著,t1執行緒執行完同步程式碼塊把鎖釋放了,這個時候鎖是有可能被t1、t2、t3中的任何一個執行緒搶到的。假如此時又被t1執行緒給搶到了,那麼上次已經進入_cxq這個佇列進行等待的執行緒t2、t3就會進入_EntryList進行等待,若此時來了個t4執行緒,t4執行緒沒有搶到鎖資源後,還是會先進入_cxq進行等待。

下面具體分析一下_WaitSet佇列與_EntryList佇列:

每個object的物件裡 markOop->monitor() 裡可以儲存ObjectMonitor的物件。ObjectWaiter 物件裡存放thread(執行緒物件) 和unpark的執行緒, 每一個等待鎖的執行緒都會有一個ObjectWaiter物件,而objectwaiter是個雙向連結串列結構的物件。

結合上圖monitor的結構圖可以分析出,當執行緒的擁有者執行完執行緒後,會釋放鎖,此時有可能是阻塞狀態的執行緒去搶到鎖,也有可能是處於等待狀態的執行緒被喚醒搶到了鎖。在JVM中每個等待鎖的執行緒都會被封裝成ObjectMonitor物件,_owner標識擁有該monitor的執行緒,而_EntryList和_WaitSet就是用來儲存ObjectWaiter物件列表的,_EntryList和_WaitSet最大的區別在於前者是用來存放等待鎖block狀態的執行緒,後者是用來存放處於wait狀態的執行緒。

當多個執行緒同時訪問同一段程式碼時:

  • 首先會進入_EntryList集合每當執行緒獲取到物件的monitor後,會將monitor中的_ower變成設定為當前執行緒,同時會將monitor中的計數器_count加1
  • 若執行緒呼叫wait()方法時,將釋放當前持有的monitor物件,將_ower設定為null,_count減1,同時該執行緒進入_WaitSet中等待被喚醒

<!---->

  • 若當前執行緒執行完畢,也將釋放monitor鎖,並將_count值復原,以便於其他執行緒獲取鎖

monitor物件存在於每個Java物件的物件頭(Mark Word)中,所以Java中任何物件都可以作為鎖,由於notify/notifyAll/wait等方法會使用到monitor鎖物件,所以必須在同步程式碼塊中使用。多執行緒情況下,執行緒需要同時訪問臨界資源,監視器monitor可以確保共享資料在同一時刻只會有一個執行緒在訪問。

那麼問題來了,synchronized是物件鎖,加鎖就是加在物件上,那物件時如何記錄鎖的狀態的呢?答案就是鎖的狀態是記錄在每個物件的物件頭(Mark Word)中的,那什麼是物件頭呢?

六、什麼是物件頭

在JVM中,物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充。如下圖所示:

物件頭又包括兩部分資訊,第一部分用於儲存物件自身的執行時資料(Mark Word),如HashCode、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。物件頭的另外一部分是型別指標(Klass pointer),即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

Class<? extends SynchTestDemo> synchClass = synchTestDemo.getClass();

值得注意的是:類元資訊存在於方法區,類元資訊有區別與堆中的synchClass位元組碼物件,synchClass可以理解為類載入完成後,JVM將類的資訊存在堆中,然後使用反射去訪問其全部資訊(包括函式和欄位),然而在JVM內部大多數物件都是使用C++程式碼實現的,對於JVM內部如果需要類資訊,JVM就會通過物件頭的型別指標去拿方法區中類元資訊的資料。

例項資料:存放類的屬性資料資訊,包括父類的屬性資訊。

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

下面可以看一下對像頭的結構:

在32位虛擬機器下,Mark Word是32bit大小的,其儲存結構如下:

在64位虛擬機器下,Mark Word是64bit大小的,其儲存結構如下:

現在虛擬機器基本是64位的,而64位的物件頭有點浪費空間,JVM預設會開啟指標壓縮,所以基本上也是按32位的形式記錄物件頭的。也可以通過下面引數進行控制JVM開啟和關閉指標壓縮:

開啟壓縮指標(-XX:+UseCompressedOops) 關閉壓縮指標(-XX:-UseCompressedOops)

那為什麼JVM需要預設開啟指標壓縮呢?原因在於在物件頭上類元資訊指標Klass pointer在32位JVM虛擬機器中用4個位元組儲存,但是到了64位JVM虛擬機器中Klass pointer用的就是8個位元組來儲存,一些物件在32位虛擬機器用的也是4位元組來儲存,到了64位機器用的都是8位元組來儲存了,一個工程專案中有成千上萬的物件,倘若每個物件都用8位元組來存放的話,那這些物件無形中就會增加很多空間,導致堆的壓力就會很大,堆很容易就會滿了,然後就會更容易的觸發GC,那指標壓縮的最主要的作用就是壓縮每個物件記憶體地址的大小,那麼同樣堆記憶體大小就可以放更多的物件。

這裡剛好可以再說一個額外的小知識點:物件頭中有4個位元組用於存放物件分代年齡的,4個位元組就是2的四次方等於16,其範圍就是0~15,所以也就很好理解物件在GC的時候,JVM物件由年輕代進入老年代的預設分代年齡是15了。

七、synchronized鎖的優化

作業系統分為“使用者空間”和“核心空間”,JVM是執行在“使用者態”的,jdk1.6之前,在使用synchronized鎖時需要呼叫底層的作業系統實現,其底層monitor會阻塞和喚醒執行緒,執行緒的阻塞和喚醒需要CPU從“使用者態”轉為“核心態”,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,這些操作給系統的併發效能 帶來了很大的壓力。同這個時候CPU就需要從“使用者態”切向“核心態”,在這個過程中就非常損耗效能而且效率非常低,所以說jdk1.6之前的synchronized是重量級鎖。如下圖所示:

然後有位紐約州立大學的教授叫Doug Lea看到jdk自帶的synchronized效能比較低,於是他利用純Java語言實現了基於AQS的ReentrantLock鎖(底層當然也呼叫了底層的語言),如下圖所示,可以說ReentrantLock鎖的出現完全是為了彌補synchronized鎖的各種不足。

由於synchronized鎖效能嚴重不足,所以oracle官方在jdk1.6之後對synchronized鎖進行了升級,如上圖所示的鎖升級的整個過程。所以就有了以下的這些名詞:

無鎖

無鎖沒有對資源進行鎖定,所有的執行緒都能訪問並修改同一個資源,但同時只有一個執行緒能修改成功,其底層是通過CAS實現的。無鎖無法全方位代替有鎖,但無鎖在某些場合下的效能是非常高的。

偏向鎖(無鎖 -> 偏向鎖)

偏向鎖的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是這個鎖會偏向於第一個獲得它的執行緒,會在物件頭儲存鎖偏向的執行緒ID,以後該執行緒進入和退出同步塊時只需要檢查是否為偏向鎖、鎖標誌位以及 ThreadID即可。

一開始無鎖狀態,JVM會預設開啟“匿名”偏向的一個狀態,就是一開始執行緒還未持有鎖的時候,就預先設定一個匿名偏向鎖,等一個執行緒持有鎖之後,就會利用CAS操作將執行緒ID設定到物件的mark word 的高23位上【32位虛擬機器】,下次執行緒若再次爭搶鎖資源的時,多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,只需要在置換ThreadID的時候依賴一次CAS原子指令即可。

輕量級鎖(偏向鎖 -> 輕量鎖)

當執行緒交替執行同步程式碼塊時,且競爭不激烈的情況下,偏向鎖就會升級為輕量級鎖。在大多數情況下,鎖總是由同一執行緒多次獲得,不存在多執行緒競爭,所以出現了偏向鎖。其目標就是在只有一個執行緒執行同步程式碼塊時能夠提高效能。當一個執行緒訪問同步程式碼塊並獲取鎖時,會在Mark Word裡儲存鎖偏向的執行緒ID。線上程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裡是否儲存著指向當前執行緒的偏向鎖。引入偏向鎖是為了在無多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。撤銷偏向鎖後恢復到無鎖(標誌位為“01”)或輕量級鎖(標誌位為“00”)的狀態。

自旋鎖

在很多場景下,共享資源的鎖定狀態只會持續很短的一段時間,為了這段時間阻塞和喚醒執行緒並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋) , 這就是自旋鎖。

當一個執行緒t1、t2同事爭搶同一把鎖時,假如t1執行緒先搶到鎖,鎖不會立馬升級成重量級鎖,此時t2執行緒會自旋幾次(預設自旋次數是10次,可以使用引數-XX : PreBlockSpin來更改),若t2自旋超過了最大自旋次數,那麼t2就會當使用傳統的方式去掛起執行緒了,鎖也升級為重量級鎖了。

自旋的等待不能代替阻塞,暫且不說對處理器數量的要求必須要兩個核,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,所以如果鎖被佔用的時間很短,自旋等 待的效果就會非常好,如果鎖被佔用的時間很長,那自旋的執行緒只會消耗處理器資源,而不會做任何有用的工作,反而會帶來效能上的浪費。

自旋鎖在jdk1.4中就已經引入,只不過預設是關閉的,可以使用-XX:+UseSpinning引數來開啟,在jdk1.6之後自旋鎖就已經預設是開啟狀態了。

重量級鎖

升級為重量級鎖時,鎖標誌的狀態值變為“10”,此時Mark Word中儲存的是指向重量級鎖的指標,此時等待鎖的執行緒都會進入阻塞狀態。

鎖消除

鎖消除是指虛擬機器即時編譯器(JIT)在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的資料支援,如果判斷在一段程式碼中,堆上的所有資料都不會逃逸出去從而被其他執行緒訪問到,那就可以把它們當做棧上資料對待,認為它們是執行緒私有的,同步加鎖自然就無須進行。

public class SynchRemoveDemo {
    public static void main(String[] args) {
        stringContact("AA", "BB", "CC");
    }
    public static String stringContact(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        return sb.append(s1).append(s2).append(s3).toString();
    }
}

//append()方法原始碼
@Override
public synchronized StringBuffer append(String str) {
   toStringCache = null;
   super.append(str);
   return this;
}

StringBuffer的append()是一個同步方法,鎖就是this也就是sb物件。虛擬機器發現它的動態作用域被限制在stringContact()方法內部。也就是說, sb物件的引用永遠不會“逃逸”到stringContact()方法之外,其他執行緒無法訪問到它,因此,雖然這裡有鎖,但是可以被安全地消除掉,在即時編譯之後,這段程式碼就會忽略掉所有的同步而直接執行了。

這裡順便說一個小的JVM知識點——“物件的逃逸分析”:就是分析物件動態作用域,當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他地方中。JVM通過逃逸分析確定該物件不會被外部訪問。如果不會逃逸可以將該物件優先在棧上分配記憶體,這樣該物件所佔用的記憶體空間就可以隨棧幀出棧而銷燬,就減輕了垃圾回收的壓力。上面sb物件的就是不會逃逸出方法stringContact(),所以sb物件有可能優先分配線上程棧中,只是有可能喲,這裡點到為止,需要了解可以自行學習喲~

鎖粗化

JVM會探測到一連串細小的操作都使用同一個物件加鎖,將同步程式碼塊的範圍放大,放到這串操作的外面,這樣只需要加一次鎖即可。可以通過下面的例子來看一下:

public class SynchDemo {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 50; i++) {
            sb.append("AA");
        }
        System.out.println(sb.toString());
    }
}

//append()方法原始碼
@Override
public synchronized StringBuffer append(String str) {
   toStringCache = null;
   super.append(str);
   return this;
}

StringBuffer的append()是一個同步方法,通過上面的程式碼可以看出,每次迴圈都要給append()方法加鎖,這時系統會通過判斷將其修改為下面這種,直接將原append()方法的synchronized的鎖給去掉直接加在了for迴圈外。

public class SynchDemo {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        synchronized(sb){
            for (int i = 0; i < 50; i++) {
            sb.append("AA");
            }
        }
        System.out.println(sb.toString());
    }
}

//append()方法原始碼
@Override
public StringBuffer append(String str) {
   toStringCache = null;
   super.append(str);
   return this;
}

八、通過物件頭分析鎖升級過程

可以通過物件頭分析工具觀察一下鎖升級時物件頭的變化:執行時物件頭鎖狀態分析工具JOL,是OpenJDK開源工具包,引入下方maven依賴

<dependency>
     <groupId>org.openjdk.jol</groupId>
     <artifactId>jol‐core</artifactId>
     <version>0.10</version>
</dependency>

觀察無鎖狀態下的物件頭【無鎖狀態】:

 public static void main(String[] args) throws InterruptedException {
     Object object = new Object();
     System.out.println(ClassLayout.parseInstance(object).toPrintable());
 }

執行結果:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)          第一行:物件頭MarkWord
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)          第二行:物件頭MarkWord
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 第三行:klass Pointer
     12     4        (loss due to the next object alignment)                                                                  第四行:對齊填充
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

這裡先詳細解釋一下列印結果,後面就不做詳細分析了:

OFFSET : 記憶體地址偏移量

SIZE : 本條資訊對應的位元組大小

Instance size: 16 bytes :本次new出的Object物件的大小

由於當前所使用的的機器是64位作業系統的機器,所以前兩行代表的就是物件頭MarkWord,已經在上述執行結果中標出,剛好是8位元組,每個位元組8位,剛好是64位;由上文中32位物件頭與64位物件頭的位數對比可知,分析物件頭鎖升級情況看第一行的物件頭即可。

第三行指的是型別指標(上文中有說過,指向的是方法區的類元資訊),已經在上述執行結果中標出,Klass Pointer在64位機器預設是8位元組,這裡由於指標壓縮的原因當前是4位元組。

第四行指的是對齊填充,有的時候有有的時候沒有,JVM內部需要保證物件大小是8個位元組的整數倍,實際上計算機底層通過大量計算得出物件時8位元組的整數倍可以提高物件儲存的效率。

可以觀察到本次new出的Object物件的大小實際只有12位元組,這裡物件填充為其填充了4個位元組,就是為了讓Object物件大小為16位元組是8位元組的整數倍。

JVM採用的是小端模式,需要現將其轉換成大端模式,具體轉換如下圖所示:

可以看出一開始物件沒有加鎖,通過最後三位的“001”也能觀察到,前25位代表hashcode,那這裡為什麼前25位是0呢?其實hashcode是通過C語言類似於“懶載入”的方式獲取到的,所以看到該物件的高25位並沒有hashcode。

觀察有鎖無競爭狀態下的物件頭【無鎖->偏向鎖】:

 public static void main(String[] args) throws InterruptedException {
     Object object = new Object();
     System.out.println(ClassLayout.parseInstance(object).toPrintable());
     synchronized (object){
          System.out.println(ClassLayout.parseInstance(o).toPrintable());
     }
 }

執行結果(JVM預設小端模式):

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           90 39 62 05 (10010000 00111001 01100010 00000101) (90323344)
      4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

執行結果分析:

通過執行結果可以看到,先列印出來的是一個“001”無鎖的狀態,但是後列印出來的“000”並不是偏向鎖的狀態,查上面的表可以發現“000”直接就是輕量級鎖的狀態了。JVM啟動的時候內部實際上也是有很多個執行緒在執行synchronized,JVM就是為了避免無畏的鎖升級過程(偏向鎖->輕量級鎖->重量級鎖)帶來的效能開銷,所以JVM預設狀態下會延遲啟動偏向鎖。只要將程式碼前面加個延遲時間即可觀察到偏向鎖:

public static void main(String[] args) throws InterruptedException {
     TimeUnit.SECONDS.sleep(6);
     Object o = new Object();
     System.out.println(ClassLayout.parseInstance(o).toPrintable());
     synchronized (o){
       System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

執行結果(JVM預設小端模式):

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 90 80 de (00000101 10010000 10000000 11011110) (-561999867)
      4     4        (object header)                           b2 7f 00 00 (10110010 01111111 00000000 00000000) (32690)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

對未開啟偏向鎖與開啟偏向鎖的執行結果分析:

未開啟偏向鎖(大端模式),沒加鎖:00000000 00000000 00000000 00000001
開啟偏向鎖(大端模式),沒加鎖     :00000000 00000000 00000000 00000101
開啟偏向鎖(大端模式),加鎖      :11011110 10000000 10010000 00000101

開啟偏向鎖之後的無鎖狀態,會加上一個偏向鎖,叫匿名偏向(可偏向狀態),表示該物件鎖是可以加偏向鎖的,從高23位的23個0可以看出暫時還沒有偏向任何一個執行緒,代表已經做好了偏向的準備,就等著接下來的某個執行緒能拿到就直接利用CAS操作把執行緒id記錄在高23位的位置。

觀察有鎖有競爭狀態下的物件頭【偏向鎖->輕量級鎖】:

public static void main(String[] args) throws InterruptedException {
        
        Thread.sleep(5000);
        
        Object object = new Object();
        
        //main執行緒
        System.out.println(ClassLayout.parseInstance(object).toPrintable());

        //執行緒t1
        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        },"t1").start();

        Thread.sleep(2000);

        //main執行緒
        System.out.println(ClassLayout.parseInstance(object).toPrintable());
        //執行緒t2
        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        },"t2").start();
    }

執行結果(JVM預設小端模式):

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)                //main執行緒列印
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 90 94 2d (00000101 10010000 10010100 00101101) (764710917)        //t1執行緒列印
      4     4        (object header)                           c9 7f 00 00 (11001001 01111111 00000000 00000000) (32713)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 90 94 2d (00000101 10010000 10010100 00101101) (764710917)       //main執行緒列印
      4     4        (object header)                           c9 7f 00 00 (11001001 01111111 00000000 00000000) (32713)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           08 a9 d5 07 (00001000 10101001 11010101 00000111) (131442952)        //t2執行緒列印
      4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

執行結果分析:

一開始main執行緒列印出的object物件頭可以看出是匿名偏向;

接著執行緒t1列印了object物件頭,可以與第一個列印出來的物件頭對比不難發現t1列印的也是偏向鎖,但是t1列印的物件頭已經把t1的執行緒id記錄在了其對應的23位;

程式再次回到main執行緒,其還是列印出來剛剛t1的物件頭資料,也就是說偏向鎖一旦偏向了某個執行緒後,如果執行緒不能重新偏向的話,那麼這個偏向鎖還是會一直記錄著之前偏向的那個執行緒的物件頭狀態;

接著執行緒t2又開始列印了object物件頭,可以看出最後一次列印已經升級成了輕量級鎖,因為這裡已經存在兩個執行緒t1、t2交替進入了object物件鎖的同步程式碼塊,並且鎖的不激烈競爭,所以鎖已經升級成了輕量級鎖。

觀察無鎖升級成重量級鎖狀態下的物件頭的整個過程【無鎖->重量級鎖】:

public static void main(String[] args) throws InterruptedException {
        sleep(5000);
        Object object = new Object();

        System.out.println(ClassLayout.parseInstance(object).toPrintable());

        new Thread(()->{
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
                //延長鎖的釋放,造成鎖的競爭
                try {
                    sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"t0").start();

        sleep(5000);

        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
                //延長鎖的釋放,造成鎖的競爭
                try {
                    sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"t1").start();

        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
                try {
                    sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"t2").start();

    }

執行結果:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)            //main執行緒列印
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 d8 8f ef (00000101 11011000 10001111 11101111) (-275785723)    //t0執行緒列印
      4     4        (object header)                           ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           00 e9 a9 09 (00000000 11101001 10101001 00001001) (162130176)    //t1執行緒列印
      4     4        (object header)                           ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           0a d8 80 f0 (00001010 11011000 10000000 11110000) (-259991542)    //t2執行緒列印
      4     4        (object header)                           ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

執行結果分析(JVM預設小端模式):

程式一開始就是設定了5秒鐘的睡眠,目的在於讓JVM優先載入完成後,讓JVM預設狀態下會延遲啟動偏向鎖,可以開出一開始main執行緒列印的是“101”就是預設的匿名偏向鎖,但是並沒有設定執行緒id;之後t0執行緒就立馬列印了,此時只需利用CAS操作把t0的執行緒id設定進物件頭即可,所以這個時候也是一個偏向鎖狀態;之後的程式睡眠5秒鐘後,程式中t1、t2執行緒執行程式碼塊時,有意的將其執行緒睡眠幾秒鐘,目的在於不管那個執行緒率先搶到鎖,都能讓另外一個執行緒在自旋等待中,所以t1執行緒列印的是“00”就已經是輕量級鎖了,最後看程式執行結果,t2列印的是“10”就已經升級為重量級鎖了,顯然t2執行緒已經超過了自旋的最大次數,已經轉成重量級鎖了。

九、總結

那平時寫程式碼如何對synchronized優化呢?

我總結就是:

1、減少synchronized的範圍,同步程式碼塊中儘量短,減少同步程式碼塊中程式碼的執行時間,減少鎖的競爭。

2、降低synchronized鎖的粒度,將一個鎖拆分為多個鎖提高併發度。這點其實可以參考HashTable與ConcurrentHashMap的底層原理。

HashTable加鎖實際上鎖的是整個hash表,一個操作進行的時候,其他操作都無法進行了。

然而ConcurrentHashMap是區域性鎖定,鎖得並不是一整張表,ConcurrentHashMap鎖得是一個segment,當前的segment被鎖了,不影響其他segment的操作。

文/harmony

關注得物技術,做最潮技術人!

相關文章