99%的Java程式設計師者,都敗給這一個字!

語言: CN / TW / HK

img

三種應用方式

  1. 修飾例項方法,作用於當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖。
  2. 修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖。
  3. 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件。

修飾例項方法

所謂的例項物件鎖就是用synchronized修飾例項物件中的例項方法,注意是例項方法不包括靜態方法,如下

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

    /**
     * synchronized 修飾例項方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
    /**
     * 輸出結果:
     * 2000000
     */
}

上述程式碼中,我們開啟兩個執行緒操作同一個共享資源即變數i,由於i++;操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取i的域值,那麼第二個執行緒就會與第一個執行緒一起看到同一個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證執行緒安全。此時我們應該注意到synchronized修飾的是例項方法increase,在這樣的情況下,當前執行緒的鎖便是例項物件instance,注意Java中的執行緒同步鎖可以是任意物件。從程式碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於2000000,這便是synchronized關鍵字的作用。這裡我們還需要意識到,當一個執行緒正在訪問一個物件的 synchronized 例項方法,那麼其他執行緒不能訪問該物件的其他 synchronized 方法,畢竟一個物件只有一把鎖,當一個執行緒獲取了該物件的鎖之後,其他執行緒無法獲取該物件的鎖,所以無法訪問該物件的其他synchronized例項方法,但是其他執行緒還是可以訪問該例項物件的其他非synchronized方法,當然如果是一個執行緒 A 需要訪問例項物件 obj1 的 synchronized 方法 f1(當前物件鎖是obj1),另一個執行緒 B 需要訪問例項物件 obj2 的 synchronized 方法 f2(當前物件鎖是obj2),這樣是允許的,因為兩個例項物件鎖並不同相同,此時如果兩個執行緒操作資料並非共享的,執行緒安全是有保障的,遺憾的是如果兩個執行緒操作的是共享資料,那麼執行緒安全就有可能無法保證了,如下程式碼將演示出該現象

COPYpublic class AccountingSyncBad implements Runnable{
    static int i=0;
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新例項
        Thread t1=new Thread(new AccountingSyncBad());
        //new新例項
        Thread t2=new Thread(new AccountingSyncBad());
        t1.start();
        t2.start();
        //join含義:當前執行緒A等待thread執行緒終止之後才能從thread.join()返回
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

上述程式碼與前面不同的是我們同時建立了兩個新例項AccountingSyncBad,然後啟動兩個不同的執行緒對共享變數i進行操作,但很遺憾操作結果是1452317而不是期望結果2000000,因為上述程式碼犯了嚴重的錯誤,雖然我們使用synchronized修飾了increase方法,但卻new了兩個不同的例項物件,這也就意味著存在著兩個不同的例項物件鎖,因此t1和t2都會進入各自的物件鎖,也就是說t1和t2執行緒使用的是不同的鎖,因此執行緒安全是無法保證的。解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣的話,物件鎖就當前類物件,由於無論建立多少個例項物件,但對於的類物件擁有隻有一個,所有在這樣的情況下物件鎖就是唯一的。下面我們看看如何使用將synchronized作用於靜態的increase方法。

修飾靜態方法

當synchronized作用於靜態方法時,其鎖就是當前類的class物件鎖。由於靜態成員不專屬於任何一個例項物件,是類成員,因此通過class物件鎖可以控制靜態 成員的併發操作。需要注意的是如果一個執行緒A呼叫一個例項物件的非static synchronized方法,而執行緒B需要呼叫這個例項物件所屬類的靜態 synchronized方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的class物件,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖,看如下程式碼

COPYpublic class AccountingSyncClass implements Runnable{
    static int i=0;

    /**
     * 作用於靜態方法,鎖是當前class物件,也就是
     * AccountingSyncClass類對應的class物件
     */
    public static synchronized void increase(){
        i++;
    }

    /**
     * 非靜態,訪問時鎖不一樣不會發生互斥
     */
    public synchronized void increase4Obj(){
        i++;
    }

    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新例項
        Thread t1=new Thread(new AccountingSyncClass());
        //new心事了
        Thread t2=new Thread(new AccountingSyncClass());
        //啟動執行緒
        t1.start();t2.start();

        t1.join();t2.join();
        System.out.println(i);
    }
}

由於synchronized關鍵字修飾的是靜態increase方法,與修飾例項方法不同的是,其鎖物件是當前類的class物件。注意程式碼中的increase4Obj方法是例項方法,其物件鎖是當前例項物件,如果別的執行緒呼叫該方法,將不會產生互斥現象,畢竟鎖物件不同,但我們應該意識到這種情況下可能會發現執行緒安全問題(操作了共享靜態變數i)。

修飾程式碼塊

除了使用關鍵字修飾例項方法和靜態方法外,還可以使用同步程式碼塊,在某些情況下,我們編寫的方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的程式碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步程式碼塊的方式對需要同步的程式碼進行包裹,這樣就無需對整個方法進行同步操作了,同步程式碼塊的使用示例如下:

COPYpublic class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    @Override
    public void run() {
        //省略其他耗時操作....
        //使用同步程式碼塊對變數i進行同步操作,鎖物件為instance
        synchronized(instance){
            for(int j=0;j<1000000;j++){
                    i++;
              }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

從程式碼看出,將synchronized作用於一個給定的例項物件instance,即當前例項物件就是鎖物件,每次當執行緒進入synchronized包裹的程式碼塊時就會要求當前執行緒持有instance例項物件鎖,如果當前有其他執行緒正持有該物件鎖,那麼新到的執行緒就必須等待,這樣也就保證了每次只有一個執行緒執行i++;操作。當然除了instance作為物件外,我們還可以使用this物件(代表當前例項)或者當前類的class物件作為鎖,如下程式碼:

COPY//this,當前例項物件鎖
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

//class物件鎖
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

瞭解完synchronized的基本含義及其使用方式後,下面我們將進一步深入理解synchronized的底層實現原理。

特性

原子性

被 synchronized 修飾的程式碼在同一時間只能被一個執行緒訪問,在鎖未釋放之前,無法被其他執行緒訪問到。因此,在 Java 中可以使用 synchronized 來保證方法和程式碼塊內的操作是原子性的。

可見性

對一個變數解鎖之前,必須先把此變數同步回主存中。這樣解鎖後,後續執行緒就可以訪問到被修改後的值。

有序性

synchronized 本身是無法禁止指令重排和處理器優化的,

as-if-serial 語義:不管怎麼重排序(編譯器和處理器為了提高並行度),單執行緒程式的執行結果都不能被改變。

編譯器和處理器無論如何優化,都必須遵守 as-if-serial 語義。

synchronized 修飾的程式碼,同一時間只能被同一執行緒執行。所以,可以保證其有序性。

可重入性

從互斥鎖的設計上來說,當一個執行緒試圖操作一個由其他執行緒持有的物件鎖的臨界資源時,將會處於阻塞狀態,但當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功,在java中synchronized是基於原子性的內部鎖機制,是可重入的,因此在一個執行緒呼叫synchronized方法的同時在其方法體內部呼叫該物件另一個synchronized方法,也就是說一個執行緒得到一個物件鎖後再次請求該物件鎖,是允許的,這就是synchronized的可重入性。

原理

synchronized可以保證方法或者程式碼塊在執行時,同一時刻只有一個方法可以進入到臨界區,同時它還可以保證共享變數的記憶體可見性

位元組碼指令

synchronized同步塊使用了monitorenter和monitorexit指令實現同步,這兩個指令,本質上都是對一個物件的監視器(monitor)進行獲取,這個過程是排他的,也就是說同一時刻只能有一個執行緒獲取到由synchronized所保護物件的監視器。

執行緒執行到monitorenter指令時,會嘗試獲取物件所對應的monitor所有權,也就是嘗試獲取物件的鎖,而執行monitorexit,就是釋放monitor的所有權。

鎖的釋放

獲取建立的 happens before 關係

鎖是 java 併發程式設計中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的執行緒向獲取同一個鎖的執行緒傳送訊息。

下面是鎖釋放 - 獲取的示例程式碼:

COPYclass MonitorExample {
    int a = 0;

    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3

    public synchronized void reader() {  //4
        int i = a;                       //5
        ……
    }                                    //6
}

假設執行緒 A 執行 writer() 方法,隨後執行緒 B 執行 reader() 方法。根據 happens before 規則,這個過程包含的 happens before 關係可以分為兩類:

  1. 根據程式次序規則,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
  2. 根據監視器鎖規則,3 happens before 4。
  3. 根據 happens before 的傳遞性,2 happens before 5。

上述 happens before 關係的圖形化表現形式如下:

img

在上圖中,每一個箭頭連結的兩個節點,代表了一個 happens before 關係。黑色箭頭表示程式順序規則;橙色箭頭表示監視器鎖規則;藍色箭頭表示組合這些規則後提供的 happens before 保證。

上圖表示線上程 A 釋放了鎖之後,隨後執行緒 B 獲取同一個鎖。在上圖中,2 happens before 5。因此,執行緒 A 在釋放鎖之前所有可見的共享變數,線上程 B 獲取同一個鎖之後,將立刻變得對 B 執行緒可見。

記憶體佈局

在Hotspot虛擬機器中,物件在記憶體中的佈局分為三塊區域:

  • 物件頭(Mark Word、Class Metadata Address)、
  • 例項資料
  • 對齊填充

img

例項資料

存放類的屬性資料資訊,包括父類的屬性資訊,如果是陣列的例項部分還包括陣列的長度,這部分記憶體按4位元組對齊。

對齊填充

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

物件頭

Java物件頭是實現synchronized的鎖物件的基礎。一般而言,synchronized使用的鎖物件是儲存在Java物件頭裡。它是輕量級鎖和偏向鎖的關鍵。

它實現synchronized的鎖物件的基礎,這點我們重點分析它,一般而言,synchronized使用的鎖物件是儲存在Java物件頭裡的,jvm中採用2個字來儲存物件頭(如果物件是陣列則會分配3個字,多出來的1個字記錄的是陣列長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明如下表:

虛擬機器位數 頭物件結構 說明
32/64bit Mark Word 儲存物件的hashCode、鎖資訊或分代年齡或GC標誌等資訊
32/64bit Class Metadata Address 型別指標指向物件的類元資料,JVM通過這個指標確定該物件是哪個類的例項
Mark Word

Mark Word用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等等。Java物件頭一般佔有兩個機器碼(在32位虛擬機器中,1個機器碼等於4位元組,也就是32bit)。

鎖狀態 25bit 4bit 1bit是否是偏向鎖 2bit 鎖標誌位
無鎖狀態 物件HashCode 物件分代年齡 0 01

由於物件頭的資訊是與物件自身定義的資料沒有關係的額外儲存成本,因此考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的資料結構,以便儲存更多有效的資料,它會根據物件本身的狀態複用自己的儲存空間,如32位JVM下,除了上述列出的Mark Word預設儲存結構外,還有如下可能變化的結構:

img

其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化後新增加的,稍後我們會簡要分析。這裡我們主要分析一下重量級鎖也就是通常說synchronized的物件鎖,鎖標識位為10,其中指標指向的是monitor物件(也稱為管程或監視器鎖)的起始地址。每個物件都存在著一個 monitor 與之關聯,物件與其 monitor 之間的關係有存在多種實現方式,如monitor可以與物件一起建立銷燬或當執行緒試圖獲取物件鎖時自動生成,但當一個 monitor 被某個執行緒持有後,它便處於鎖定狀態。在Java虛擬機器(HotSpot)中,monitor是由ObjectMonitor實現的,其主要資料結構如下(位於HotSpot虛擬機器原始碼ObjectMonitor.hpp檔案,C++實現的)

COPYObjectMonitor() {
    _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 ;
  }

ObjectMonitor中有兩個佇列,_WaitSet 和 _EntryList,用來儲存ObjectWaiter物件列表( 每個等待鎖的執行緒都會被封裝成ObjectWaiter物件),_owner指向持有ObjectMonitor物件的執行緒,當多個執行緒同時訪問一段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的monitor 後進入 _Owner 區域並把monitor中的owner變數設定為當前執行緒同時monitor中的計數器count加1,若執行緒呼叫 wait() 方法,將釋放當前持有的monitor,owner變數恢復為null,count自減1,同時該執行緒進入 WaitSe t集合中等待被喚醒。若當前執行緒執行完畢也將釋放monitor(鎖)並復位變數的值,以便其他執行緒進入獲取monitor(鎖)。如下圖所示

img

由此看來,monitor物件存在於每個Java物件的物件頭中(儲存的指標的指向),synchronized鎖便是通過這種方式獲取鎖的,也是為什麼Java中任意物件可以作為鎖的原因,同時也是notify/notifyAll/wait等方法存在於頂級物件Object中的原因(關於這點稍後還會進行分析),ok~,有了上述知識基礎後,下面我們將進一步分析synchronized在位元組碼層面的具體語義實現。

Class Metadata Address

型別指標,即是物件指向它的類的元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

Array length

如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。

底層原理

程式碼塊底層原理

關於synchionized程式碼塊底層原理和synchionized方法底層原理,這裡通過利用javap直觀的展現加了synchionized後,我們的程式碼到底出現了些什麼指令來觀察

反編譯程式碼

現在我們重新定義一個synchronized修飾的同步程式碼塊,在程式碼塊中操作共享變數i,如下:

COPYpublic class SyncCodeBlock {

   public int i;

   public void syncTask(){
       //同步程式碼塊
       synchronized (this){
           i++;
       }
   }
}

編譯上述程式碼並使用javap反編譯後得到位元組碼如下(這裡我們省略一部分沒有必要的資訊):

COPYClassfile /***/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class
  Last modified 2018-07-25; size 426 bytes
  MD5 checksum c80bc322c87b312de760942820b4fed5
  Compiled from "SyncCodeBlock.java"
public class com.hc.concurrencys.SyncCodeBlock
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
  //........省略常量池中資料
  //建構函式
  public com.hc.concurrencys.SyncCodeBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
  //===========主要看看syncTask方法實現================
  public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         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
      Exception table:
      //省略其他位元組碼.......
}
SourceFile: "SyncCodeBlock.java"

我們主要關注位元組碼中的如下程式碼

COPY3: monitorenter  //進入同步方法
//..........省略其他  
15: monitorexit   //退出同步方法
16: goto          24
//省略其他.......
21: monitorexit //退出同步方法
鎖的競爭模擬

通過反編譯解讀–同步程式碼塊多執行緒下關於鎖的競爭模擬

  1. 首先從位元組碼中可知同步語句塊的實現使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步程式碼塊的開始位置,monitorexit指令則指明同步程式碼塊的結束位置.
  2. 當執行monitorenter指令時,當前執行緒將試圖獲取 objectref(即物件鎖) 所對應的 monitor 的持有權,當 objectref 的 monitor 的進入計數器為 0,那執行緒可以成功取得 monitor,並將計數器值設定為 1,取鎖成功。
  3. 如果當前執行緒已經擁有 objectref 的 monitor 的持有權,那它可以重入這個 monitor (關於重入性稍後會分析),重入時計數器的值也會加 1。
  4. 倘若其他執行緒已經擁有 objectref 的 monitor 的所有權,那當前執行緒將被阻塞,直到正在執行執行緒執行完畢,即monitorexit指令被執行,執行執行緒將釋放 monitor(鎖)並設定計數器值為0 ,其他執行緒將有機會持有 monitor 。
  5. 值得注意的是編譯器將會確保無論方法通過何種方式完成,方法中呼叫過的每條 monitorenter 指令都有執行其對應 monitorexit 指令,而無論這個方法是正常結束還是異常結束。**為了保證在方法異常完成時 monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器宣告可處理所有的異常,它的目的就是用來執行 monitorexit 指令。**從位元組碼中也可以看出多了一個monitorexit指令,它就是異常結束時被執行的釋放monitor 的指令。

方法底層原理

方法級的同步是隱式,即無需通過位元組碼指令來控制的,它實現在方法呼叫和返回操作之中。

JVM可以從方法常量池中的方法表結構(method_info Structure) 中的 ACC_SYNCHRONIZED 訪問標誌區分一個方法是否同步方法。

當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED訪問標誌是否被設定,如果設定了,執行執行緒將先持有monitor(虛擬機器規範中用的是管程一詞),然後再執行方法,最後再方法完成(無論是正常完成還是非正常完成)時釋放monitor。

在方法執行期間,執行執行緒持有了monitor,其他任何執行緒都無法再獲得同一個monitor。如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的monitor將在異常拋到同步方法之外時自動釋放。

反編譯程式碼

下面我們看看位元組碼層面如何實現:

COPYpublic class SyncMethod {

   public int i;

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

使用javap反編譯後的位元組碼如下:

COPYClassfile /***/src/main/java/com/zejian/concurrencys/SyncMethod.class
  Last modified 2017-6-2; size 308 bytes
  MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
  Compiled from "SyncMethod.java"
public class com.hc.concurrencys.SyncMethod
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool;

   //省略沒必要的位元組碼
  //==================syncTask方法======================
  public synchronized void syncTask();
    descriptor: ()V
    //方法標識ACC_PUBLIC代表public修飾,ACC_SYNCHRONIZED指明該方法為同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
}
SourceFile: "SyncMethod.java"

從位元組碼中可以看出,synchronized修飾的方法並沒有monitorenter指令和monitorexit指令,取得代之的確實是ACC_SYNCHRONIZED標識,該標識指明瞭該方法是一個同步方法,JVM通過該ACC_SYNCHRONIZED訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。

這便是synchronized鎖在同步程式碼塊和同步方法上實現的基本原理的區別。同時我們還必須注意到的是在Java早期版本中,synchronized屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層的作業系統的Mutex Lock(互斥鎖)來實現的,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的synchronized效率低的原因。

本文由傳智教育博學谷狂野架構師教研團隊釋出。

如果本文對您有幫助,歡迎關注點贊;如果您有任何建議也可留言評論私信,您的支援是我堅持創作的動力。

轉載請註明出處!