synchronized的使用以及原理

synchronized的三種應用方式

synchronized關鍵字最主要有以下幾種應用方式

這裏寫圖片描述

java對象頭

在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

Mark Word 被設計成爲一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象本身的狀態複用自己的存儲空間,如32位JVM下,除了上述列出的Mark Word默認存儲結構外,還有如下可能變化的結構:
這裏寫圖片描述

synchronized的原理

synchronized代碼塊
package com.own.learn.concurrent._synchronized;

public class SynchronizedCoodeBlockDemo {

    static Integer flag = new Integer(0);
    public int i;

    public void syncTask() {

        synchronized (flag) {
            i++;
        }
    }
}

查看字節碼:javap -v com/own/learn/concurrent/_synchronized/SynchronizedCoodeBlockDemo,只拿到syncTask方法

  public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: getstatic     #2                  // Field flag:Ljava/lang/Integer;
         3: dup
         4: astore_1
         5: monitorenter
         6: aload_0
         7: dup
         8: getfield      #3                  // Field i:I
        11: iconst_1
        12: iadd
        13: putfield      #3                  // Field i:I
        16: aload_1
        17: monitorexit
        18: goto          26
        21: astore_2
        22: aload_1
        23: monitorexit
        24: aload_2
        25: athrow
        26: return
      Exception table:
         from    to  target type
             6    18    21   any
            21    24    21   any
      LineNumberTable:
        line 10: 0
        line 11: 6
        line 12: 16
        line 13: 26
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      27     0  this   Lcom/own/learn/concurrent/_synchronized/SynchronizedCoodeBlockDemo;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 21
          locals = [ class com/own/learn/concurrent/_synchronized/SynchronizedCoodeBlockDemo, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

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

synchronized方法底層原理

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

package com.own.learn.concurrent._synchronized;

public class SynchronizedMethodDemo {


    public int i;

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

用javap查看字節碼:

  public synchronized void syncTask();
    descriptor: ()V
    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 9: 0
        line 10: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/own/learn/concurrent/_synchronized/SynchronizedMethodDemo;
}
SourceFile: "SynchronizedMethodDemo.java"

synchronized修飾的方法並沒有monitorenter指令和monitorexit指令,取得代之的確實是ACC_SYNCHRONIZED標識,該標識指明瞭該方法是一個同步方法,JVM通過該ACC_SYNCHRONIZED訪問標誌來辨別一個方法是否聲明爲同步方法,從而執行相應的同步調用。這便是synchronized鎖在同步代碼塊和同步方法上實現的基本原理

Java虛擬機對synchronized的優化

Java早期版本中,synchronized屬於重量級鎖,效率低下,因爲監視器鎖(monitor)是依賴於底層的操作系統的Mutex Lock來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的synchronized效率低的原因。慶幸的是在Java 6之後Java官方對從JVM層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了,Java 6之後,爲了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了輕量級鎖和偏向鎖,接下來我們將簡單瞭解一下Java官方在JVM層面對synchronized鎖的優化。

偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。

偏向鎖的獲取

當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程

偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。
偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處於活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作爲偏向鎖,最後喚醒暫停的線程。

下圖線程1展示了偏向鎖獲取的過程,線程2展示了偏向鎖撤銷的過程。
這裏寫圖片描述

如何關閉偏向鎖

偏向鎖在Java 6和Java 7裏是默認啓用的,但是它在應用程序啓動幾秒鐘之後才激活,如有必要可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程序裏所有的鎖通常情況下處於競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態

輕量級鎖

加鎖

線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱爲Displaced Mark Word。然後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖

解鎖

輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。
這裏寫圖片描述
因爲自旋會消耗CPU,爲了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

自旋鎖

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

鎖消除

消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java虛擬機在JIT編譯時(可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個局部變量,並且不會被其他線程所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。

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

}
各種鎖的比較

這裏寫圖片描述

讓你徹底理解Synchronized

深入理解Java併發之synchronized實現原理

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