音視頻開發之旅(53) - Java併發編程 之 synchronized

目錄

  1. synchronized的使用方式
  2. synchronized的原理
  3. 線程的等待、中斷與喚醒
  4. 資料
  5. 收穫

一、synchronized的使用方式

關鍵字 synchronized可以保證在同一個時刻,只有一個線程可以執行某個方法或者某個代碼塊.有如下三種常見的使用:

  • 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
     synchronized void syncIncrease4Obj(){
            i++;
    }
  • 修飾靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
    synchronized static void syncIncrease(){
            i++;
    }
  • 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。
    針對方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的代碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步代碼塊的方式對需要同步的代碼進行包裹
   public void syncIncrease(){
        synchronized (this){
            i++;
        }
    }

二、 synchronized的原理

2.1 查看反彙編代碼

通過javap查看生成的class文件發現,施加了synchronized的代碼塊的實現使用了monitorenter和monitorexit指令。而同步方法則依靠修飾符ACC_SYNCHRONIZED來完成。

先看下synchronized修飾方法的反彙編


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

    public synchronized void increase4Obj(){
        i++;
    }

---> javap -c -v Main.class 反編譯對應彙編如下:

public static synchronized void syncIncrease();
   descriptor: ()V
   flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED  //同步標示
   Code:
     stack=2, locals=0, args_size=0
        0: getstatic     #2                  // Field i:I
        3: iconst_1
        4: iadd
        5: putstatic     #2                  // Field i:I
        8: return
     LineNumberTable:
       line 12: 0
       line 13: 8

 public synchronized void syncIncrease4Obj();
   descriptor: ()V
   flags: ACC_PUBLIC, ACC_SYNCHRONIZED.        //同步標示
   Code:
     stack=2, locals=1, args_size=1
        0: getstatic     #2                  // Field i:I
        3: iconst_1
        4: iadd
        5: putstatic     #2                  // Field i:I
        8: return
     LineNumberTable:
       line 19: 0
       line 20: 8

ACC_SYNCHRONIZED標識,該標識指明瞭該方法是一個同步方法,JVM通過該ACC_SYNCHRONIZED訪問標誌來辨別一個方法是否聲明爲同步方法,從而執行相應的同步調用

再來看下以代碼塊方式使用synchronized 的反編譯

 
   public void syncIncrease(){
        synchronized (this){
            i++;
        }
    }

-->javap -c Main.class 反編譯對應彙編如下:

public void syncIncrease();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter                //進入同步方法
       4: aload_0
       5: dup
       6: getfield      #2                  // Field i:I
       9: iconst_1
      10: iadd
      11: putfield      #2                  // Field i:I
      14: aload_1
      15: monitorexit                 //退出同步方法
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit                 //退出同步方法(這個是針對異常處理的)
      22: aload_2
      23: athrow
      24: return

Q: 從上面的字節碼可以看出,多了一個monitorexit指令,爲什麼?
A: 爲了保證在方法異常完成時 monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,異常結束時被執行的釋放monitor 的指令。

無論採用哪種方式,其本質都是對一個對象的監視器(monitor)的獲取,而這個獲取是排他的,也就是同一時刻只能有一個線程獲得synchrozied所保護對性的監視器。沒有獲得監視器的線程將會被阻塞在同步塊或者同步方法的入口處,進入BLOCKED狀態


圖片來自:《java併發編程的藝術》

當執行monitorenter指令時,當前線程將試圖獲取 objectref(即對象鎖) 所對應的 monitor 的持有權,當 objectref 的 monitor 的進入計數器爲 0,那線程可以成功取得 monitor,並將計數器值設置爲 1,取鎖成功。
如果當前線程已經擁有 objectref 的 monitor 的持有權,那它可以重入這個 monitor,重入時計數器的值也會加 1。
倘若其他線程已經擁有 objectref 的 monitor 的所有權,那當前線程將被阻塞,直到正在執行線程執行完畢,即monitorexit指令被執行

那麼怎麼知道該線程是否獲取了某個對象的監視器吶?
下面我們一起來學習 一個對象在JVM中的的內存佈局,來尋找答案。

2.2 對象頭

在JVM中,對象在內存中的佈局分爲三塊區域:對象頭、實例數據和對齊填充。

圖片來源:深入理解Java併發之synchronized實現原理

對象頭: 是實現synchronized鎖的基礎。
實例變量:存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分是按照4字節對齊
填充數據:用於字節對齊。虛擬機要求對象起始地址必須是8字節的整數倍。

Java頭對象,它是synchronized的鎖對象的基礎,synchronized使用的鎖對象是存儲在Java對象頭裏的Mark Word中


圖片來自:《java併發編程的藝術》

2.3 重量級鎖、偏向鎖、輕量級鎖

2.3.1 重量級鎖

在Java1.6之前synchronized只有重量級鎖,Mark Word指針指向的是monitor對象. 在Java虛擬機(HotSpot)中,monitor是由ObjectMonitor實現的,其主要數據結構如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //記錄個數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //處於wait狀態的線程,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //處於等待鎖block狀態的線程,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

監視器鎖(monitor)是依賴於底層的操作系統的Mutex Lock來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高.

Java 6之後,從JVM層面對synchronized較大優化,引入了輕量級鎖和偏向鎖,減少獲得鎖和釋放鎖所帶來的性能消耗

2.3.2 偏向鎖

偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程, 這樣就省去了大量有關鎖申請的操作,從而也就提供程序的性能

2.3.3 輕量級鎖

輕量級鎖能夠提升程序性能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗數據。需要了解的是,輕量級鎖所適應的場景是線程交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹爲重量級鎖。

自旋鎖

輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。這是基於在大多數情況下,線程持有鎖的時間都不會太長,如果直接掛起操作系統層面的線程可能會得不償失,畢竟操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的線程可以獲得鎖,因此虛擬機會讓當前想要獲取鎖的線程做幾個空循環(這也是稱爲自旋的原因),一般不會太久,可能是50個循環或100循環,在經過若干次循環後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級爲重量級鎖了
引用自:強烈推薦-深入理解Java併發之synchronized實現原理

鎖消除

Java虛擬機在JIT編譯時,通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間

三、 線程的等待、中斷與喚醒

3.1 等待/通知機制

等待/通知的相關方法是任意java對象都具備的,因爲這些方法被定義在Object類上

  • notify: 通知一個在對象上等待的線程,使其從wait方法返回,而返回的前提是該線程獲取到了對象的鎖 notify將等待隊列中的一個等待線程移動到同步隊列中,被移動的線程狀態有WAITING變成BLOCKED

  • notifyAll: 通知所有等待該對象上的線程。

  • wait: 調用該方法的線程進入WAITING狀態,並將當前線程放置到對象的等待隊列。 只有等待另一個線程通知或者被中斷纔會被返回,需要注意,調用wait方法後,會釋放對象的鎖。

  • wait(long): 超時等待一段時間,這裏的參數時間是毫秒,也就是等待長達n毫秒,如果沒有通知就超時返回

  • Wait(long ,int) 對於超時時間更細的控制,可以達到納秒。

在使用上述幾個方法時,必須處於synchronized代碼塊或者synchronized方法中,否則就會拋出IllegalMonitorStateException異常

3.2 中斷與喚醒

//中斷線程(實例方法)
public void Thread.interrupt();

//判斷線程是否被中斷(實例方法)
public boolean Thread.isInterrupted();

//判斷是否被中斷並清除當前中斷狀態(靜態方法)
public static boolean Thread.interrupted();
  • 當一個線程處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()方式中斷該線程,注意此時將會拋出一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改爲非中斷狀態)

  • 當線程處於運行狀態時,也可調用實例方法interrupt()進行線程中斷,但同時必須手動判斷中斷狀態,並編寫中斷線程的代碼(其實就是結束run方法體的代碼)。

public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(){
            @Override
            public void run(){
                while(true){
                    //判斷當前線程是否被中斷
                    if (this.isInterrupted()){
                        System.out.println("線程中斷");
                        break;
                    }
                }

                System.out.println("已跳出循環,線程中斷!");
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();

    }

結合上面兩點,可以採用如何方式判斷:

public void run(){
    try {
    //判斷當前線程是否已中斷,注意interrupted方法是靜態的,執行後會對中斷狀態進行復位
    while (!Thread.interrupted()) {
        TimeUnit.SECONDS.sleep(2);
    }
    } catch (InterruptedException e) {

    }
}
  • 線程的中斷操作對於正在等待獲取的鎖對象的synchronized方法或者代碼塊並不起作用. 由於對象鎖被其他線程佔用,導致等待線程只能等到鎖,此時我們調用了thread.interrupt();但並不能中斷線程。

資料

  1. 圖書:《java併發編程的藝術》
  2. 強烈推薦-深入理解Java併發之synchronized實現原理

收穫

通過本篇的學習實踐

  1. 回顧了synchronized的基本使用
  2. 學習了synchronized的實現同步的原理
  3. 瞭解了JVM對synchronized做的優化
  4. 學習回顧了線程的等待、中斷與喚醒

感謝你的閱讀
下一篇我們繼續學習實踐Java併發編程系列-Lock相關,歡迎關注公衆號“音視頻開發之旅”,一起學習成長。
歡迎交流

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