打擊面試重災區——Synchronized原理

兄弟們,你們好。時隔多天,我,終於來了。今天咱們來聊一下讓人神魂顛倒的Synchronizedjava

不過呢,在讀這篇文章以前,我但願你真正使用過這個東東,或者瞭解它究竟是幹嗎用的,否則很難理解這篇文章講解的東西。node

這篇文章的大致順序是:從無鎖-->偏向鎖-->輕量級鎖-->重量級鎖講解,其中會涉及到CAS對象內存佈局,緩存行等等知識點。也是滿滿的乾貨內容。其中也夾雜了我的在面試過程當中出現的面試題,各位兄弟慢慢享受。c++

Synchronizedjdk1.6作了很是大的優化,避免了不少時候的用戶態到內核態的切換,節省了資源的開銷,而這一切的前提均來源於CAS這個理念。下面咱們先來聊一下CAS的一些基本理論。面試

1. CAS

CAS全稱:CompareAndSwap,故名思意:比較並交換。他的主要思想就是:我須要對一個值進行修改,我不會直接修改,而是將當前我認爲的值和要修改的值傳入,若是此時內存中的確爲我認爲的值,那麼就進行修改,不然修改失敗。他的思想是一種樂觀鎖的思想。數組

一張圖解釋他的工做流程:緩存

知道了它的工做原理,咱們來聽一個場景:如今有一個int類型的數字它等於1,存在三個線程須要對其進行自增操做。安全

通常來講,咱們認爲的操做步驟是這樣:線程從主內存中讀取這個變量,到本身的工做空間中,而後執行變量自增,而後回寫主內存,但這樣在多線程狀態下會存在安全問題。而若是咱們保證變量的安全性,經常使用的作法是ThreadLocal或者直接加鎖。(對ThreadLocal不瞭解的兄弟,看我這篇文章一文讀懂ThreadLocal設計思想多線程

這個時候咱們思考一下,若是使用咱們上面的CAS進行對值的修改,咱們須要如何操做。架構

首先,咱們須要將當前線程認爲的值傳入,而後將想要修改的值傳入。若是此時內存中的值和咱們的指望值相等,進行修改,不然修改失敗。這樣是否是解決了一個多線程修改的問題,並且它沒有使用到操做系統提供的鎖。工具

上面的流程其實就是類AtomicInteger執行自增操做的底層實現,它保證了一個操做的原子性。咱們來看一下源碼。

public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            //從內存中讀取最新值
            var5 = this.getIntVolatile(var1, var2);
            //修改
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
 }

實現CAS使用到了Unsafe類,看它的名字就知道不安全,因此JDK不建議咱們使用。對比咱們上面多個線程執行一個變量的修改流程,這個類的操做僅僅增長了一個自旋,它在不斷獲取內存中的最新值,而後執行自增操做。

可能有兄弟說了,那getIntVolatilecompareAndSwapInt操做如何保證原子性。

對於getIntVolatile來講,讀取內存中的地址,原本就一部操做,原子性顯而易見。

對於compareAndSwapInt來講,它的原子性由CPU保證,經過一系列的CPU指令實現,其C++底層是依賴於Atomic::cmpxchg_ptr實現的

到這裏CAS講完了,不過其中還有一個ABA問題,有興趣能夠去了解個人這篇文章多線程知識點小節。裏面有詳細的講解。

咱們經過CAS能夠保證了操做的原子性,那麼咱們須要考慮一個東西,鎖是怎麼實現的。對比生活中的case,咱們經過一組密碼或者一把鑰匙實現了一把鎖,一樣在計算機中也經過一個鑰匙即synchronized代碼塊使用的鎖對象。

那其餘線程如何判斷當前資源已經被佔有了呢?

在計算機中的實現,每每是經過對一個變量的判斷來實現,無鎖狀態爲0,有鎖狀態爲1等等來判斷這個資源是否被加鎖了,當一個線程釋放鎖時僅僅須要將這個變量值更改成0,表明無鎖。

咱們僅僅須要保證在進行變量修改時的原子性便可,而剛剛的CAS恰好能夠解決這個問題

至於那個鎖變量存儲在哪裏這個問題,就是下面的內容了,對象的內存佈局

2. 內存佈局

各位兄弟們,應該都清楚,咱們建立的對象都是被存放到堆中的,最後咱們得到到的是一個對象的引用指針。那麼有一個問題就會誕生了,JVM建立的對象的時候,開闢了一塊空間,那這個空間裏都有什麼東西?這個就是咱們這個點的內容。

先來結論:Java中存在兩種類型的對象,一種是普通對象,另外一種是數組

對象內存佈局

咱們來一個一個解釋其含義。

白話版:對象頭中包含又兩個字段,Mark Word主要存儲改對象的鎖信息,GC信息等等(鎖升級的實現)。而其中的Klass Point表明的是一個類指針,它指向了方法區中類的定義和結構信息。而Instance Data表明的就是類的成員變量。在咱們剛剛學習Java基礎的時候,都聽過老師講過,對象的非靜態成員屬性都會被存放在堆中,這個就是對象的Instance Data。相對於對象而言,數組額外添加了一個數組長度的屬性

最後一個對其數據是什麼?

咱們拿一個場景來展現這個緣由:想像一下,你和女友週末打算出去玩,女友讓你給她帶上口紅,那麼這個時候你僅僅會帶上口紅嘛?固然不是,而是將全部的必用品通通帶上,以防剛一出門就得回家拿東西!!!這種行爲叫啥?未雨綢繆,沒錯,暖男行爲。還不懂?再來一個案例。你準備創業了,資金很是充足,你須要註冊一個域名,你僅僅註冊一個嘛?不,而是將全部相關的都註冊了,防止之後大價錢買域名。一個道理。

而對於CPU而言,它在進行計算處理數據的時候,不可能須要什麼拿什麼吧,那對其性能損耗很是嚴重。因此有一個協議,CPU在讀取數據的時候,不只僅只拿須要的數據,而是獲取一行的數據,這就是緩存行,而一行是64個字節

因此呢?經過這個特性能夠玩一些詭異的花樣,好比下面的代碼。

public class CacheLine {
    private volatile Long l1 , l2;
}

咱們給一個場景:兩個線程t1和t2分別操做l1l2,那麼當t1l1作了修改之後,l2需不須要從新讀取主內存種值。答案是必定,根據咱們上面對於緩存行的理解,l1和l2必然位於同一個緩存行中,根據緩存一致性協議,當數據被修改之後,其餘CPU須要從新重主內存中讀取數據。這就引起了僞共享的問題

那麼爲何對象頭要求會存在一個對其數據呢?

HotSpot虛擬機要求每個對象的內存大小必須保證爲8字節的整數倍,因此對於不是8字節的進行了對其補充。其緣由也是由於緩存行的緣由

對象=對象頭+實例數據

3. 無鎖

咱們在前面聊了一下,計算機中的鎖的實現思路和對象在內存中的佈局,接下來咱們來聊一下它的具體鎖實現,爲對象加鎖使用的是對象內存模型中的對象頭,經過對其鎖標誌位和偏向鎖標誌位的修改實現對資源的獨佔即加鎖操做。接下來咱們看一下它的內存結構圖。

上圖就是對象頭在內存中的表現(64位),JVM經過對對象頭中的鎖標誌位和偏向鎖位的修改實現「無鎖」。

對於無鎖這個概念來講,在1.6以前,即全部的對象,被建立了之後都處於無鎖狀態,而在1.6以後,偏向鎖被開啓,對象在經歷過幾秒的時候(4~5s)之後,自動升級爲當前線程的偏向鎖。(不管經沒通過synchronized)。

咱們來驗證一下,經過jol-core工具打印其內存佈局。注:該工具打印出來的數據信息是反的,即最後幾位在前面,經過下面的案例能夠看到

場景:建立兩個對象,一個在剛開始的時候就建立,另外一個在5秒以後建立,進行對比其內存佈局

Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());//此時處於無鎖態
try {TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {e.printStackTrace();}
Object o = new Object();
System.out.println("偏向鎖開啓");
System.out.println(ClassLayout.parseInstance(o).toPrintable());//五秒之後偏向鎖開啓

咱們能夠看到,線程已開啓建立的對象處於無鎖態,而在5秒之後建立的線程處於偏向鎖狀態。

一樣,當咱們遇到synchronized塊的時候,也會自動升級爲偏向鎖,而不是和操做系統申請鎖。

說完這個,提一嘴一個面試題吧。解釋一下什麼是無鎖。

從對象內存結構的角度來講,是一個鎖標誌位的體現;從其語義來講,無鎖這個比較抽象了,由於在之前鎖的概念每每是與操做系統的鎖息息相關,因此新出現的基於CAS的偏向鎖,輕量級鎖等等也被成爲無鎖。而在synchronized升級的起點----無鎖。這個東西就比較難以解釋,只能說它沒加鎖。不過面試的過程當中從對象內存模型中理解可能會更加舒服一點。

4. 偏向鎖

在實際開發中,每每資源的競爭比較少,因而出現了偏向鎖,故名思意,當前資源偏向於該線程,認爲未來的一切操做均來自於改線程。下面咱們從對象的內存佈局下看看偏向鎖

對象頭描述:偏向鎖標誌位經過CAS修改成1,而且存儲該線程的線程指針

當發生了鎖競爭,其實也不算鎖競爭,就是當這個資源被多個線程使用的時候,偏向鎖就會升級。

在升級的期間有一個點-----全局安全點,只有處在這個點的時候,纔會撤銷偏向鎖。

全局安全點-----相似於CMSstop the world,保證這個時候沒有任何線程在操做這個資源,這個時間點就叫作全局安全點。

能夠經過XX:BiasedLockingStartupDelay=0 關閉偏向鎖的延遲,使其當即生效。

經過XX:-UseBiasedLocking=false 關閉偏向鎖。

5.輕量級鎖

在聊輕量級鎖的時候,咱們須要搞明白這幾個問題。什麼是輕量級鎖,什麼重量級鎖?,爲何就重量了,爲何就輕量了?

輕量級和重量級的標準是依靠於操做系統做爲標準判斷的,在進行操做的時候你有沒有調用過操做系統的鎖資源,若是有就是重量級,若是沒有就是輕量級

接下來咱們看一下輕量級鎖的實現。

  • 線程獲取鎖,判斷當前線程是否處於無鎖或者偏向鎖的狀態,若是是,經過CAS複製當前對象的對象頭到Lock Recoder放置到當前棧幀中(對於JVM內存模型不清楚的兄弟,看這裏入門JVM看這一篇就夠了
  • 經過CAS將當前對象的對象頭設置爲棧幀中的Lock Recoder,而且將鎖標誌位設置爲00
  • 若是修改失敗,則判斷當前棧幀中的線程是否爲本身,若是是本身直接獲取鎖,若是不是升級爲重量級鎖,後面的線程阻塞

咱們在上面提到了一個Lock Recoder,這個東東是用來保存當前對象的對象頭中的數據的,而且此時在該對象的對象頭中保存的數據成爲了當前Lock Recoder的指針

咱們看一個代碼模擬案例,

public class QingLock {
    public static void main(String[] args) {
        try {
            //睡覺5秒,開啓偏向鎖,可使用JVM參數
            TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {e.printStackTrace();}
        A o = new A();
        //讓線程交替執行
        CountDownLatch countDownLatch = new CountDownLatch(1);
        new Thread(()->{
            o.test();
            countDownLatch.countDown();
        },"1").start();

        new Thread(()->{
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            o.test();
        },"2").start();


    }
}

class A{
    private Object object = new Object();
    public void test(){
        System.out.println("爲進入同步代碼塊*****");
        System.out.println(ClassLayout.parseInstance(object).toPrintable());
        System.out.println("進入同步代碼塊******");
        for (int i = 0; i < 5; i++) {
            synchronized (object){
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }
    }
}

運行結果爲兩個線程交替先後

輕量級鎖強調的是線程交替使用資源,不管線程的個數有幾個,只要沒有同時使用就不會升級爲重量級鎖

在上面的關於輕量級鎖加鎖步驟的講解中,若是線程CAS修改失敗,則判斷棧幀中的owner是否是本身,若是不是就失敗升級爲重量級鎖,而在實際中,JDK加入了一種機制自旋鎖,即修改失敗之後不會當即升級而是進行自旋,在JDK1.6以前自旋次數爲10次,而在1.6又作了優化,改成了自適應自旋鎖,由虛擬機判斷是否須要進行自旋,判斷緣由有:當前線程以前是否獲取到過鎖,若是沒有,則認爲獲取鎖的概率不大,直接升級,若是有則進行自旋獲取鎖。

6. 重量級鎖

前面咱們談到了無鎖-->偏向鎖-->輕量級鎖,如今最後咱們來聊一下重量級鎖。

這個鎖在咱們開發過程當中很常見,線程搶佔資源大部分都是同時的,因此synchronized會直接升級爲重量級鎖。咱們來代碼模擬看一下它的對象頭的情況。

代碼模擬

public class WeightLock {
    public static void main(String[] args) {
        A a = new A();
        for (int i = 0; i < 2; i++) {
             new Thread(()->{
                a.test();
             },"線程"+ i).start();
        }
    }
}

未進入代碼塊以前,二者均爲無鎖狀態

開始執行循環,進入代碼塊

在看一眼,對象頭鎖標誌位

對比上圖,能夠發現,在線程競爭的時候鎖,已經變爲了重量級鎖。接下來咱們來看一下重量級鎖的實現

6.1 Java彙編碼分析

咱們先從Java字節碼分析synchronzied的底層實現,它的主要實現邏輯是依賴於一個monitor對象,當前線程執行遇到monitorenter之後,給當前對象的一個屬性recursions加一(下面會詳細講解),當遇到monitorexit之後該屬性減一,表明釋放鎖。

代碼

Object o = new Object();
synchronized (o){

}

彙編碼

上圖就是上面的四行代碼的彙編碼,咱們能夠看到synchronized的底層是兩個彙編指令

  • monitoreneter表明synchronized塊開始
  • monitorexit表明synchronized塊結束

有兄弟要說了爲何會有兩個monitorexit?這也是我曾經遇到的一個面試題

第一個monitorexit表明了synchronized塊正常退出

第二個monitorexit表明了synchronized塊異常退出

很好理解,當在synchronized塊中出現了異常之後,不能當前線程一直拿着鎖不讓其餘線程使用吧。因此出現了兩個monitorexit

同步代碼塊理解了,咱們再來看一下同步方法。

代碼

public static void main(String[] args) {

}

public synchronized void test01(){

}

彙編碼

咱們能夠看到,同步方法增長了一個ACC_SYNCHRONIZED標誌,它會在同步方法執行以前調用monitorenter,結束之後調用monitorexit指令。

6.2 C++代碼

Java彙編碼的講解中,咱們提到了兩個指令monitorentermonitorexit,其實他們是來源於一個C++對象monitor,在Java中每建立一個對象的時候都會有一個monitor對象被隱式建立,他們和當前對象綁定,用於監視當前對象的狀態。其實說綁定也不算正確,其實際流程爲:線程自己維護了兩個MonitorList列表,分別爲空閒(free)和已經使用(used),當線程遇到同步代碼塊或者同步方法的時候,會從空閒列表中申請一個monitor使用,若是當先線程已經沒有空閒的了,則直接從全局(JVM)獲取一個monitor使用

咱們來看一下C++對這個對象的描述

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0; // 重入次數
    _object       = NULL; //存儲該Monitor對象
    _owner        = NULL; //擁有該Monitor對象的對象
    _WaitSet      = NULL; //線程等待集合(Waiting)
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多線程競爭時的單向鏈表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //阻塞鏈表(Block)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

線程加鎖模型

加鎖流程:

  • 最新進入的線程會進入_cxp棧中,嘗試獲取鎖,若是當前線程得到鎖就執行代碼,若是沒有獲取到鎖則添加到EntryList阻塞隊列中
  • 若是在執行的過程的當前線程被掛起(wait)則被添加到WaitSet等待隊列中,等待被喚醒繼續執行
  • 當同步代碼塊執行完畢之後,從_cxp或者EntryList中獲取一個線程執行

monitorenter加鎖實現

  • CAS修改當前monitor對象的_owner爲當前線程,若是修改爲功,執行操做;
  • 若是修改失敗,判斷_owner對象是否爲當前線程,若是是則令_recursions重入次數加一
  • 若是當前實現是第一次獲取到鎖,則將_recursions設置爲一
  • 等待鎖釋放

阻塞和獲取鎖實現

  • 將當前線程封裝爲一個node節點,狀態設置爲ObjectWaiter::TS_CXQ
  • 將之添加到_cxp棧中,嘗試獲取鎖,若是獲取失敗,則將當前線程掛起,等待喚醒
  • 喚醒之後,從掛起點執行剩下的代碼

monitorexit釋放鎖實現

  • 讓當前線程的_recursions重入次數減一,若是當前重入次數爲0,則直接退出,喚醒其餘線程

參考資料:

馬士兵多線程技術詳解書籍

HotSpot源碼

往期推薦:

一文帶你瞭解Spring MVC的架構思路

Mybatis你只會CRUD嘛

IOC的架構你瞭解嘛

相關文章
相關標籤/搜索