1. 程式人生 > >Java並發編程:synchronized和鎖優化

Java並發編程:synchronized和鎖優化

section ext 隨著 32bit 就是 -i 序列 UC 進行

每天學習一點點 編程PDF電子書、視頻教程免費下載:
http://www.shitanlife.com/code

1. 使用方法

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

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

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

  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。

過程如下圖所示:

技術分享圖片

3. 鎖優化

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

通過引入輕量級鎖和偏向鎖來減少重量級鎖的使用。鎖的狀態總共分四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。鎖隨著競爭情況可以升級,但鎖升級後不能降級,意味著不能從輕量級鎖狀態降級為偏向鎖狀態,也不能從重量級鎖狀態降級為輕量級鎖狀態。

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

3.1. 對象頭

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

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

技術分享圖片

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

3.2. 重量級鎖

monitor 監視器鎖本質上是依賴操作系統的 Mutex Lock 互斥量 來實現的,我們一般稱之為重量級鎖。因為 OS 實現線程間的切換需要從用戶態轉換到核心態,這個轉換過程成本較高,耗時相對較長,因此 synchronized 效率會比較低。

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

3.3. 輕量級鎖

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

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

輕量級鎖的加鎖過程:

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

技術分享圖片

2、JVM 使用 CAS 操作嘗試將對象頭中的 Mark Word 更新為指向 Lock Record 的指針。如果更新成功,則執行步驟3;更新失敗,則執行步驟4

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

技術分享圖片

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

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

輕量級鎖的解鎖過程:

1、通過 CAS 操作用線程中復制的 Displaced Mark Word 中的數據替換對象當前的 Mark Word

2、如果替換成功,整個同步過程就完成了

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

3.4. 偏向鎖

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

偏向鎖提升性能的經驗依據是:對於絕大部分鎖,在整個同步周期內不僅不存在競爭,而且總由同一線程多次獲得。偏向鎖會偏向第一個獲得它的線程,如果接下來的執行過程中,該鎖沒有被其他線程獲取,則持有偏向鎖的線程不需要再進行同步。這使得線程獲取鎖的代價更低。

偏向鎖的獲取過程:

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

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

3、如果不是,則通過CAS操作競爭鎖,競爭成功,則將 Mark Word 的 ThreadID 替換為當前線程的 ThreadID

偏向鎖的釋放過程:

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

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

3、如果原持有偏向鎖的線程未退出同步代碼塊,則升級為輕量級鎖(鎖標誌位為‘00‘)

3.5. 總結

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

技術分享圖片

下面是這幾種鎖的比較:

技術分享圖片

3.6. 其他優化

1、適應性自旋

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

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

2、鎖消除

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

技術分享圖片
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() 方法外部,其他線程無法訪問它。因此這裏有鎖,但是在即時編譯之後,會被安全的消除掉,忽略掉同步而直接執行了。

3、鎖粗化

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

每天學習一點點 編程PDF電子書、視頻教程免費下載:
http://www.shitanlife.com/code

Java並發編程:synchronized和鎖優化