【Java併發編程】11.講點synchronized原理

在這裏插入圖片描述

使用

synchronized關鍵字是併發編程中線程同步的常用手段之一,synchronized是悲觀鎖,其作用有三個:

  1. 互斥性:確保線程互斥的訪問同步代,鎖自動釋放,多個線程操作同個代碼塊或函數必須排隊獲得鎖,
  2. 可見性:保證共享變量的修改能夠及時可見,獲得鎖的線程操作完畢後會將所數據刷新到共享內存區
  3. 有序性:有效解決重排序問題,其用法也有三個:
  1. 修飾實例方法
  2. 修飾靜態方法
  3. 修飾代碼塊

1. 修飾實例方法

synchronized關鍵詞作用在方法的前面,用來鎖定方法,其實默認鎖定的是this對象。

public class Thread1 implements Runnable{
    //共享資源(臨界資源)
    static int i=0;
    //如果沒有synchronized關鍵字,輸出小於20000
    public synchronized void increase(){
        i++;
    }
    public void run() {
        for(int j=0;j<10000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread1 t=new Thread1();
        Thread t1=new Thread(t);
        Thread t2=new Thread(t);
        t1.start();
        t2.start();
        t1.join();//主線程等待t1執行完畢
        t2.join();//主線程等待t2執行完畢
        System.out.println(i);
    }
}

在這裏插入圖片描述

2. 修飾靜態方法

synchronized還是修飾在方法上,不過修飾的是靜態方法,等價於鎖定的是Class對象,

    public class Thread1 {
    //共享資源(臨界資源)
    static int i = 0;

    //如果沒有synchronized關鍵字,輸出小於20000
    public static synchronized void increase() {
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();//主線程等待t1執行完畢
        t2.join();//主線程等待t2執行完畢
        System.out.println(i);
    }
}

在這裏插入圖片描述

3. 修飾代碼塊

用法是在函數體內部對於要修改的參數區間用synchronized來修飾,相比與鎖定函數這個範圍更小,可以指定鎖定什麼對象。

public class Thread1 implements Runnable {
    //共享資源(臨界資源)
    static int i = 0;

    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            //獲得了String的類鎖
            synchronized (String.class) {
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread1 t = new Thread1();
        Thread t1 = new Thread(t);
        Thread t2 = new Thread(t);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

在這裏插入圖片描述
總結:

  1. synchronized修飾的實例方法,多線程併發訪問時,只能有一個線程進入,獲得對象內置鎖,其他線程阻塞等待,但在此期間線程仍然可以訪問其他方法。
  2. synchronized修飾的靜態方法,多線程併發訪問時,只能有一個線程進入,獲得類鎖,其他線程阻塞等待,但在此期間線程仍然可以訪問其他方法。
  3. synchronized修飾的代碼塊,多線程併發訪問時,只能有一個線程進入,根據括號中的對象或者是類,獲得相應的對象內置鎖或者是類鎖
  4. 每個類都有一個類鎖,類的每個對象也有一個內置鎖,它們是互不干擾的,也就是說一個線程可以同時獲得類鎖和該類實例化對象的內置鎖,當線程訪問非synchronzied修飾的方法時,並不需要獲得鎖,因此不會產生阻塞。

管程

管程 (英語:Monitors,也稱爲監視器) 在操作系統中是很重要的概念,管程其實指的是管理共享變量以及管理共享變量的操作過程。有點扮演中介的意思,管程管理一堆對象,多個線程同一時候只能有一個線程來訪問這些東西。

  1. 管程可以看做一個軟件模塊,它是將共享的變量和對於這些共享變量的操作封裝起來,形成一個具有一定接口的功能模塊,進程可以調用管程來實現進程級別的併發控制。
  2. 進程只能互斥得使用管程,即當一個進程使用管程時,另一個進程必須等待。當一個進程使用完管程後,它必須釋放管程並喚醒等待管程的某一個進程。

管程解決互斥問題相對簡單,把共享變量以及共享變量的操作都封裝在一個類中
在這裏插入圖片描述
當線程A和線程B需要獲取共享變量count時,就需要調用get和set方法,而get和set方法則保證互斥性,保證每次只能有一個線程訪問。
店長分配,每一箇中介管理一部分二手房源,

  1. 中介 就是管程。
  2. 多個二手房源被一箇中介管理中,就是一個管程管理着多個系統資源。
  3. 多個客戶就相當於多個線程。

Synchronzied的底層原理

對象頭解析

我們知道在Java的JVM內存區域中一個對象在堆區創建,創建後的對象由三部分組成。
在這裏插入圖片描述
這三部分功能如下:

  1. 填充數據:由於虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊
  2. 實例變量:存放類的屬性數據信息,包括父類的屬性信息,這部分內存按4字節對齊。
  3. 對象頭:主要包括兩部分
  1. Klass Point(類型指針):是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
  2. Mark Word(標記字段):這一部分用於儲存對象自身的運行時數據,如哈希碼GC分代年齡,鎖狀態標誌鎖指針等,這部分數據在32bit和64bit的虛擬機中大小分別爲32bit和64bit,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間中存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間(跟ConcurrentHashMap裏的標誌位類似),詳細情況如下圖:

Mark Word狀態表示位如下:
在這裏插入圖片描述
synchronized不論是修飾方法還是代碼塊,都是通過持有修飾對象的來實現同步,synchronized鎖對象是存在對象頭Mark Word。其中輕量級鎖和偏向鎖是Java6對synchronized鎖進行優化後新增加的,這裏我們主要分析一下重量級鎖也就是通常說synchronized的對象鎖,鎖標識位爲10,其中指針指向的是monitor對象(也稱爲管程或監視器鎖)的起始地址。每個對象都存在着一個 monitor 與之關聯。

在這裏插入圖片描述

反彙編查看

分析對象的monitor前我們先通過反彙編看下同步方法跟同步方法塊在彙編語言級別是什麼樣的指令。

public class SynchronizedTest {
    public synchronized void doSth(){
        System.out.println("Hello World");
    }
    public void doSth1(){
        synchronized (SynchronizedTest.class){
            System.out.println("Hello World");
        }
    }
}

javac SynchronizedTest .java 然後javap -c SynchronizedTest反編譯後看彙編指令如下:

 public synchronized void doSth();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED //  這是重點 方法鎖
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  
         3: ldc           #3    
         5: invokevirtual #4                  
         8: return

  public void doSth1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #5                 
         2: dup
         3: astore_1
         4: monitorenter  //   進入同步方法
         5: getstatic     #2                  
         8: ldc           #3                  
        10: invokevirtual #4                
        13: aload_1
        14: monitorexit  //正常時 退出同步方法
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit  // 異常時 退出同步方法
        21: aload_2
        22: athrow
        23: return

我們可以看到Java編譯器爲我們生成的字節碼。在對於doSth和doSth1的處理上稍有不同。也就是說。JVM對於同步方法和同步代碼塊的處理方式不同。對於同步方法,JVM採用ACC_SYNCHRONIZED標記符來實現同步。 對於同步代碼塊。JVM採用monitorentermonitorexit兩個指令來實現同步。
ACC_SYNCHRONIZED

方法級的同步是隱式的。同步方法的常量池中會有一個ACC_SYNCHRONIZED標誌。當某個線程要訪問某個方法的時候,會檢查是否有ACC_SYNCHRONIZED,如果有設置,則需要先獲得監視器鎖,然後開始執行方法,方法執行之後再釋放監視器鎖。這時如果其他線程來請求執行方法,會因爲無法獲得監視器鎖而被阻斷住。值得注意的是,如果在方法執行過程中,發生了異常,並且方法內部並沒有處理該異常,那麼在異常被拋到方法外面之前監視器鎖會被自動釋放。

monitorenter跟monitorexit

可以把執行monitorenter指令理解爲加鎖,執行monitorexit理解爲釋放鎖。 每個對象維護着一個記錄着被鎖次數的計數器。未被鎖定的對象的該計數器爲0,當一個線程獲得鎖(執行monitorenter)後,該計數器自增變爲 1 ,當同一個線程再次獲得該對象的鎖的時候,計數器再次自增。當同一個線程釋放鎖(執行monitorexit指令)的時候,計數器再自減。當計數器爲0的時候。鎖將被釋放,其他線程便可以獲得鎖。

結論:同步方法和同步代碼塊底層都是通過monitor來實現同步的。
兩者的區別:同步方式是通過方法中的access_flags中設置ACC_SYNCHRONIZED標誌來實現,同步代碼塊是通過monitorentermonitorexit來實現。

monitor解析

我們知道了每個對象都與一個monitor相關聯,而monitor可以被線程擁有或釋放,在Java虛擬機(HotSpot)中,monitor是由ObjectMonitor實現的,其主要數據結構如下(位於HotSpot虛擬機源碼ObjectMonitor.hpp文件,C++實現的)。

ObjectMonitor() {
    _count        = 0;      //記錄數
    _recursions   = 0;      //鎖的重入次數
    _owner        = NULL;   //指向持有ObjectMonitor對象的線程 
    _WaitSet      = NULL;   //調用wait後,線程會被加入到_WaitSet
    _EntryList    = NULL ;  //等待獲取鎖的線程,會被加入到該列表
}

monitor運行圖如下:
在這裏插入圖片描述
對於一個synchronized修飾的方法(代碼塊)來說:

  1. 當多個線程同時訪問該方法,那麼這些線程會先被放進_EntryList隊列,此時線程處於blocked狀態
  2. 當一個線程獲取到了對象的monitor後,那麼就可以進入running狀態,執行方法塊,此時,ObjectMonitor對象的_owner指向當前線程,_count加1表示當前對象鎖被一個線程獲取。
  3. running狀態的線程調用wait()方法,那麼當前線程釋放monitor對象,進入waiting狀態,ObjectMonitor對象的_owner變爲null,_count減1,同時線程進入_WaitSet隊列,直到有線程調用notify()方法喚醒該線程,則該線程進入_EntryList隊列,競爭到鎖再進入_owner區。
  4. 如果當前線程執行完畢,那麼也釋放monitor對象,ObjectMonitor對象的_owner變爲null,_count減1。

因爲監視器鎖(monitor)是依賴於底層的操作系統的Mutex Lock來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的synchronized效率低的原因。慶幸的是在Java 6之後Java官方對從JVM層面對synchronized較大優化最終提升顯著,Java 6之後,爲了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了鎖升級的概念。

鎖升級

synchronized鎖有四種狀態,無鎖偏向鎖輕量級鎖重量級鎖。這幾個狀態會隨着競爭狀態逐漸升級,鎖可以升級但不能降級,但是偏向鎖狀態可以被重置爲無鎖狀態。

偏向鎖

因爲經過HotSpot的作者大量的研究發現,大多數時候是不存在鎖競爭的,常常是一個線程多次獲得同一個鎖,因此如果每次都要競爭鎖會增大很多沒有必要付出的代價,爲了降低獲取鎖的代價,才引入的偏向鎖。
核心思想:

如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程序的性能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因爲這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹爲重量級鎖,而是先升級爲輕量級鎖。

具體流程:當線程1訪問代碼塊並獲取鎖對象時,會在java對象頭和棧幀中記錄偏向的鎖的threadID,因爲偏向鎖不會主動釋放鎖,因此以後線程1再次獲取鎖的時候,需要比較當前線程的threadID和Java對象頭中的threadID是否一致,如果一致(還是線程1獲取鎖對象),則無需使用CAS來加鎖、解鎖;如果不一致(其他線程,如線程2要競爭鎖對象,而偏向鎖不會主動釋放因此還是存儲的線程1的threadID),那麼需要查看Java對象頭中記錄的線程1是否存活,如果沒有存活,那麼鎖對象被重置爲無鎖狀態,其它線程(線程2)可以競爭將其設置爲偏向鎖;如果存活,那麼立刻查找該線程(線程1)的棧幀信息,如果還是需要繼續持有這個鎖對象,那麼暫停當前線程1,撤銷偏向鎖,升級爲 輕量級鎖,如果線程1 不再使用該鎖對象,那麼將鎖對象狀態設爲無鎖狀態,重新偏向新的線程。

輕量級鎖

輕量級鎖考慮的是競爭鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。因爲阻塞線程需要CPU從用戶態轉到內核態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,因此這個時候就乾脆不阻塞這個線程,讓它自旋這等待鎖釋放。

原理跟升級
線程1獲取輕量級鎖時會先把鎖對象的對象頭MarkWord複製一份到線程1的棧幀中創建的用於存儲鎖記錄的空間(稱爲DisplacedMarkWord),然後使用CAS把對象頭中的內容替換爲線程1存儲的鎖記錄(DisplacedMarkWord)的地址;

如果在線程1複製對象頭的同時(在線程1CAS之前),線程2也準備獲取鎖,複製了對象頭到線程2的鎖記錄空間中,但是在線程2CAS的時候,發現線程1已經把對象頭換了,線程2的CAS失敗,那麼線程2就嘗試使用自旋鎖來等待線程1釋放鎖。 自旋鎖簡單來說就是讓線程2在循環中不斷CAS嘗試獲得鎖對象。

但是如果自旋的時間太長也不行,因爲自旋是要消耗CPU的,因此自旋的次數是有限制的,比如10次或者100次,如果自旋次數到了線程1還沒有釋放鎖,或者線程1還在執行,線程2還在自旋等待,那麼這個時候輕量級鎖就會膨脹爲重量級鎖。重量級鎖把除了擁有鎖的線程都阻塞,防止CPU空轉。
在這裏插入圖片描述

PS:關於這個鎖的大致流程有一個不錯的 WC 講解。

鎖消除

消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java虛擬機在JIT編譯時通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消,除沒有必要的鎖,可以節省毫無意義的請求鎖時間,我們知道StringBuffer是線程安全的,裏面包含鎖的存在,但是如果我們在函數內部使用StringBuffer那麼代碼會在JIT後會自動將鎖釋放掉哦。

對比如下:

鎖狀態 優點 缺點 適用場景
偏向鎖 加鎖解鎖無需額外消耗,跟非同步方法時間相差納秒級別 如果競爭線程多,會帶來額外的鎖撤銷的消耗 基本沒有其他線程競爭的同步場景
輕量級鎖 競爭的線程不會阻塞而是在自旋,可提高程序響應速度 如果一直無法獲得會自旋消耗CPU 少量線程競爭,持有鎖時間不長,追求響應速度
重量級鎖 線程競爭不會導致CPU自旋跟消耗CPU資源 線程阻塞,響應時間長 很多線程競爭鎖,切鎖持有時間長,追求吞吐量時候

PS:ReentrantLock底層實現依賴於特殊的CPU指令,比如發送lock指令和unlock指令,不需要用戶態和內核態的切換,所以效率高(這裏和volatile底層原理類似),而synchronized底層由監視器鎖(monitor)是依賴於底層的操作系統的Mutex Lock需要用戶態和內核態的切換,所以效率會低一些。

鎖升級流程圖

最後奉上unbelievableme繪製的鎖升級大圖:
在這裏插入圖片描述

參考

深入理解synchronized
阿里專家獨門博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章