Java併發編程:synchronized和鎖優化

1. 使用方法

synchronized 是 java 中最經常使用的保證線程安全的方式,synchronized 的做用主要有三方面:html

  1. 確保線程互斥的訪問代碼塊,同一時刻只有一個方法能夠進入到臨界區
  2. 保證共享變量的修改能及時可見
  3. 有效解決重排序問題

語義上來說,synchronized主要有三種用法:java

  1. 修飾普通方法,鎖的是當前對象實例(this)
  2. 修飾靜態方法,鎖的是當前 Class 對象(靜態方法是屬於類,而不是對象)
  3. 修飾代碼塊,鎖的是括號裏的對象

 

2. 實現原理

2.1. 監視器鎖

synchronized 同步代碼塊的語義底層是基於對象內部的監視器鎖(monitor),分別是使用 monitorenter 和 monitorexit 指令完成。其實 wait/notify 也依賴於 monitor 對象,因此其通常要在 synchronized 同步的方法或代碼塊內使用。monitorenter 指令在編譯爲字節碼後插入到同步代碼塊的開始位置,monitorexit 指令在編譯爲字節碼後插入到方法結束處和異常處。JVM 要保證每一個 monitorenter 必須有對應的 moniorexit。數組

monitorenter:每一個對象都有一個監視器鎖(monitor),當 monitor 被某個線程佔用時就會處於鎖定狀態,線程執行 monitorenter 指令時嘗試得到 monitor 的全部權,即嘗試獲取對象的鎖。過程以下:安全

  1. 若是 monitor 的進入數爲0,則該線程進入 monitor,而後將進入數設置爲1,該線程即爲 monitor 的全部者;
  2. 若是線程已經佔有monitor,只是從新進入,則monitor的進入數+1;
  3. 若是其餘線程已經佔用 monitor,則該線程處於阻塞狀態,直至 monitor 的進入數爲0,再從新嘗試得到 monitor 的全部權

monitorexit:執行 monitorexit 的線程必須是 objectref 所對應的 monitor 的全部者。執行指令時,monitor 的進入數減1,若是減1後進入數爲0,則線程退出 monitor,再也不是這個 monitor 的全部者,其餘被這個 monitor 阻塞的線程能夠嘗試獲取這個 monitor 的全部權。數據結構

 

2.2. 線程狀態和狀態轉化

在 HotSpot JVM 中,monitor 由 ObjectMonitor 實現,其主要數據結構以下:多線程

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;      //記錄個數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;   //持有monitor的線程
    _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 對象的線程。併發

  1. 當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList,等待鎖處於阻塞狀態。
  2. 當線程獲取到對象的 monitor 後進入 The Owner 區域,並把 ObjectMonitor 中的 _owner 變量設置爲當前線程,同時 monitor 中的計數器 count 加1。
  3. 若線程調用 wait() 方法,將釋放當前持有的 monitor,_owner 變量恢復爲 null,count 減1,同時該線程進入 _WaitSet 集合中等待被喚醒,處於 waiting 狀態。
  4. 若當前線程執行完畢,將釋放 monitor 並復位變量的值,以便其餘線程進入獲取 monitor。

過程以下圖所示: app

 

 

3. 鎖優化

在 JDK1.6 以後,出現了各類鎖優化技術,如輕量級鎖、偏向鎖、適應性自旋、鎖粗化、鎖消除等,這些技術都是爲了在線程間更高效的解決競爭問題,從而提高程序的執行效率。ide

經過引入輕量級鎖和偏向鎖來減小重量級鎖的使用。鎖的狀態總共分四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。鎖隨着競爭狀況能夠升級,但鎖升級後不能降級,意味着不能從輕量級鎖狀態降級爲偏向鎖狀態,也不能從重量級鎖狀態降級爲輕量級鎖狀態。性能

無鎖狀態 → 偏向鎖狀態 → 輕量級鎖 → 重量級鎖

3.1. 對象頭

要理解輕量級鎖和偏向鎖的運行機制,還要從瞭解對象頭(Object Header)開始。對象頭分爲兩部分:

一、Mark Word:存儲對象自身的運行時數據,如:Hash Code,GC 分代年齡、鎖信息。這部分數據在32位和64位的 JVM 中分別爲 32bit 和 64bit。考慮空間效率,Mark Word 被設計爲非固定的數據結構,以便在極小的空間內存儲儘可能多的信息,32bit的 Mark Word 以下圖所示: 

二、存儲指向方法區對象類型數據的指針,若是是數組對象的話,額外會存儲數組的長度

 

3.2. 重量級鎖

monitor 監視器鎖本質上是依賴操做系統的 Mutex Lock 互斥量 來實現的,咱們通常稱之爲重量級鎖。由於 OS 實現線程間的切換須要從用戶態轉換到核心態,這個轉換過程成本較高,耗時相對較長,所以 synchronized 效率會比較低。

重量級鎖的鎖標誌位爲'10',指針指向的是 monitor 對象的起始地址,關於 monitor 的實現原理上文已經描述了。

 

3.3. 輕量級鎖

輕量級鎖是相對基於OS的互斥量實現的重量級鎖而言的,它的本意是在沒有多線程競爭的前提下,減小傳統的重量級鎖使用OS的互斥量而帶來的性能消耗。

輕量級鎖提高性能的經驗依據是:對於絕大部分鎖,在整個同步週期內都是不存在競爭的。若是沒有競爭,輕量級鎖就可使用 CAS 操做避免互斥量的開銷,從而提高效率。

輕量級鎖的加鎖過程: 

一、線程在進入到同步代碼塊的時候,JVM 會先在當前線程的棧幀中創建一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象當前 Mark Word 的拷貝(官方稱爲 Displaced Mark Word),owner 指針指向對象的 Mark Word。此時堆棧與對象頭的狀態如圖所示: 

二、JVM 使用 CAS 操做嘗試將對象頭中的 Mark Word 更新爲指向 Lock Record 的指針。若是更新成功,則執行步驟3;更新失敗,則執行步驟4

三、若是更新成功,那麼這個線程就擁有了該對象的鎖,對象的 Mark Word 的鎖狀態爲輕量級鎖(標誌位轉變爲'00')。此時線程堆棧與對象頭的狀態如圖所示: 

四、若是更新失敗,JVM 首先檢查對象的 Mark Word 是否指向當前線程的棧幀

  • 若是是,就說明當前線程已經擁有了該對象的鎖,那就能夠直接進入同步代碼塊繼續執行
  • 若是不是,就說明這個鎖對象已經被其餘的線程搶佔了,當前線程會嘗試自旋必定次數來獲取鎖。若是自旋必定次數 CAS 操做仍沒有成功,那麼輕量級鎖就要升級爲重量級鎖(鎖的標誌位轉變爲'10'),Mark Word 中存儲的就是指向重量級鎖的指針,後面等待鎖的線程也就進入阻塞狀態

輕量級鎖的解鎖過程: 

一、經過 CAS 操做用線程中複製的 Displaced Mark Word 中的數據替換對象當前的 Mark Word 

二、若是替換成功,整個同步過程就完成了 

三、若是替換失敗,說明有其餘線程嘗試過獲取該鎖,那就在釋放鎖的同時,喚醒被掛起的線程

 

3.4. 偏向鎖

輕量級鎖是在無多線程競爭的狀況下,使用 CAS 操做去消除互斥量;偏向鎖是在無多線程競爭的狀況下,將這個同步都消除掉。

偏向鎖提高性能的經驗依據是:對於絕大部分鎖,在整個同步週期內不只不存在競爭,並且總由同一線程屢次得到。偏向鎖會偏向第一個得到它的線程,若是接下來的執行過程當中,該鎖沒有被其餘線程獲取,則持有偏向鎖的線程不須要再進行同步。這使得線程獲取鎖的代價更低。

偏向鎖的獲取過程: 

一、線程執行同步塊,鎖對象第一次被獲取的時候,JVM 會將鎖對象的 Mark Word 中的鎖狀態設置爲偏向鎖(鎖標誌位爲'01',是否偏向的標誌位爲'1'),同時經過 CAS 操做在 Mark Word 中記錄獲取到這個鎖的線程的 ThreadID

二、若是 CAS 操做成功。持有偏向鎖的線程每次進入和退出同步塊時,只需測試一下 Mark Word 裏是否存儲着當前線程的 ThreadID。若是是,則表示線程已經得到了鎖,而不須要額外花費 CAS 操做加鎖和解鎖

三、若是不是,則經過CAS操做競爭鎖,競爭成功,則將 Mark Word 的 ThreadID 替換爲當前線程的 ThreadID

偏向鎖的釋放過程: 

一、當一個線程已經持有偏向鎖,而另一個線程嘗試競爭偏向鎖時,CAS 替換 ThreadID 操做失敗,則開始撤銷偏向鎖。偏向鎖的撤銷,須要等待原持有偏向鎖的線程到達全局安全點(在這個時間點上沒有字節碼正在執行),暫停該線程,並檢查其狀態

二、若是原持有偏向鎖的線程不處於活動狀態或已退出同步代碼塊,則該線程釋放鎖。將對象頭設置爲無鎖狀態(鎖標誌位爲'01',是否偏向標誌位爲'0')

三、若是原持有偏向鎖的線程未退出同步代碼塊,則升級爲輕量級鎖(鎖標誌位爲'00')

 

3.5. 總結

偏向鎖、輕量級鎖、重量級鎖之間的狀態轉換如圖所示(歸納上文描述的鎖獲取和釋放的內容): 

下面是這幾種鎖的比較:

 

3.6. 其餘優化

一、適應性自旋 

自旋鎖:互斥同步時,掛起和恢復線程都須要切換到內核態完成,這對性能併發帶來了很多的壓力。同時在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段較短的時間而去掛起和恢復線程並不值得。那麼若是有多個線程同時並行執行,可讓後面請求鎖的線程經過自旋(CPU忙循環執行空指令)的方式稍等一下子,看看持有鎖的線程是否會很快的釋放鎖,這樣就不須要放棄 CPU 的執行時間了

適應性自旋:在輕量級鎖獲取過程當中,線程執行 CAS 操做失敗時,須要經過自旋來獲取重量級鎖。若是鎖被佔用的時間比較短,那麼自旋等待的效果就會比較好,而若是鎖佔用的時間很長,自旋的線程則會白白浪費 CPU 資源。解決這個問題的最簡答的辦法就是:指定自旋的次數,若是在限定次數內還沒獲取到鎖(例如10次),就按傳統的方式掛起線程進入阻塞狀態。JDK1.6 以後引入了自適應性自旋的方式,若是在同一鎖對象上,一線程自旋等待剛剛成功得到鎖,而且持有鎖的線程正在運行中,那麼 JVM 會認爲此次自旋也有可能再次成功得到鎖,進而容許自旋等待相對更長的時間(例如100次)。另外一方面,若是某個鎖自旋不多成功得到,那麼之後要得到這個鎖時將省略自旋過程,以免浪費 CPU。

二、鎖消除 

鎖消除就是編譯器運行時,對一些被檢測到不可能存在共享數據競爭的鎖進行消除。若是判斷一段代碼中,堆上的數據不會逃逸出去從而被其餘線程訪問到,則能夠把他們當作棧上的數據對待,認爲它們是線程私有的,沒必要要加鎖。

public String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append("a");
    sb.append("b");
    sb.append("c");
    return sb.toString();
}

 

在 StringBuffer.append() 方法中有一個同步代碼塊,鎖就是sb對象,但 sb 的全部引用不會逃逸到 concatString() 方法外部,其餘線程沒法訪問它。所以這裏有鎖,可是在即時編譯以後,會被安全的消除掉,忽略掉同步而直接執行了。

三、鎖粗化 

鎖粗化就是 JVM 檢測到一串零碎的操做都對同一個對象加鎖,則會把加鎖同步的範圍粗化到整個操做序列的外部。以上述 concatString() 方法爲例,內部的 StringBuffer.append() 每次都會加鎖,將會鎖粗化,在第一次 append() 前至 最後一個 append() 後只須要加一次鎖就能夠了。

4. 參考

《深刻理解Java虛擬機》- 周志明 
Java Synchronised機制 
Java synchronized 關鍵字的實現原理

---

個人博客即將搬運同步至騰訊雲+社區,邀請你們一同入駐:https://cloud.tencent.com/developer/support-plan?invite_code=12mihsfip6v9b

相關文章
相關標籤/搜索