1. 使用方法 synchronized 是 java 中最常用的保證線程安全的方式,synchronized 的作用主要有三方面: 語義上來講,synchronized主要有三種用法: 2. 實現原理 2.1. 監視器鎖 synchronized 同步代碼塊的語義底層是基於對象內部的監視器鎖(mo ...
1. 使用方法
synchronized 是 java 中最常用的保證線程安全的方式,synchronized 的作用主要有三方面:
- 確保線程互斥的訪問代碼塊,同一時刻只有一個方法可以進入到臨界區
- 保證共用變數的修改能及時可見
- 有效解決重排序問題
語義上來講,synchronized主要有三種用法:
- 修飾普通方法,鎖的是當前對象實例(this)
- 修飾靜態方法,鎖的是當前 Class 對象(靜態方法是屬於類,而不是對象)
- 修飾代碼塊,鎖的是括弧里的對象
2. 實現原理
2.1. 監視器鎖
synchronized 同步代碼塊的語義底層是基於對象內部的監視器鎖(monitor),分別是使用 monitorenter 和 monitorexit 指令完成。其實 wait/notify 也依賴於 monitor 對象,所以其一般要在 synchronized 同步的方法或代碼塊內使用。monitorenter 指令在編譯為位元組碼後插入到同步代碼塊的開始位置,monitorexit 指令在編譯為位元組碼後插入到方法結束處和異常處。JVM 要保證每個 monitorenter 必須有對應的 moniorexit。
monitorenter:每個對象都有一個監視器鎖(monitor),當 monitor 被某個線程占用時就會處於鎖定狀態,線程執行 monitorenter 指令時嘗試獲得 monitor 的所有權,即嘗試獲取對象的鎖。過程如下:
- 如果 monitor 的進入數為0,則該線程進入 monitor,然後將進入數設置為1,該線程即為 monitor 的所有者;
- 如果線程已經占有monitor,只是重新進入,則monitor的進入數+1;
- 如果其他線程已經占用 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 對象的線程。
- 當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList,等待鎖處於阻塞狀態。
- 當線程獲取到對象的 monitor 後進入 The Owner 區域,並把 ObjectMonitor 中的 _owner 變數設置為當前線程,同時 monitor 中的計數器 count 加1。
- 若線程調用 wait() 方法,將釋放當前持有的 monitor,_owner 變數恢復為 null,count 減1,同時該線程進入 _WaitSet 集合中等待被喚醒,處於 waiting 狀態。
- 若當前線程執行完畢,將釋放 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() 後只需要加一次鎖就可以了。
4. 參考
《深入理解Java虛擬機》- 周志明
Java Synchronised機制
Java synchronized 關鍵字的實現原理
---
我的博客即將搬運同步至騰訊雲+社區,邀請大家一同入駐:https://cloud.tencent.com/developer/support-plan?invite_code=12mihsfip6v9b