詳述 synchronized 和 volatile 的實現原理以及兩者的區別

版權聲明:本文的內容大都來自於「zejian_」的博文,略作修改。

線程安全

在併發編程中,線程安全是我們最需要關心的問題,而導致併發問題的原因,主要是:

  • 存在共享數據;
  • 並且,存在多條線程共同操作共享數據。

因此,爲了解決這個問題,我們需要保證當存在多個線程操作共享數據時,同一時刻有且只有一個線程能夠操作共享數據,其他線程必須等到該線程處理完數據之後才能進行處理。在 Java 中,關鍵字synchronized就可以保證在同一個時刻,只有一個線程能夠執行某個方法或者某個代碼塊,主要是對方法或者代碼塊中存在共享數據的操作。除此之外,synchronized另外一個重要的作用,是其可以保證一個線程的變化(主要是共享數據的變化)能夠被其他線程所看到,即保證可見性。

但是並不是所有操作都需要這麼嚴格的限制,所以在 Java 中,還提供了具有稍弱同步語義的volatile關鍵字,用於保證內存可見性。在本文中,我們就主要講解synchronizedvolatile的實現原理以及兩者的區別,

synchronized

使用方式

synchronized關鍵字主要有以下 3 種使用方式,分別爲:

  • 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖;
  • 修飾靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖;
  • 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。

作用於實例方法

所謂的實例對象鎖就是用synchronized修飾實例對象中的實例方法,注意是實例方法不包括靜態方法,如下:

public 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();
        // 輸出結果爲 2000000
        System.out.println(i);
    }
}

在上述代碼中,我們開啓兩個線程操作同一個共享資源即變量i,由於i++操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上 1,分兩步完成,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一起看到同一個值,並執行相同值的加1操作,這也就造成了線程安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證線程安全。

此時我們應該注意到synchronized修飾的是實例方法increase,在這樣的情況下,當前線程的鎖便是實例對象instance,注意 Java 中的線程同步鎖可以是任意對象。從代碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於 2000000,這便是synchronized關鍵字的作用。

這裏我們還需要意識到,當一個線程正在訪問一個對象的synchronized實例方法,那麼其他線程不能訪問該對象的其他synchronized方法,畢竟一個對象只有一把鎖,當一個線程獲取了該對象的鎖之後,其他線程無法獲取該對象的鎖,所以無法訪問該對象的其他synchronized實例方法,但是其他線程還是可以訪問該實例對象的其他非synchronized方法。

當然,如果一個線程 A 需要訪問實例對象obj1synchronized方法f1(當前對象鎖是obj1),另一個線程 B 需要訪問實例對象obj2synchronized方法f2(當前對象鎖是obj2),這樣是允許的,因爲兩個實例對象鎖並不同相同,此時如果兩個線程操作數據並非共享的,線程安全是有保障的,遺憾的是如果兩個線程操作的是共享數據,那麼線程安全就有可能無法保證了,如下代碼將演示出該現象:

public 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了兩個不同的實例對象,這也就意味着存在着兩個不同的實例對象鎖,因此t1t2都會進入各自的對象鎖,也就是說t1t2線程使用的是不同的鎖,因此線程安全是無法保證的。

解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣的話,對象鎖就當前類對象,由於無論創建多少個實例對象,但對於的類對象擁有隻有一個,所有在這樣的情況下對象鎖就是唯一的。下面我們看看如何使用將synchronized作用於靜態的increase方法。

作用於靜態方法

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

public 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

作用於同步代碼塊

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

public 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對象作爲鎖,如下代碼:

// 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的底層實現原理。

實現原理

Java 虛擬機中的同步基於進入和退出管程(Monitor)對象實現, 無論是顯式同步(有明確的monitorentermonitorexit指令,即同步代碼塊)還是隱式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被synchronized修飾的同步方法。同步方法並不是由monitorentermonitorexit指令來實現同步的,而是由方法調用指令讀取運行時常量池中方法的ACC_SYNCHRONIZED標誌來隱式實現的,關於這點,稍後詳細分析。下面先來了解一個概念 Java 對象頭,這對深入理解synchronized實現原理非常關鍵。

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

  • 實例變量:存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按 4 字節對齊。
  • 對齊填充:由於虛擬機要求對象起始地址必須是 8 字節的整數倍,因此填充數據不是必須存在的,僅僅是爲了字節對齊。

而對 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 在默認情況下存儲着對象的 HashCode、分代年齡、鎖標記位等,以下是 32 位 JVM 的 Mark Word 默認存儲結構:

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

由於對象頭的信息是與對象自身定義的數據沒有關係的額外存儲成本,因此考慮到 JVM 的空間效率,Mark Word 被設計成爲一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象本身的狀態複用自己的存儲空間,如在 32 位 JVM 下,除了上述列出的 Mark Word 默認存儲結構外,其結構可能還會發生變化,如鎖狀態可能是輕量級鎖、偏向鎖或重量級鎖。

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

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 ;
  }

ObjectMonitor中有兩個隊列,_WaitSet_EntryList,用來保存ObjectWaiter對象列表(每個等待鎖的線程都會被封裝成ObjectWaiter對象)),_owner指向持有ObjectMonitor對象的線程,當多個線程同時訪問一段同步代碼時,首先會進入_EntryList集合,當線程獲取到對象的monitor後進入_owner區域並把monitor中的owner變量設置爲當前線程同時monitor中的計數器count加 1,若線程調用wait()方法,將釋放當前持有的monitor_owner變量恢復爲nullcount自減 1,同時該線程進入_WaitSet集合中等待被喚醒。若當前線程執行完畢也將釋放monitor並復位變量的值,以便其他線程進入獲取monitor

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

同步代碼塊

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

public class SyncCodeBlock {

   public int i;

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

編譯上述代碼並使用javap反編譯後得到字節碼如下(這裏我們省略一部分沒有必要的信息):

Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class
  Last modified 2017-6-2; size 426 bytes
  MD5 checksum c80bc322c87b312de760942820b4fed5
  Compiled from "SyncCodeBlock.java"
public class com.zejian.concurrencys.SyncCodeBlock
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
  //........省略常量池中數據
  //構造函數
  public com.zejian.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"

我們主要關注字節碼中的如下代碼:

3: monitorenter  //進入同步方法
//..........省略其他  
15: monitorexit   //退出同步方法
16: goto          24
//省略其他.......
21: monitorexit //退出同步方法

從字節碼中可知同步代碼塊的實現使用的是monitorentermonitorexit指令,其中monitorenter指令指向同步代碼塊的開始位置,monitorexit指令則指明同步代碼塊的結束位置,當執行monitorenter指令時,當前線程將試圖獲取objectref(即對象鎖)所對應的monitor的持有權,當objectrefmonitor的進入計數器爲 0,那線程可以成功取得monitor,並將計數器值設置爲 1,取鎖成功。如果當前線程已經擁有objectrefmonitor的持有權,那它可以重入這個monitor(關於重入性稍後會分析),重入時計數器的值也會加 1。

倘若其他線程已經擁有objectrefmonitor的所有權,那當前線程將被阻塞,直到正在執行線程執行完畢,即monitorexit指令被執行,執行線程將釋放monitor並設置計數器值爲 0 ,其他線程將有機會持有monitor。值得注意的是,編譯器將會確保無論方法通過何種方式完成,方法中調用過的每條monitorenter指令都有執行其對應monitorexit指令,而無論這個方法是正常結束還是異常結束。爲了保證在方法異常完成時monitorentermonitorexit指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器聲明可處理所有的異常,它的目的就是用來執行monitorexit指令。從字節碼中也可以看出多了一個monitorexit指令,它就是異常結束時被執行的釋放monitor的指令。

同步方法

方法級的同步是隱式的,即無需通過字節碼指令來控制的,它實現在方法調用和返回操作之中。JVM 可以從方法常量池中的方法表結構中的ACC_SYNCHRONIZED訪問標誌區分一個方法是否同步方法。當方法調用時,調用指令將會 檢查方法的ACC_SYNCHRONIZED訪問標誌是否被設置,如果設置了,執行線程將先持有monitor(虛擬機規範中用的是管程一詞), 然後再執行方法,最後在方法完成(無論是正常完成還是非正常完成)時釋放monitor。在方法執行期間,執行線程持有了monitor,其他任何線程都無法再獲得同一個monitor。如果一個同步方法執行期間拋出了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的monitor將在異常拋到同步方法之外時自動釋放。下面我們看看字節碼層面如何實現:

public class SyncMethod {

   public int i;

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

使用javap反編譯後的字節碼如下:

Classfile /Users/zejian/Downloads/Java8_Action/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.zejian.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效率低的原因。

慶幸的是在 Java 6 之後,Java 官方對從 JVM 層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了,Java 6 之後,爲了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了輕量級鎖和偏向鎖,接下來我們將簡單瞭解一下 Java 官方在 JVM 層面對synchronized鎖的優化。

在 JVM 中,鎖的狀態總共有四種,分別爲:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級,關於重量級鎖,前面我們已詳細分析過,下面我們將介紹偏向鎖和輕量級鎖以及 JVM 的其他優化手段,這裏並不打算深入到每個鎖的實現和轉換過程更多地是闡述 Java 虛擬機所提供的每個鎖的核心優化思想,畢竟涉及到具體過程比較繁瑣,如需瞭解詳細過程可以查閱《深入理解Java虛擬機原理》。

  • 偏向鎖:偏向鎖是 Java 6 之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,因此爲了減少同一線程獲取鎖(會涉及到一些 CAS 操作)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時 Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程序的性能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因爲這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹爲重量級鎖,而是先升級爲輕量級鎖。
  • 輕量級鎖:倘若偏向鎖失敗,虛擬機並不會立即升級爲重量級鎖,它還會嘗試使用一種稱爲輕量級鎖的優化手段(JDK 1.6 之後加入的),此時 Mark Word 的結構也變爲輕量級鎖的結構。輕量級鎖能夠提升程序性能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗數據。需要了解的是,輕量級鎖所適應的場景是線程交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹爲重量級鎖。
  • 自旋鎖:輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。這是基於在大多數情況下,線程持有鎖的時間都不會太長,如果直接掛起操作系統層面的線程可能會得不償失,畢竟操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的線程可以獲得鎖,因此虛擬機會讓當前想要獲取鎖的線程做幾個空循環(這也是稱爲自旋的原因),一般不會太久,可能是 50 個循環或 100 循環,在經過若干次循環後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級爲重量級鎖了。
  • 鎖消除:消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java 虛擬機在 JIT 編譯時(可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBufferappend是一個同步方法,但是在add方法中的StringBuffer屬於一個局部變量,並且不會被其他線程所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM 會自動將其鎖消除。
/**
 * 消除StringBuffer同步鎖
 */
public class StringBufferRemoveSync {

    public void add(String str1, String str2) {
        //StringBuffer是線程安全,由於sb只會在append方法中使用,不可能被其他線程引用
        //因此sb屬於不可能共享的資源,JVM會自動消除內部的鎖
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }

    public static void main(String[] args) {
        StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
        for (int i = 0; i < 10000000; i++) {
            rmsync.add("abc", "123");
        }
    }
}

其他可能需要了解的關鍵點

可重入性

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

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;
    
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){

            //this,當前實例對象鎖
            synchronized(this){
                i++;
                increase();//synchronized的可重入性
            }
        }
    }

    public synchronized void increase(){
        j++;
    }

    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代碼塊執行同步代碼,並在代碼塊中調用了當前實例對象的另外一個synchronized方法,再次請求當前實例鎖時,將被允許,進而執行方法體代碼,這就是重入鎖最直接的體現,需要特別注意另外一種情況,當子類繼承父類時,子類也是可以通過可重入鎖調用父類的同步方法。注意由於synchronized是基於monitor實現的,因此每次重入,monitor中的計數器仍會加 1。

線程中斷

正如中斷二字所表達的意義,在線程運行(run方法)中間打斷它,在 Java 中,提供了以下 3 個有關線程中斷的方法

// 中斷線程(實例方法)
public void Thread.interrupt();
// 判斷線程是否被中斷(實例方法)
public boolean Thread.isInterrupted();
// 判斷是否被中斷並清除當前中斷狀態(靜態方法)
public static boolean Thread.interrupted();

當一個線程處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()方式中斷該線程,注意此時將會拋出一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改爲非中斷狀態),如下代碼將演示該過程:

public class InterruputSleepThread3 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                //while在try中,通過異常中斷就可以退出run循環
                try {
                    while (true) {
                        //當前線程處於阻塞狀態,異常必須捕捉處理,無法往外拋出
                        TimeUnit.SECONDS.sleep(2);
                    }
                } catch (InterruptedException e) {
                    System.out.println("Interruted When Sleep");
                    boolean interrupt = this.isInterrupted();
                    //中斷狀態被複位
                    System.out.println("interrupt:"+interrupt);
                }
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        //中斷處於阻塞狀態的線程
        t1.interrupt();
         /**
          * 輸出結果:
          * Interruted When Sleep
          * interrupt:false
          */
    }
}

如上述代碼所示,我們創建一個線程,並在線程中調用了sleep方法從而使用線程進入阻塞狀態,啓動線程後,調用線程實例對象的interrupt方法中斷阻塞異常,並拋出InterruptedException異常,此時中斷狀態也將被複位。這裏有些人可能會詫異,爲什麼不用Thread.sleep(2000)而是用TimeUnit.SECONDS.sleep(2)

其實原因很簡單,前者使用時並沒有明確的單位說明,而後者非常明確表達秒的單位,事實上後者的內部實現最終還是調用了Thread.sleep(2000),但爲了編寫的代碼語義更清晰,建議使用TimeUnit.SECONDS.sleep(2)的方式,注意TimeUnit是個枚舉類型。除了阻塞中斷的情景,我們還可能會遇到處於運行期且非阻塞的狀態的線程,這種情況下,直接調用Thread.interrupt()中斷線程是不會得到任響應的,如下代碼,將無法中斷非阻塞狀態下的線程:

public class InterruputThread {
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(){
            @Override
            public void run(){
                while(true){
                    System.out.println("未被中斷");
                }
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();

        /**
         * 輸出結果(無限執行):
         *    未被中斷
         *    未被中斷
         *    未被中斷
         *    ......
         */
    }
}

雖然我們調用了interrupt方法,但線程t1並未被中斷,因爲處於非阻塞狀態的線程需要我們手動進行中斷檢測並結束程序,改進後代碼如下:

public class InterruputThread {
    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();

        /**
         * 輸出結果:
         *   線程中斷
         *   已跳出循環,線程中斷!
         */
    }
}

我們在代碼中使用了實例方法isInterrupted判斷線程是否已被中斷,如果被中斷將跳出循環以此結束線程,注意非阻塞狀態調用interrupt()並不會導致中斷狀態重置。綜合所述,可以簡單總結一下中斷兩種情況,一種是當線程處於阻塞狀態或者試圖執行一個阻塞操作時,我們可以使用實例方法interrupt()進行線程中斷,執行中斷操作後將會拋出InterruptException異常(該異常必須捕捉無法向外拋出)並將中斷狀態復位;另外一種是當線程處於運行狀態時,我們也可調用實例方法interrupt()進行線程中斷,但同時必須手動判斷中斷狀態,並編寫中斷線程的代碼(其實就是結束run方法體的代碼)。有時我們在編碼時可能需要兼顧以上兩種情況,那麼就可以如下編寫:

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

事實上,線程的中斷操作對於正在等待獲取的鎖對象的synchronized方法或者代碼塊並不起作用,也就是對於synchronized來說,如果一個線程在等待鎖,那麼結果只有兩種,要麼它獲得這把鎖繼續執行,要麼它就保存等待,即使調用中斷線程的方法,也不會生效。演示代碼如下

public class SynchronizedBlocked implements Runnable{

    public synchronized void f() {
        System.out.println("Trying to call f()");
        while(true) // Never releases lock
            Thread.yield();
    }

    /**
     * 在構造器中創建新線程並啓動獲取對象鎖
     */
    public SynchronizedBlocked() {
        //該線程已持有當前實例鎖
        new Thread() {
            public void run() {
                f(); // Lock acquired by this thread
            }
        }.start();
    }
    public void run() {
        //中斷判斷
        while (true) {
            if (Thread.interrupted()) {
                System.out.println("中斷線程!!");
                break;
            } else {
                f();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlocked sync = new SynchronizedBlocked();
        Thread t = new Thread(sync);
        //啓動後調用f()方法,無法獲取當前實例鎖處於等待狀態
        t.start();
        TimeUnit.SECONDS.sleep(1);
        //中斷線程,無法生效
        t.interrupt();
    }
}

我們在SynchronizedBlocked構造函數中創建一個新線程並啓動獲取調用f()獲取到當前實例鎖,由於SynchronizedBlocked自身也是線程,啓動後在其run方法中也調用了f(),但由於對象鎖被其他線程佔用,導致t線程只能等到鎖,此時我們調用了t.interrupt()但並不能中斷線程。

等待喚醒機制

所謂等待喚醒機制本篇主要指的是notify/notifyAllwait方法,在使用這 3 個方法時,必須處於synchronized代碼塊或者synchronized方法中,否則就會拋出IllegalMonitorStateException異常,這是因爲調用這幾個方法前必須拿到當前對象的監視器monitor對象,也就是說notify/notifyAllwait方法依賴於monitor對象,在前面的分析中,我們知道monitor存在於對象頭的 Mark Word 中,而synchronized關鍵字可以獲取monitor,這也就是爲什麼notify/notifyAllwait方法必須在synchronized代碼塊或者synchronized方法調用的原因。

synchronized (obj) {
       obj.wait();
       obj.notify();
       obj.notifyAll();         
 }

需要特別理解的一點是,與sleep方法不同的是wait方法調用完成後,線程將被暫停,但wait方法將會釋放當前持有的監視器鎖,直到有線程調用notify/notifyAll方法後方能繼續執行,而sleep方法只讓線程休眠並不釋放鎖。同時notify/notifyAll方法調用後,並不會馬上釋放監視器鎖,而是在相應的synchronized(){}/synchronized方法執行結束後才自動釋放鎖。

volatile

volatile是 Java 中提供的另外一個用於併發編程的關鍵字,其在併發編程中很常見,但也容易被濫用。現在,我們就進一步分析volatile關鍵字的語義。volatile是 Java 虛擬機提供的輕量級的同步機制。volatile關鍵字有如下兩個作用:

  • 內存可見性;
  • 禁止指令重排優化。

內存可見性

關於volatile的可見性作用,我們必須意識到被volatile修飾的變量對所有線程總數立即可見的,對volatile變量的所有寫操作總是能立刻反應到其他線程中,但是對於volatile變量運算操作在多線程環境並不保證安全性,例如:

public class VolatileVisibility {
    public static volatile int i =0;

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

正如上述代碼所示,i變量的任何改變都會立馬反應到其他線程中,但是如此存在多條線程同時調用increase()方法的話,就會出現線程安全問題,畢竟i++操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上 1,分兩步完成,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一起看到同一個值,並執行相同值的加1操作,這也就造成了線程安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證線程安全,需要注意的是一旦使用synchronized修飾方法後,由於synchronized本身也具備與volatile相同的特性,即可見性,因此在這樣種情況下就完全可以省去volatile修飾變量。

public class VolatileVisibility {
    public static int i =0;

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

現在來看另外一種場景,可以使用volatile修飾變量達到線程安全的目的,如下:

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由於對於boolean變量close值的修改屬於原子性操作,因此可以通過使用volatile修飾變量close,使用該變量對其他線程立即可見,從而達到線程安全的目的。那麼 JMM 是如何實現讓volatile變量對其他線程立即可見的呢?

實際上,當寫一個volatile變量時,JMM 會把該線程對應的工作內存中的共享變量值刷新到主內存中,當讀取一個volatile變量時,JMM 會把該線程對應的工作內存置爲無效,那麼該線程將只能從主內存中重新讀取共享變量。volatile變量正是通過這種“寫-讀”方式實現對其他線程可見的。

禁止指令重排優化

volatile關鍵字另一個作用就是禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象,關於指令重排優化前面已詳細分析過,這裏主要簡單說明一下volatile是如何實現禁止指令重排優化的。先了解一個概念,內存屏障。

內存屏障(Memory Barrier),又稱內存柵欄,是一個 CPU 指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和 CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說通過插入內存屏障禁止在內存屏障前後的指令執行重排序優化。Memory Barrier的另外一個作用是強制刷出各種 CPU 的緩存數據,因此任何 CPU 上的線程都能讀取到這些數據的最新版本。總之,volatile變量正是通過內存屏障實現其在內存中的語義,即可見性和禁止重排優化。下面看一個非常典型的禁止重排優化的例子 DCL,如下:

public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){
        //第一次檢測
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多線程環境下可能會出現問題的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代碼一個經典的單例的雙重檢測的代碼,這段代碼在單線程環境下並沒有什麼問題,但如果在多線程環境下就可以出現線程安全問題。原因在於某一個線程執行到第一次檢測,讀取到的instance不爲null時,instance的引用對象可能沒有完成初始化。因爲instance = new DoubleCheckLock()可以分爲以下 3 步完成(僞代碼):

// 1.分配對象內存空間
memory = allocate();
// 2.初始化對象
instance(memory);   
// 3.設置instance指向剛分配的內存地址,此時instance!=null
instance = memory;   

由於步驟 1 和步驟 2 間可能會重排序,如下:

// 1.分配對象內存空間
memory = allocate(); 
// 3.設置instance指向剛分配的內存地址,此時instance!=null,但是對象還沒有初始化完成!
instance = memory;  
// 2.初始化對象
instance(memory);   

由於步驟 2 和步驟 3 不存在數據依賴關係,而且無論重排前還是重排後程序的執行結果在單線程中並沒有改變,因此這種重排優化是允許的。但是指令重排只會保證串行語義的執行的一致性(單線程),但並不會關心多線程間的語義一致性。所以當一條線程訪問instance不爲null時,由於instance實例未必已初始化完成,也就造成了線程安全問題。那麼該如何解決呢,很簡單,我們使用volatile禁止instance變量被執行指令重排優化即可。

// 禁止指令重排優化
private volatile static DoubleCheckLock instance;

synchronized 和 volatile 的區別

最後,我們來總結一下synchronizedvolatile的主要區別:

  • 阻塞性:
    • synchronized:可能造成線程阻塞;
    • volatile:不會造成線程阻塞。
  • 作用範圍:
    • synchronized:可以作用於變量、方法和代碼塊級別;
    • volatile:僅能作用於變量級別。
  • 編譯器優化:
    • synchronized:可以被編譯器優化;
    • volatile:禁止編譯器優化。
  • 可見性和原子性:
    • synchronized:保證可見性和原子性;
    • volatile:僅保證可見性。

到這裏,本篇文章就要結束了,希望能夠對大家有所幫助,歡迎大家積極留言討論!


參考資料

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