這一次,完全搞懂Java中的synchronized關鍵字

多線程併發是Java語言中很是重要的一塊內容,同時,也是Java基礎的一個難點。說它重要是由於多線程是平常開發中頻繁用到的知識,說它難是由於多線程併發涉及到的知識點很是之多,想要徹底掌握Java的併發相關知識並不是易事。也正所以,Java併發成了Java面試中最高頻的知識點之一。本系列文章將從Java內存模型、volatile關鍵字、synchronized關鍵字、ReetrantLock、Atomic併發類以及線程池等方面來系統的認識Java的併發知識。經過本系列文章的學習你將深刻理解volatile關鍵字的做用,瞭解到synchronized實現原理、AQS和CLH隊列鎖,清晰的認識自旋鎖、偏向鎖、樂觀鎖、悲觀鎖...等等一系列讓人眼花繚亂的併發知識。html

多線程併發系列文章:java

這一次,完全搞懂Java內存模型與volatile關鍵字linux

這一次,完全搞懂Java中的synchronized關鍵字git

這一次,完全搞懂Java中的ReentranLock實現原理github

這一次,完全搞懂Java併發包中的Atomic原子類面試

深刻理解Java線程的等待與喚醒機制(一)數組

深刻理解Java線程的等待與喚醒機制(二)markdown

Java併發系列終結篇:完全搞懂Java線程池的工做原理數據結構

本文是Java併發系列的第二篇文章,將詳細的講解synchronized關鍵字以及其底層實現原理。多線程

開始以前先給你們推薦一下AndroidNote這個GitHub倉庫,這裏是個人學習筆記,同時也是我文章初稿的出處。這個倉庫中彙總了大量的java進階和Android進階知識。是一個比較系統且全面的Android知識庫。對於準備面試的同窗也是一份不可多得的面試寶典,歡迎你們到GitHub的倉庫主頁關注。

1、synchronized基本使用

上篇文章詳細講解了volatile關鍵字,咱們知道volatile關鍵字能夠保證共享變量的可見性和有序性,但並不能保證原子性。若是既想保證共享變量的可見性和有序性,又想保證原子性,那麼synchronized關鍵字是一個不錯的選擇。

synchronized的使用很簡單,能夠用它來修飾實例方法和靜態方法,也能夠用來修飾代碼塊。值的注意的是synchronized是一個對象鎖,也就是它鎖的是一個對象。所以,不管使用哪種方法,synchronized都須要有一個鎖對象

1.修飾實例方法

synchronized修飾實例方法只須要在方法上加上synchronized關鍵字便可。

public synchronized void add(){
       i++;
}
複製代碼

此時,synchronized加鎖的對象就是這個方法所在實例的自己。

2.修飾靜態方法

synchronized修飾靜態方法的使用與實例方法並沒有差異,在靜態方法上加上synchronized關鍵字便可

public static synchronized void add(){
       i++;
}
複製代碼

此時,synchronized加鎖的對象爲當前靜態方法所在類的Class對象。

3.修飾代碼塊

synchronized修飾代碼塊須要傳入一個對象。

public void add() {f synchronized (this) {
        i++;
    }
}
複製代碼

很明顯,此時synchronized加鎖對象即爲傳入的這個對象實例。

到這裏不是道你是否有個疑問,synchronized關鍵字是如何對一個對象加鎖實現代碼同步的呢?若是想弄清楚,那就不得不先了解一下Java對象的對象頭了。

2、Java對象頭與Monitor對象

在JVM中,對象在內存中存儲的佈局能夠分爲三個區域,分別是對象頭、實例數據以及填充數據。

  • 實例數據 存放類的屬性數據信息,包括父類的屬性信息,這部份內存按4字節對齊。
  • 填充數據 因爲虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊。
  • 對象頭 在HotSpot虛擬機中,對象頭又被分爲兩部分,分別爲:Mark Word(標記字段)、Class Pointer(類型指針)。若是是數組,那麼還會有數組長度。對象頭是本章內容的重點,下邊詳細討論。

1.對象頭

在對象頭的Mark Word中主要存儲了對象自身的運行時數據,例如哈希碼、GC分代年齡、鎖狀態、線程持有的鎖、偏向線程ID以及偏向時間戳等。同時,Mark Word也記錄了對象和鎖有關的信息。

當對象被synchronized關鍵字當成同步鎖時,和鎖相關的一系列操做都與Mark Word有關。因爲在JDK1.6版本中對synchronized進行了鎖優化,引入了偏向鎖和輕量級鎖(關於鎖優化後邊詳情討論)。Mark Word在不一樣鎖狀態下存儲的內容有所不一樣。咱們以32位JVM中對象頭的存儲內容以下圖所示。

object_header.png

從圖中能夠清楚的看到,Mark Word中有2bit的數據用來標記鎖的狀態。無鎖狀態和偏向鎖標記位爲01,輕量級鎖的狀態爲00,重量級鎖的狀態爲10。

  • 當對象爲偏向鎖時,Mark Word存儲了偏向線程的ID;
  • 當狀態爲輕量級鎖時,Mark Word存儲了指向線程棧中Lock Record的指針;
  • 當狀態爲重量級鎖時,Mark Word存儲了指向堆中的Monitor對象的指針。

當前咱們只討論重量級鎖,由於重量級鎖至關於對synchronized優化以前的狀態。關於偏向鎖和輕量級鎖在後邊鎖優化章節中詳細講解。

能夠看到,當爲重量級鎖時,對象頭的MarkWord中存儲了指向Monitor對象的指針。那麼Monitor又是什麼呢?

2.Monitor對象

Monitor對象被稱爲管程或者監視器鎖。在Java中,每個對象實例都會關聯一個Monitor對象。這個Monitor對象既能夠與對象一塊兒建立銷燬,也能夠在線程試圖獲取對象鎖時自動生成。當這個Monitor對象被線程持有後,它便處於鎖定狀態。

在HotSpot虛擬機中,Monitor是由ObjectMonitor實現的,它是一個使用C++實現的類,主要數據結構以下:

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 ; // 阻塞隊列,線程被喚醒後根據決策判讀是放入cxq仍是EntryList
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 沒有搶到鎖的線程會被放到這個隊列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }
複製代碼

ObjectMonitor中有五個重要部分,分別爲_ower,_WaitSet,_cxq,_EntryList和count。

  • _ower 用來指向持有monitor的線程,它的初始值爲NULL,表示當前沒有任何線程持有monitor。當一個線程成功持有該鎖以後會保存線程的ID標識,等到線程釋放鎖後_ower又會被重置爲NULL;
  • _WaitSet 調用了鎖對象的wait方法後的線程會被加入到這個隊列中;
  • _cxq 是一個阻塞隊列,線程被喚醒後根據決策判讀是放入cxq仍是EntryList;
  • _EntryList 沒有搶到鎖的線程會被放到這個隊列;
  • count 用於記錄線程獲取鎖的次數,成功獲取到鎖後count會加1,釋放鎖時count減1。

若是線程獲取到對象的monitor後,就會將monitor中的ower設置爲該線程的ID,同時monitor中的count進行加1. 若是調用鎖對象的wait()方法,線程會釋放當前持有的monitor,並將owner變量重置爲NULL,且count減1,同時該線程會進入到_WaitSet集合中等待被喚醒。

另外_WaitSet,_cxq與_EntryList都是鏈表結構的隊列,存放的是封裝了線程的ObjectWaiter對象。若是不深刻虛擬機查看相關源碼很難理解這幾個隊列的做用,關於源碼會在後邊系列文章中分析。這裏我簡單說下它們之間的關係,以下:

在多條線程競爭monitor鎖的時候,全部沒有競爭到鎖的線程會被封裝成ObjectWaiter並加入_EntryList隊列。 當一個已經獲取到鎖的線程,調用鎖對象的wait方法後,線程也會被封裝成一個ObjectWaiter並加入到_WaitSet隊列中。 當調用鎖對象的notify方法後,會根據不一樣的狀況來決定是將_WaitSet集合中的元素轉移到_cxq隊列仍是_EntryList隊列。 等到得到鎖的線程釋放鎖後,又會根據條件來執行_EntryList中的線程或者將_cxq轉移到_EntryList中再執行_EntryList中的線程。

因此,能夠看得出來,_WaitSet存放的是處於WAITING狀態等待被喚醒的線程。而_EntryList隊列中存放的是等待鎖的BLOCKED狀態。_cxq隊列僅僅是臨時存放,最終仍是會被轉移到_EntryList中等待獲取鎖。

瞭解了對象頭和Monitor,那麼synchronized關鍵字究竟是如何作到與monitor關聯的呢?

3、synchronized底層實現原理

在Java代碼中,咱們只是使用了synchronized關鍵字就實現了同步效果。那他究竟是怎麼作到的呢?這就須要咱們經過javap工具來反彙編出字節指令一探究竟了。

1.同步代碼塊

經過javap -v來反彙編下面的一段代碼。

public void add() {
    synchronized (this) {
        i++;
    }
}
複製代碼

能夠獲得以下的字節碼指令:

public class com.zhangpan.text.TestSync {
  public com.zhangpan.text.TestSync();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void add();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter    // synchronized關鍵字的入口
       4: getstatic     #2                  // Field i:I
       7: iconst_1
       8: iadd
       9: putstatic     #2                  // Field i:I
      12: aload_1
      13: monitorexit  // synchronized關鍵字的出口
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit // synchronized關鍵字的出口
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}
複製代碼

從字節碼指令中能夠看到add方法的第3條指令處和第1三、19條指令處分別有monitorenter和moniterexit兩條指令。另外第四、七、八、九、13這幾條指令其實就是i++的指令。由此能夠得出在字節碼中會在同步代碼塊的入口和出口加上monitorenter和moniterexit指令。當執行到monitorenter指令時,線程就會去嘗試獲取該對象對應的Monitor的全部權,即嘗試得到該對象的鎖。

當該對象的 monitor 的計數器count爲0時,那線程能夠成功取得 monitor,並將計數器值設置爲 1,取鎖成功。若是當前線程已經擁有該對象monitor的持有權,那它能夠重入這個 monitor ,計數器的值也會加 1。而當執行monitorexit指令時,鎖的計數器會減1。

假若其餘線程已經擁有monitor 的全部權,那麼當前線程獲取鎖失敗將被阻塞並進入到_WaitSet中,直到等待的鎖被釋放爲止。也就是說,當全部相應的monitorexit指令都被執行,計數器的值減爲0,執行線程將釋放 monitor(鎖),其餘線程纔有機會持有 monitor 。

2.同步方法的實現

同步方法的字節碼指令與同步代碼塊的字節指令有所差別。咱們先來經過javap -v查看下面代碼的字節碼指令。

public synchronized void add(){
       i++;
}
複製代碼

反彙編後可獲得以下的字節指令

public synchronized void add();
    descriptor: ()V
    flags: (0x0021) 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 5: 0
        line 6: 10

複製代碼

能夠看到這裏並無monitorenter和moniterexit兩條指令,而是在方法的flag上加入了ACC_SYNCHRONIZED的標記位。這其實也容易理解,由於整個方法都是同步代碼,所以就不須要標記同步代碼的入口和出口了。當線程線程執行到這個方法時會判斷是否有這個ACC_SYNCHRONIZED標誌,若是有的話則會嘗試獲取monitor對象鎖。執行步驟與同步代碼塊一致,這裏就再也不贅述了。

4、重量級鎖存在性能問題

在Linux系統架構中能夠分爲用戶空間和內核,咱們的程序都運行在用戶空間,進入用戶運行狀態就是所謂的用戶態。在用戶態可能會涉及到某些操做如I/O調用,就會進入內核中運行,此時進程就被稱爲內核運行態,簡稱內核態。

linux_kernel.png

  • 內核: 本質上能夠理解爲一種軟件,控制計算機的硬件資源,並提供上層應用程序運行的環境。
  • 用戶空間: 上層應用程序活動的空間。應用程序的執行必須依託於內核提供的資源,包括CPU資源、存儲資源、I/O資源等。
  • 系統調用: 爲了使上層應用可以訪問到這些資源,內核必須爲上層應用提供訪問的接口:即系統調用。

上邊咱們已經提到了使用monitor是重量級鎖的加鎖方式。在objectMonitor.cpp中會涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等內核函數, 執行同步代碼塊,沒有競爭到鎖的對象會park()被掛起,競爭到鎖的線程會unpark()喚醒。這個時候就會存在操做系統用戶態和內核態的轉換,這種切換會消耗大量的系統資源。試想,若是程序中存在大量的鎖競爭,那麼會引發程序頻繁的在用戶態和內核態進行切換,嚴重影響到程序的性能。這也是爲何說synchronized效率低的緣由

爲了解決這一問題,在JDK1.6中引入了偏向鎖和輕量級鎖來優化synchronized。

5、synchronized鎖優化

JDK1.6中引入偏向鎖和輕量級鎖對synchronized進行優化。此時的synchronized一共存在四個狀態:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。鎖着鎖競爭激烈程度,鎖的狀態會出現一個升級的過程。便可以從偏向鎖升級到輕量級鎖,再升級到重量級鎖。鎖升級的過程是單向不可逆的,即一旦升級爲重量級鎖就不會再出現降級的狀況。

1.幾種鎖狀態

接下來咱們來詳細的認識一下這幾種鎖狀態。

1).偏向鎖

經研究發現,在大多數狀況下鎖不只不存在多線程競爭關係,並且大多數狀況都是被同一線程屢次得到。所以,爲了減小同一線程獲取鎖的代價而引入了偏向鎖的概念。

偏向鎖的核心思想是,若是一個線程得到了鎖,那麼鎖就進入偏向模式,此時Mark Word的結構也變爲偏向鎖結構,即將對象頭中Mark Word的第30bit的值改成1,而且在Mark Word中記錄該線程的ID。當這個線程再次請求鎖時,無需再作任何同步操做,便可獲取鎖的過程,這樣就省去了大量有關鎖申請的操做,從而也就提高了程序的性能。因此,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續屢次是同一個線程申請相同的鎖。

可是,對於鎖競爭比較激烈的狀況,偏向鎖就有問題了。由於每次申請鎖的均可能是不一樣線程。這種狀況使用偏向鎖就會得不償失,此時就會升級爲輕量級鎖。

2).輕量級鎖

輕量級鎖優化性能的依據是對於大部分的鎖,在整個同步生命週期內都不存在競爭。 當升級爲輕量級鎖以後,MarkWord的結構也會隨之變爲輕量級鎖結構。JVM會利用CAS嘗試把對象本來的Mark Word 更新爲Lock Record的指針,成功就說明加鎖成功,改變鎖標誌位爲00,而後執行相關同步操做。

輕量級鎖所適應的場景是線程交替執行同步塊的場合,若是存在同一時間訪問同一鎖的場合,就會致使輕量級鎖就會失效,進而膨脹爲重量級鎖。

3).自旋鎖

輕量級鎖失敗後,虛擬機爲了不線程真實地在操做系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。

自旋鎖是基於在大多數狀況下,線程持有鎖的時間都不會太長。若是直接掛起操做系統層面的線程可能會得不償失,畢竟操做系統實現線程之間的切換時須要從用戶態轉換到核心態,這個狀態之間的轉換須要相對比較長的時間,時間成本相對較高,所以自旋鎖會假設在不久未來,當前的線程能夠得到鎖,所以虛擬機會讓當前想要獲取鎖的線程作幾個空循環(這也是稱爲自旋的緣由),不斷的嘗試獲取鎖。空循環通常不會執行太屢次,多是50個循環或100循環,在通過若干次循環後,若是獲得鎖,就順利進入同步代碼。若是還不能得到鎖,那就會將線程在操做系統層面掛起,即進入到重量級鎖。

這就是自旋鎖的優化方式,這種方式確實也是能夠提高效率的。

2.synchronized鎖升級過程

在瞭解了jdk1.6引入的這幾種鎖以後,咱們來詳細的看一下synchronized是怎麼一步步進行鎖升級的。

(1)當沒有被當成鎖時,這就是一個普通的對象,Mark Word記錄對象的HashCode,鎖標誌位是01,是否偏向鎖那一位是0;

(2)當對象被當作同步鎖並有一個線程A搶到了鎖時,鎖標誌位仍是01,可是否偏向鎖那一位改爲1,前23bit記錄搶到鎖的線程id,表示進入偏向鎖狀態;

(3) 當線程A再次試圖來得到鎖時,JVM發現同步鎖對象的標誌位是01,是否偏向鎖是1,也就是偏向狀態,Mark Word中記錄的線程id就是線程A本身的id,表示線程A已經得到了這個偏向鎖,能夠執行同步中的代碼;

(4) 當線程B試圖得到這個鎖時,JVM發現同步鎖處於偏向狀態,可是Mark Word中的線程id記錄的不是B,那麼線程B會先用CAS操做試圖得到鎖,這裏的得到鎖操做是有可能成功的,由於線程A通常不會自動釋放偏向鎖。若是搶鎖成功,就把Mark Word裏的線程id改成線程B的id,表明線程B得到了這個偏向鎖,能夠執行同步代碼。若是搶鎖失敗,則繼續執行步驟5;

(5) 偏向鎖狀態搶鎖失敗,表明當前鎖有必定的競爭,偏向鎖將升級爲輕量級鎖。JVM會在當前線程的線程棧中開闢一塊單獨的空間,裏面保存指向對象鎖Mark Word的指針,同時在對象鎖Mark Word中保存指向這片空間的指針。上述兩個保存操做都是CAS操做,若是保存成功,表明線程搶到了同步鎖,就把Mark Word中的鎖標誌位改爲00,能夠執行同步代碼。若是保存失敗,表示搶鎖失敗,競爭太激烈,繼續執行步驟6;

(6) 輕量級鎖搶鎖失敗,JVM會使用自旋鎖,自旋鎖不是一個鎖狀態,只是表明不斷的重試,嘗試搶鎖。從JDK1.7開始,自旋鎖默認啓用,自旋次數由JVM決定。若是搶鎖成功則執行同步代碼,若是失敗則繼續執行步驟7;

(7) 自旋鎖重試以後若是搶鎖依然失敗,同步鎖會升級至重量級鎖,鎖標誌位改成10。在這個狀態下,未搶到鎖的線程都會被阻塞。

6、總結

synchronized關鍵字的使用能夠說很是簡單,可是想要徹底搞懂synchronized實際上並無那麼容易。由於它涉及到不少虛擬機底層的知識。同時,還要了解JDK1.6中對synchronized針對性的優化,其中牽扯到的東西又不少。好比,本篇文章並無講解什麼是CAS,若是你不懂CAS,就很難理解鎖升級的過程。須要不懂讀者自行去查閱相關資料。本篇文章對於synchronized的講解相對來講仍是很全面的。但願你看完能有所收穫。

參考&推薦閱讀

深刻理解Java中synchronized關鍵字的實現原理

synchronized底層monitor原理

盤一盤 synchronized (一)—— 從打印Java對象頭提及

相關文章
相關標籤/搜索