天天看點

Java的synchronized關鍵字

一、從線程安全開始

1.1、誘因

  • 存在共享資料(也稱臨界資源)
  • 存在多條線程共同操作這些共享資料 解決的根本辦法其實很簡單,隻要保證同一時刻有且隻有一個線程能操作共享資料,其他線程必須等到該線程處理完資料後再對共享資料進行處理。

1.2、鎖的記憶體語義

線程釋放鎖,JMM會把該線程中對應的本地記憶體中的共享變量重新整理到主記憶體中。 線程擷取鎖,JMM會把線程對應的本地記憶體置為無效,進而使被螢幕保護的臨界區代碼必須從主記憶體中讀取共享變量。

1.3、互斥鎖

  • 互斥性:同一時刻隻允許一個線程持有某個對象鎖,互斥性也成為原子性
  • 可見性:確定鎖釋放之前,對共享資料的修改,對後續獲得該鎖的線程可見 Java中synchronized即為互斥鎖,它鎖的不是代碼,鎖的都是對象。

1.4、擷取對象鎖的方式:

1、同步代碼塊

// 代碼示例
/**
 * 方法中有 synchronized(this|object) {} 同步代碼塊
 */
private void syncObjectBlock1() {
    System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1: " +
            new SimpleDateFormat("HH:mm:ss").format(new Date()));
    synchronized (this) {
        // 觀察一下是否是同一個示例對象
        System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1: " + this);
        try {
            System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1_Start: " +
                    new SimpleDateFormat("HH:mm:ss").format(new Date()));
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1_End: " +
                    new SimpleDateFormat("HH:mm:ss").format(new Date()));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}           

2、同步非靜态方法

/**
 * synchronized 修飾非靜态方法
 */
private synchronized void syncObjectMethod1() {
    System.out.println(Thread.currentThread().getName() + "_SyncObjectMethod1: " +
            new SimpleDateFormat("HH:mm:ss").format(new Date()));
    try {
        // 觀察一下是否是同一個示例對象
        System.out.println(Thread.currentThread().getName() + "syncObjectMethod1: " + this);
        System.out.println(Thread.currentThread().getName() + "_SyncObjectMethod1_Start: " +
                new SimpleDateFormat("HH:mm:ss").format(new Date()));
        Thread.sleep(1000);
        System.out.println(Thread.currentThread().getName() + "_SyncObjectMethod1_End: " +
                new SimpleDateFormat("HH:mm:ss").format(new Date()));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}           

注意:同步塊和同步非靜态方法鎖的是同一個對象,即this;同一個類的不同對象鎖是互不幹擾的。

1.5、擷取類鎖的方式:

1、同步代碼塊

// 代碼示例
private void syncClassBlock1() {
    System.out.println(Thread.currentThread().getName() + "_SyncClassBlock1: " +
            new SimpleDateFormat("HH:mm:ss").format(new Date()));
    synchronized (SyncThread.class) {
        try {
            System.out.println(Thread.currentThread().getName() + "_SyncClassBlock1_Start: " +
                    new SimpleDateFormat("HH:mm:ss").format(new Date()));
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "_SyncClassBlock1_End: " +
                    new SimpleDateFormat("HH:mm:ss").format(new Date()));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}           

2、同步靜态方法

private synchronized static void syncClassMethod1() {
    System.out.println(Thread.currentThread().getName() + "_SyncClassMethod1: " +
            new SimpleDateFormat("HH:mm:ss").format(new Date()));
    try {
        System.out.println(Thread.currentThread().getName() + "_SyncClassMethod1_Start: " +
                new SimpleDateFormat("HH:mm:ss").format(new Date()));
        Thread.sleep(1000);
        System.out.println(Thread.currentThread().getName() + "_SyncClassMethod1_End: " +
                new SimpleDateFormat("HH:mm:ss").format(new Date()));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}           

注意:對象鎖和類鎖是不會互相幹擾的。

二、如何實作synchronized

在前文簡單的了解synchronized的使用,這在面試中顯然是不夠的。 本章我們來講一下它的底層實作原理,主要圍繞Java對象頭和Monitor,以JDK8&&hotspot JVM為叙述基礎。

2.1、Monitor

Java對象在記憶體中的布局分為對象頭、執行個體資料、對齊填充。 鎖對象是存儲在對象頭中,對象頭的結構為,

對象頭結構 說明
Mark Word 存儲對象hashcode、分代年齡、鎖類型、鎖标志位等資訊
Class Metadata Address 對象中繼資料指針位址,JVM通過該指針擷取對象的class資訊

synchronized為重量級鎖,該資訊就被記錄在對象的Mark Word中;Moinitor是Java對象天生自帶的一把鎖。每一個對象都有一個Moinitor對象與之關聯,在hotspot中它由ObjectMonitor實作的,來看看它裡面定義了啥?

// hotspot源碼截取
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
  _header       = NULL;
  _count        = 0;
  _waiters      = 0,
  _recursions   = 0;
  _object       = NULL;
  _owner        = NULL;
  _WaitSet      = NULL;
  _WaitSetLock  = 0 ;
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;
  FreeNext      = NULL ;
  _EntryList    = NULL ;
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
  _previous_owner_tid = 0;
}           

重點關注以下field,

  • _owner:指向持有ObjectMonitor對象的線程
  • _WaitSet:存放處于wait狀态的線程隊列,即調用wait()方法的線程
  • _EntryList:存放處于等待鎖block狀态的線程隊列
  • _count:約為_WaitSet 和 _EntryList 的節點數之和
  • _cxq: 多個線程争搶鎖,會先存入這個單向連結清單
  • _recursions: 記錄重入次數 定義中的WaitSet與EntryList,與線程的等待池和鎖池可以聯系起來,每個對象鎖的線程都會封裝至ObjectWait對象,并存儲在裡面;owner指向持有ObjectMonitor對象的線程,當多個線程同時通路同一段代碼時候,首先會進入EntryList中,排隊等候;當線程調用wait方法,那麼ObjectWait對象會重新存入WaitSet,等待被喚醒。 Monitor同樣存在于Java對象的對象頭中,synchronized就是通過該方式擷取鎖,這也解釋了Java中為什麼任意對象都可以作為鎖。

2.2、位元組碼分析

接下來分析一下synchronized具體在位元組碼層面的實作,

public void syncsTask() {
    // 同步代碼塊
    synchronized (this) {
        System.out.println("Hello");
    }
}

// 同步方法
public synchronized void syncTask() {
    System.out.println("Hello Again");
}           

javap -v打開上述class編譯之後的class檔案,讓我們聚焦到code區域, 首先分析syncsTask方法,

// syncsTask方法位元組碼
public void syncsTask();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=2, locals=3, args_size=1
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String Hello
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: aload_1
      13: monitorexit
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit
      20: aload_2
      21: athrow
      22: return           

顯而易見,同步代碼塊使用的是monitorenter與monitorexit指令,monitorenter指向同步代碼塊開始的位置,monitorexit則指明同步代碼塊結束的位置,兩兩配對執行。 當執行monitorenter指令時,目前線程将試圖擷取objectref(即對象鎖) 所對應的monitor的持有權,當objectref的monitor的進入計數器為0,那線程可以成功取得monitor,并将計數器值設定為1,取鎖成功。如果目前線程已經擁有objectref的monitor的持有權,那它可以重入這個monitor。

又一個新概念被引入了重入,下一節介紹(挖坑)。 位元組碼的19行多了一個monitorexit,前面不是說配對執行嗎?這怎麼多了一個monitorexit指令?其實這是編譯器幹了一些“壞事”,為了保證方法在異常時也能夠正确的配對執行,編譯器自動産生了一個異常處理器,可處理所有的異常并執行monitorexit指令,釋放monitor。 再來看看syncTask(),

// syncTask方法位元組碼
public synchronized void syncTask();
  descriptor: ()V
  flags: ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=2, locals=1, args_size=1
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #5                  // String Hello Again
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return           

這裡我們未看到任何的monitor相關的指令,其實方法級的同步是隐式的無需通過指令來實作,出現在flags中的ACC_SYNCHRONIZED标志,即可用來區分方法是否同步。方法在運作時會判斷标志位,執行線程也會取到monitor。

2.3、可重入

重入其實一句話解釋就是當一個線程再次請求自己持有對象鎖的共享資料時,這種情況屬于重入。 synchronized是可重入鎖;ReentrantLock也是。 即同一個線程可以輸出Hello World不會死鎖。

// 可重入
public void syncsTask() {
    synchronized (this) {
        System.out.println("Hello");
        synchronized (this){
            System.out.println("World");
        }
    }
}           

三、為何嗤之以鼻

  • 早期JDK的synchronized是重量級鎖,依賴于系統的Mutex Lock(互斥)
  • 線程之間切換從使用者态轉換至核心态,開銷大。 hotspot做了很多的優化,JDK6之後synchronized的性能已經提升。 例如,自适應自旋、鎖消除、鎖粗化、輕量鎖、偏向鎖等等,使得線程之間更高效的共享資料,解決競争問題,提高程式執行效率。

3.1、自旋鎖與自适應自旋鎖

許多情況下,共享資料的鎖定狀态持續時間較短,切換線程不值得。 通過讓線程處于忙循環等待鎖釋放,期間不出讓CPU,減少線程的切換,該鎖在JDK4就被引入。JDK6之後預設開啟,處于自旋便會不再挂起線程,但如果鎖占用時間過長,就不再推薦使用了,這時候應該通過參數PreBlockSpin參數來更改。 自适應自旋鎖,自旋的次數不再固定,由前一次在同一個鎖上的自旋時間與鎖的擁有者狀态來決定。

3.2、鎖消除

更徹底的優化,JIT編譯時,對運作上下文進行掃描,去除不可能存在競争的鎖。

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

3.3、鎖粗化

JVM對鎖的範圍進行擴大,減少鎖同步的代價。

public static String copyString100Times(String target){
    int i = 0;
    StringBuffer sb = new StringBuffer();
    while (i<100){
        sb.append(target);
    }
    return sb.toString();
}           

3.4、synchronized的四個演變階段

鎖膨脹的方向:無鎖、偏向鎖、輕量級鎖、重量級鎖

偏向鎖

減少同一線程擷取鎖的代價,大多數情況鎖不存在競争,總是由一個線程擷取。 指一段同步代碼一直被一個線程所通路,那麼該線程會自動擷取鎖,降低擷取鎖的代價,不适用于鎖競争比較激烈的多線程場合。

輕量級鎖(flag可以單獨開一章講講)

偏向鎖更新而來,适用于線程交替執行同步塊,自旋

重量級鎖

同步塊或者方法執行時間較長,追求吞吐量,詳見前面小節分析。

優缺點

優點 缺點 場景
偏向鎖 加鎖和解鎖無需CAS操作,沒有額外的性能消耗,和無鎖方法執行時間僅存納秒差異 如果線程間存在鎖競争,會帶來額外鎖撤銷的消耗 隻有一個線程通路同步代碼
輕量級鎖 競争的線程不會阻塞,響應速度提升 若線程長時間無法擷取鎖,自旋會消耗CPU 線程交替執行的同步代碼
重量級鎖 線程競争不自旋,不消耗CPU 線程阻塞,響應時間緩慢,多線程下,頻繁擷取釋放,性能消耗多 追求吞吐,同步代碼執行時間長

四、寫在最後

對于Java線程這塊的内容文章幾乎沒有涉及,按照面試中的路子,其實一般是會從線程的基礎切入到多線程與并發。這裡再立一個flag,有關線程基礎後續會開一個blog。 目前缺少源碼的調用流程可視化呈現,後續涉及到本文中闡述的流程會使用圖形式。 本文為Java面試造火箭之多線程與并發系列一,後續還會涉及JUC與線程池相關内容。 期待大家的關注,我們一起前行,定能造成這火箭。

繼續閱讀