資安

死磕synchronized底層原理

文章已收錄Github精選,歡迎Starhttps://github.com/yehongzhi

前言

作為Java程序員,我們都知道在多線程的情況下,為了保證線程安全,經常會使用synchronized和Lock鎖。Lock鎖之前寫過一篇《不得不學的AQS》,已經詳細講解過Lock鎖的底層原理。這次我們講一下日常開發中常用的關鍵字synchronized,想要用得好,底層原理必須要搞明白。

synchronized是JDK自帶的一個關鍵字,在JDK1.5之前是一個重量級鎖,所以從性能上考慮大部分人會選擇Lock鎖,不過畢竟是JDK自帶的關鍵字,所以在JDK1.6後對它進行優化,引入了偏向鎖,輕量級鎖,自旋鎖等概念。

一、synchronized的使用方式

在語法上,要使用synchronized關鍵字,需要把任意一個非null對象作為"鎖"對象,也就是需要一個對象監視器(Object Monitor)。總的來說有三種用法:

1.1 作用在實例方法

修飾實例方法,相當於對當前實例對象this加鎖,this作為對象監視器。

public synchronized void hello(){
    System.out.println("hello world");
}

1.2 作用在靜態方法

修飾靜態方法,相當於對當前類的Class對象加鎖,當前類的Class對象作為對象監視器。

public synchronized static void helloStatic(){
    System.out.println("hello world static");
}

1.3 修飾代碼塊

指定加鎖對象,對給定對象加鎖,括號括起來的對象就是對象監視器。

public void test(){
    SynchronizedTest test = new SynchronizedTest();        
    synchronized (test){
        System.out.println("hello world");
    }
}

二、synchronized鎖的原理

在講原理前,我們先講一下Java對象的構成。在JVM中,對象在內存中分為三塊區域:對象頭,實例數據和對齊填充。如圖所示:
在這裡插入圖片描述
對象頭

  • Mark Word,用於存儲對象自身運行時的數據,如哈希碼(Hash Code),GC分代年齡,鎖狀態標誌,偏向線程ID、偏向時間戳等信息,它會根據對象的狀態複用自己的存儲空間。它是實現輕量級鎖和偏向鎖的關鍵
  • 類型指針,對象會指向它的類的元數據的指針,虛擬機通過這個指針確定這個對象是哪個類的實例。
  • Array length,如果對象是一個數組,還必須記錄數組長度的數據。

實例數據

  • 存放類的屬性數據信息,包括父類的屬性信息。

對齊填充

  • 由於虛擬機要求 對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是為了字節對齊。

2.1 同步代碼塊原理

為了看底層實現原理,使用javap -v xxx.class命令進行反編譯。
在這裡插入圖片描述
這是使用同步代碼塊被標誌的地方就是剛剛提到的對象頭,它會關聯一個monitor對象,也就是括號括起來的對象。

1、monitorenter,如果當前monitor的進入數為0時,線程就會進入monitor,並且把進入數+1,那麼該線程就是monitor的擁有者(owner)。

2、如果該線程已經是monitor的擁有者,又重新進入,就會把進入數再次+1。也就是可重入的。

3、monitorexit,執行monitorexit的線程必須是monitor的擁有者,指令執行後,monitor的進入數減1,如果減1後進入數為0,則該線程會退出monitor。其他被阻塞的線程就可以嘗試去獲取monitor的所有權。

monitorexit指令出現了兩次,第1次為同步正常退出釋放鎖;第2次為發生異步退出釋放鎖;

總的來說,synchronized的底層原理是通過monitor對象來完成的。

2.2 同步方法原理

比如說使用synchronized修飾的實例方法。

public synchronized void hello(){
    System.out.println("hello world");
}

同理使用javap -v反編譯。
在這裡插入圖片描述
可以看到多了一個標誌位ACC_SYNCHRONIZED,作用就是一旦執行到這個方法時,就會先判斷是否有標誌位,如果有這個標誌位,就會先嚐試獲取monitor,獲取成功才能執行方法,方法執行完成後再釋放monitor。在方法執行期間,其他線程都無法獲取同一個monitor。歸根結底還是對monitor對象的爭奪,只是同步方法是一種隱式的方式來實現。

2.3 Monitor

上面經常提到monitor,它內置在每一個對象中,任何一個對象都有一個monitor與之關聯,synchronized在JVM裡的實現就是基於進入和退出monitor來實現的,底層則是通過成對的MonitorEnter和MonitorExit指令來實現,因此每一個Java對象都有成為Monitor的潛質。所以我們可以理解monitor是一個同步工具。

三、synchronized鎖的優化

前面講過JDK1.5之前,synchronized是屬於重量級鎖,重量級需要依賴於底層操作系統的Mutex Lock實現,然後操作系統需要切換用戶態和內核態,這種切換的消耗非常大,所以性能相對來說並不好。

既然我們都知道性能不好,JDK的開發人員肯定也是知道的,於是在JDK1.6後開始對synchronized進行優化,增加了自適應的CAS自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖這些優化策略。鎖的等級從無鎖,偏向鎖,輕量級鎖,重量級鎖逐步升級,並且是單向的,不會出現鎖的降級。

3.1 自適應性自旋鎖

在說自適應自旋鎖之前,先講自旋鎖。上面已經講過,當線程沒有獲得monitor對象的所有權時,就會進入阻塞,當持有鎖的線程釋放了鎖,當前線程才可以再去競爭鎖,但是如果按照這樣的規則,就會浪費大量的性能在阻塞和喚醒的切換上,特別是線程佔用鎖的時間很短的話。

為了避免阻塞和喚醒的切換,在沒有獲得鎖的時候就不進入阻塞,而是不斷地循環檢測鎖是否被釋放,這就是自旋。在佔用鎖的時間短的情況下,自旋鎖表現的性能是很高的。

但是又有問題,由於線程是一直在循環檢測鎖的狀態,就會佔用cpu資源,如果線程佔用鎖的時間比較長,那麼自旋的次數就會變多,佔用cpu時間變長導致性能變差,當然我們也可以通過參數-XX:PreBlockSpin設置自旋鎖的自旋次數,當自旋一定的次數(時間)後就掛起,但是設置的自旋次數是多少比較合適呢?

如果設置次數少了或者多了都會導致性能受到影響,而且佔用鎖的時間在業務高峰期和正常時期也有區別,所以在JDK1.6引入了自適應性自旋鎖。

自適應性自旋鎖的意思是,自旋的次數不是固定的,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

表現是如果此次自旋成功了,很有可能下一次也能成功,於是允許自旋的次數就會更多,反過來說,如果很少有線程能夠自旋成功,很有可能下一次也是失敗,則自旋次數就更少。這樣能最大化利用資源,隨著程序運行和性能監控信息的不斷完善,虛擬機對鎖的狀況預測會越來越準確,也就變得越來越智能。

3.2 鎖消除

鎖消除是一種鎖的優化策略,這種優化更加徹底,在JVM編譯時,通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖。這種優化策略可以消除沒有必要的鎖,節省毫無意義的請求鎖時間。比如StringBuffer的append()方法,就是使用synchronized進行加鎖的。

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

如果在實例方法中StringBuffer作為局部變量使用append()方法,StringBuffer是不可能存在共享資源競爭的,因此會自動將其鎖消除。例如:

public String add(String s1, String s2) {
    //sb屬於不可能共享的資源,JVM會自動消除內部的鎖
    StringBuffer sb = new StringBuffer();
    sb.append(s1).append(s2);
    return sb.toString();
}

3.3 鎖粗化

如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗話的概念。意思是將多個連續加鎖、解鎖的操作連接在一起,擴展成為一個範圍更大的鎖。

3.4 偏向鎖

偏向鎖是JDK1.6引入的一個重要的概念,JDK的開發人員經過研究發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得。也就是說在很多時候我們是假設有多線程的場景,但是實際上卻是單線程的。所以偏向鎖是在單線程執行代碼塊時使用的機制。

原理是什麼呢,我們前面提到鎖的爭奪實際上是Monitor對象的爭奪,還有每個對象都有一個對象頭,對象頭是由Mark Word和Klass pointer 組成的。一旦有線程持有了這個鎖對象,標誌位修改為1,就進入偏向模式,同時會把這個線程的ID記錄在對象的Mark Word中,當同一個線程再次進入時,就不再進行同步操作,這樣就省去了大量的鎖申請的操作,從而提高了性能。

一旦有多個線程開始競爭鎖的話呢?那麼偏向鎖並不會一下子升級為重量級鎖,而是先升級為輕量級鎖。

3.5 輕量級鎖

如果獲取偏向鎖失敗,也就是有多個線程競爭鎖的話,就會升級為JDK1.6引入的輕量級鎖,Mark Word 的結構也變為輕量級鎖的結構。

執行同步代碼塊之前,JVM會在線程的棧幀中創建一個鎖記錄(Lock Record),並將Mark Word拷貝複製到鎖記錄中。然後嘗試通過CAS操作將Mark Word中的鎖記錄的指針,指向創建的Lock Record。如果成功表示獲取鎖狀態成功,如果失敗,則進入自旋獲取鎖狀態。

自旋鎖的原理在上面已經講過了,如果自旋獲取鎖也失敗了,則升級為重量級鎖,也就是把線程阻塞起來,等待喚醒。

3.6 重量級鎖

重量級鎖就是一個悲觀鎖了,但是其實不是最壞的鎖,因為升級到重量級鎖,是因為線程佔用鎖的時間長(自旋獲取失敗),鎖競爭激烈的場景,在這種情況下,讓線程進入阻塞狀態,進入阻塞隊列,能減少cpu消耗。所以說在不同的場景使用最佳的解決方案才是最好的技術。synchronized在不同的場景會自動選擇不同的鎖,這樣一個升級鎖的策略就體現出了這點。

3.7 小結

偏向鎖:適用於單線程執行。

輕量級鎖:適用於鎖競爭較不激烈的情況。

重量級鎖:適用於鎖競爭激烈的情況。

四、Lock鎖與synchronized

我們看一下他們的區別:

  • synchronized是Java語法的一個關鍵字,加鎖的過程是在JVM底層進行。Lock是一個類,是JDK應用層面的,在JUC包裡有豐富的API。
  • synchronized在加鎖和解鎖操作上都是自動完成的,Lock鎖需要我們手動加鎖和解鎖。
  • Lock鎖有豐富的API能知道線程是否獲取鎖成功,而synchronized不能。
  • synchronized能修飾方法和代碼塊,Lock鎖只能鎖住代碼塊。
  • Lock鎖有豐富的API,可根據不同的場景,在使用上更加靈活。
  • synchronized是非公平鎖,而Lock鎖既有非公平鎖也有公平鎖,可以由開發者通過參數控制。

個人覺得在鎖競爭不是很激烈的場景,使用synchronized,語義清晰,實現簡單,JDK1.6後引入了偏向鎖,輕量級鎖等概念後,性能也能保證。而在鎖競爭激烈,複雜的場景下,則使用Lock鎖會更靈活一點,性能也較穩定。

總結

學習synchronized關鍵字的底層原理不是鑽牛角尖,其實是從底層原理上知道了synchronized在什麼場景使用會有什麼樣的效果,我們都知道沒有最好的技術,只有最適合的技術,所以在學完之後,希望對大家有所幫助,寫出更加高效的代碼。所謂不積跬步無以至千里,一步一個腳印,哪怕現在還是菜鳥,總有一天也會成為雄鷹。

那麼這篇文章就寫到這裡了,感謝大家的閱讀,希望看完後能有所收穫!

覺得有用就點個贊吧,你的點贊是我創作的最大動力~

我是一個努力讓大家記住的程序員。我們下期再見!!!
在這裡插入圖片描述

能力有限,如果有什麼錯誤或者不當之處,請大家批評指正,一起學習交流!

Leave a Reply

Your email address will not be published. Required fields are marked *