一夜搞懂 | JVM 執行緒安全與鎖優化
前言
本文已經收錄到我的 Github 個人部落格,歡迎大佬們光臨寒舍:
我的 GIthub 部落格
學習導圖
一.為什麼要學習記憶體模型與執行緒?
之前我們學習了記憶體模型和執行緒,瞭解了
JMM
和執行緒,初步探究了JVM
怎麼實現併發,而本篇文章,我們的關注點是JVM
如何實現高效
併發程式設計的目的是為了讓程式執行得更快,提高程式的響應速度,雖然我們希望通過多執行緒執行任務讓程式執行得更快,但是同時也會面臨非常多的挑戰,比如像執行緒安全問題、執行緒上下文切換的問題、硬體和軟體資源限制等問題,這些都是併發程式設計給我們帶來的難題。
其中執行緒安全問題是我們最關心的問題之一,我們接下來主要就圍繞著執行緒安全的問題來展開。
二.核心知識點歸納
2.1 執行緒安全
2.1.1 定義
當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件是執行緒安全的
要求執行緒安全的程式碼都必須具備一個特徵:
程式碼本身封裝了所有必要的正確性保障手段(如互斥同步等),令呼叫者無須關心多執行緒的問題,更無須自己採取任何措施來保證多執行緒的正確呼叫。
2.1.2 分類
下面將按照執行緒安全的程度由強至弱分成五類
- 不可變:外部的可見狀態永遠不會改變,在多個執行緒之中永遠是一致的狀態
一定是執行緒安全的
如何實現:
1.如果共享資料是一個基本資料型別,只要在定義時用
final
關鍵字修飾2.如果共享資料是一個物件,最簡單的方法是把物件中帶有狀態的變數都宣告為
final
(例如String
類的實現)
- 絕對執行緒安全:完全滿足之前給出的執行緒安全的定義,即達到『不管執行時環境如何,呼叫者都不需要任何額外的同步措施』
- 相對執行緒安全:能保證對該物件單獨的操作是執行緒安全的,在呼叫時無需做額外保障措施,但對於一些特定順序的連續呼叫,可能需要在呼叫端使用額外的同步措施來保證呼叫的正確性
- 是通常意義上所講的執行緒安全
- 大部分的執行緒安全類都屬於這種型別,如
Vector
、HashTable
、Collections#synchronizedCollection()
包裝的集合等
- 執行緒相容:物件本身非執行緒安全的,但可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用
- 是通常意義上所講的非執行緒安全
Java API
中大部分類都是屬於執行緒相容的,如ArrayList
和HashMap
等
- 執行緒對立:無論呼叫端是否採取了同步措施,都無法在多執行緒環境中併發使用的程式碼
例子:
Thread
類的suspend()
和resume()
,一個嘗試中斷執行緒,一個嘗試恢復執行緒,在併發條件下,有可能會造成死鎖
2.1.3 實現
可分成兩大手段:
- 通過程式碼編寫實現執行緒安全
- 通過虛擬機器本身實現同步與鎖
本篇重點在虛擬機器本身
1.互斥同步
- 含義:
同步:在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只被一個執行緒使用
互斥:是實現同步的一種手段,臨界區、互斥量和訊號量都是主要的互斥實現方式
兩者關係:互斥是因,同步是果;互斥是方法,同步是目的
-
屬於悲觀併發策略(悲觀鎖),即認為只要不做正確的同步措施就肯定會出現問題,因此無論共享資料是否真的會出現競爭,都要加鎖
-
最大的問題是進行執行緒阻塞和喚醒所帶來的效能問題,也稱為阻塞同步
-
使用方式:
A.使用
synchronized
關鍵字:
原理:編譯後會在同步塊的前後分別形成
monitorenter
和monitorexit
這兩個位元組碼指令,並通過一個reference
型別的引數來指明要鎖定和解鎖的物件注意:
1.若明確指定了物件引數,則取該物件的
reference
2.否則,會根據
synchronized
修飾的是例項方法還是類方法去取對應的物件例項或Class
物件來作為鎖物件
過程:執行
monitorenter
指令時先要嘗試獲取物件的鎖。若該物件沒被鎖定或者已被當前執行緒獲取,那麼鎖計數器+ 1
;而在執行monitorexit
指令時,鎖計數器- 1
;當鎖計數器 =0
時,鎖就被釋放;若獲取物件鎖失敗,那當前執行緒會一直被阻塞等待,直到物件鎖被另外一個執行緒釋放為止特別注意:
1.
synchronized
同步塊對同一條執行緒來說是可重入的,不會出現自我鎖死的問題2.同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入
B.使用重入鎖 ReentrantLock
:
之前在 進階之路 | 奇妙的 Thread 之旅中也提到過重入鎖的使用,相信看過的讀者還有一些印象
與
synchronized
的相同:用法與synchronized
很相似,且都可重入與
synchronized
的不同:1.等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情
2.公平鎖:多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。而
synchronized
是非公平的,即在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。ReentrantLock
預設情況下也是非公平的,但可以通過帶布林值的建構函式改用公平鎖3.鎖繫結多個條件:一個
ReentrantLock
物件可以通過多次呼叫newCondition()
同時繫結多個Condition
物件。而在synchronized
中,鎖物件的wait()
和notify()
或notifyAl()
只能實現一個隱含的條件,若要和多於一個的條件關聯不得不額外地新增一個鎖
- 選擇:在
synchronized
能實現需求的情況下,優先考慮使用它來進行同步。理由如下:
synchronized
是Java
語法層面的同步,足夠清晰簡單Lock
必須由程式設計師確保在finally
塊中釋放鎖,而synchronized
可以由JVM
確保鎖的自動釋放
2.非阻塞同步
- 定義:基於衝突檢測的樂觀併發策略(樂觀鎖),即先進行操作,若無其他執行緒爭用共享資料,操作成功;反之產生了衝突再去採取其他的補償措施
- 為了保證操作和衝突檢測這兩步具備原子性,需要用到硬體指令集,比如:
- 測試並設定
- 獲取並增加
- 交換
- 比較並交換(
CAS
)- 載入連結 / 條件儲存
3.無同步方案
- 定義:不用同步的方式保證執行緒安全,因為有些程式碼天生就是執行緒安全的。
- 例子:
A.可重入程式碼/ 純程式碼
- 含義:可在程式碼執行的任何時刻中斷它去執行另外一段程式碼,當控制權返回後原來的程式並不會出現任何錯誤
- 共同特徵:不依賴儲存在堆上的資料和公用的系統資源、用到的狀態量都由引數中傳入、不呼叫非可重入的方法
- 判定依據:如果一個方法,它的返回結果是可預測的,只要輸入相同的資料就都能返回相同的結果,就滿足可重入性
- 注意:滿足可重入性的程式碼一定是執行緒安全的,反之,滿足執行緒安全的程式碼不一定是可重入的
B.執行緒本地儲存
- 含義:把共享資料的可見範圍限制在同一個執行緒之內,無須同步就能保證執行緒之間不出現資料爭用的問題
- 想詳細瞭解
ThreadLocal
的讀者,可以看下筆者之前寫的一篇文章:進階之路 | 奇妙的 Handler 之旅
2.2 鎖優化
解決併發的正確性之後,為了能線上程之間更『高效』地共享資料、解決競爭問題、提高程式的執行效率,下面介紹五種鎖優化技術
2.2.1 適應性自旋
- 背景:互斥同步在實現阻塞和喚醒時需要掛起執行緒和恢復執行緒的操作,都需要轉入核心態中完成,很影響系統的併發效能;同時,在許多應用上共享資料的鎖定狀態只是暫時,沒必要去掛起和恢復執行緒
- 自旋鎖:當物理機器有多個處理器使得多個執行緒同時並行執行時,先讓後請求鎖的執行緒等待,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖,這時只需讓執行緒執行一個忙迴圈,即自旋
注意:自旋等待不能代替阻塞,它雖然能避免執行緒切換的開銷,但會佔用處理器時間,因此自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數(預設
10
次)仍未成功獲鎖,就需要掛執行緒了
- 自適應自旋鎖:自旋的時間不再固定,而是由該鎖上的上次自旋時間及鎖的擁有者的狀態共同決定。具體表現是:
- 如果對於某個鎖,自旋等待剛剛成功獲得,且持有鎖的執行緒正在執行中,那麼虛擬機器很可能允許自旋等待的時間更久點
- 如果對於某個鎖,自旋很少成功獲得過,那麼很可能以後將省略自旋等待這個鎖,避免浪費處理器資源
2.2.2 鎖消除
- 定義:指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除
- 判定依據:如果一段程式碼中堆上的所有資料都不會逃逸出去被其他執行緒訪問到,可把它們當做棧上資料對待,即執行緒私有的,無須同步加鎖
2.2.3 鎖粗化
- 一般情況下,會將同步塊的作用範圍限制到只在共享資料的實際作用域中才進行同步,使得需要同步的運算元量儘可能變小,保證就算存在鎖競爭,等待鎖的執行緒也能儘快拿到鎖
- 但如果反覆操作對同一個物件進行加鎖和解鎖,即使沒有執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗,此時,虛擬機器將會把加鎖同步的範圍粗化到整個操作序列的外部,這樣只需加一次鎖
2.2.4 輕量級鎖
- 目的:在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗,注意不是用來代替重量級鎖的
首先先理解
HotSpot
虛擬機器的物件頭的記憶體佈局:分為兩部分
- 第一部分用於儲存物件自身的執行時資料,這部分被稱為
Mark Word
,是實現輕量級鎖和偏向鎖的關鍵。如雜湊碼、GC
分代年齡等- 另外一部分用於儲存指向方法區物件型別資料的指標,如果是陣列物件還會有一個額外的部分用於儲存陣列長度
-
加鎖過程:
1.程式碼進入同步塊時,如果同步物件未被鎖定(鎖標誌位為
01
),虛擬機器會在當前執行緒的棧幀中建立一個名為Lock Record
的空間,用於儲存鎖物件Mark Word
的拷貝。如下圖
2.之後虛擬機器會嘗試用 CAS
操作將物件的 Mark Word
更新為指向 Lock Record
的指標。若更新動作成功,那麼當前執行緒就擁有了該物件的鎖,且物件 Mark Word
的鎖標誌位變為 00
,即處於輕量級鎖定狀態;反之,虛擬機器會先檢查物件的 Mark Word
是否指向當前執行緒的棧幀,若是,則當前執行緒已有該物件的鎖,可直接進入同步塊繼續執行,否則說明改物件已被其他執行緒搶佔。如下圖:
另外,如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標誌位變為
10
,Mark Word
中儲存的就是指向重量級鎖的指標,後面等待鎖的執行緒也要進入阻塞狀態
-
解鎖過程:若物件的
Mark Word
仍指向著執行緒的Lock Record
,就用CAS
操作把物件當前的Mark Word
和執行緒中複製的Displaced Mark Word
替換回來。若替換成功,那麼就完成了整個同步過程;反之,說明有其他執行緒嘗試獲取該鎖,那麼就要在釋放鎖的同時喚醒被掛起的執行緒 -
優點:因為對於絕大部分的鎖,在整個同步週期內都是不存在競爭的,所以輕量級鎖通過使用
CAS
操作消除同步使用的互斥量 -
自旋鎖和輕量級鎖的關係:
- 自旋鎖是為了減少執行緒掛起次數
- 輕量級鎖是在加鎖的時候,如何使用一種更高效的方式來加鎖
Q:處於輕量級鎖狀態時,會不會使用自旋鎖這個競爭機制
A:執行緒首先會通過
CAS
獲取鎖,失敗後通過自旋鎖來嘗試獲取鎖,再失敗鎖就膨脹為重量級鎖。所以輕量級鎖狀態下可能會有自旋鎖的參與(CAS
將物件頭的標記指向鎖記錄指標失敗的時候)
2.2.5 偏向鎖
- 目的:消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能
如果說輕量級鎖是在無競爭的情況下使用
CAS
去消除同步使用的互斥量那偏向鎖就是在無競爭情況下把整個同步都消除掉
-
含義:偏向鎖會偏向於第一個獲得它的執行緒,如果在後面的執行中該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步
-
加鎖過程:啟用偏向鎖的鎖物件在第一次被執行緒獲取時,
Mark Word
的鎖標誌位會被設定為01
,即偏向模式,同時使用CAS
操作把獲取到這個鎖的執行緒ID
記錄在物件的Mark Word
中。若操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時都可不再進行任何同步操作 -
解鎖過程:當有另外的執行緒去嘗試獲取這個鎖時,根據鎖物件目前是否處於被鎖定的狀態,撤銷偏向後恢復到未鎖定
01
或輕量級鎖定00
的狀態,後續的同步操作就如輕量級鎖執行過程。如下圖: -
優點:可提高帶有同步但無競爭的程式效能,但若程式中大多數鎖總被多個執行緒訪問,此模式就沒必要了
三.碎碎念
能夠寫出高效能、高伸縮性的併發程式是一門藝術,而瞭解併發在底層是如何實現的,則是掌握這門藝術的前提,也是成長為高階程式設計師的必備知識!
加油吧!騷年!以夢為馬,不負韶華!
如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力
本文參考連結:
- 《深入理解Java虛擬機器》第3版
- 一、聊聊併發—執行緒安全到底在說什麼
- 要點提煉 | 理解 JVM 之執行緒安全 & 鎖優化
- 自旋鎖跟輕量級鎖的關係是什麼?
- 一圖帶你看懂Java各種鎖,建議儲存!