執行緒安全與鎖最佳化

strind發表於2024-10-07

執行緒安全與鎖最佳化

1. 執行緒安全

什麼是執行緒安全:

《 Java 併發程式設計實戰(Java Concurrency In Practice)》的作者 Brian Goetz為“執行緒安全”做出了一個比較恰當的定義:“當多個執行緒同時訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那就稱這個物件是執行緒安全的。”

為了更深人地理解執行緒安全,在這裡我們可以不把執行緒安全當作一個非真即假的二元排他選項來看待,而是按照執行緒安全的“安全程度”由強至弱來排序,我們可以將Java語言中各種操作共享的資料分為以下五類:不可變、絕對執行緒安全、相對執行緒安全、執行緒相容和執行緒對立。

1.1 不可變

在Java語言裡面(特指JDK5以後,即Java記憶體模型被修正之後的Java語言),不可變的物件一定是執行緒安全的,無論是物件的方法實現還是方法的呼叫者,都不需要再進行任何執行緒安全保障措施。在第10章裡我們講解“final關鍵字帶來的可見性時曾經提到過這一點:只要一個不可變的物件被正確地構建出來(即沒有發生this引用逃逸的情況),那其外部的可見狀態永遠都不會改變,永遠都不會看到它在多個執行緒之中處於不一致的狀態。“不可變”帶來的安全性是最直接、最純粹的。

Java語言中,如果多執行緒共享的資料是一個基本資料型別,那麼只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。如果共享資料是一個物件,由於Java語言目前暫時還沒有提供值型別的支援,那就需要物件自行保證其行為不會對其狀態產生任何影響才行。

例如:java.lang.String類的物件例項,它是一個典型的不可變物件,使用者呼叫它的substring()、replace()和concat()這些方法都不會影響它原來的值,只會返回一個新構造的字串物件。

保證物件行為不影響自己狀態的途徑有很多種,最簡單的一種就是把物件裡面帶有狀態的變數都宣告為final,這樣在建構函式結束之後,它就是不可變的,例如 java.lang.Integer建構函式,它透過將內部狀態變數value定義為final來保障狀態不變。

1.2 絕對執行緒安全

絕對的執行緒安全能夠完全滿足Brian Goetz給出的執行緒安全的定義,這個定義其實是很嚴格的,一個類要達到“不管執行時環境如何,呼叫者都不需要任何額外的同步措施”可能需要付出非常高昂的,甚至不切實際的代價。在JavaAPI中標註自己是執行緒安全的類大多數都不是絕對的執行緒安全。

1.3 相對執行緒安全

相對執行緒安全就是我們通常意義上所講的執行緒安全,它需要保證對這個物件單次的操作是執行緒安全的,我們在呼叫的時候不需要進行額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。

在Java語言中,大部分聲稱執行緒安全的類都屬於這種型別,例如Vector、HashTable、Collections 的 synchronizedCollection()方法包裝的集合等。

1.4 執行緒相容

執行緒相容是指物件本身並不是執行緒安全的,但是可以透過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用。我們平常說一個類不是執行緒安全的,通常就是指這種情況。Java類庫API中大部分的類都是執行緒相容的,如集合類ArrayList和HashMap等。

1.5執行緒對立

執行緒對立是指不管呼叫端是否採取了同步措施,都無法在多執行緒環境中併發使用程式碼。由於Java語言天生就支援多執行緒的特性,執行緒對立這種排斥多執行緒的程式碼是很少出現的,而且通常都是有害的,應當儘量避免。

2. 執行緒安全的實現方法

在這裡,如何編寫程式碼實現執行緒安全,以及虛擬機器如何實現同步與鎖這兩方面都會涉及,相對而言更偏重後者一些,只要明白了Java虛擬機器執行緒安全措施的原理與運作過程,自己再去思考程式碼如何編寫就不是一件困難的事情了。

2.1 互斥同步

同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只被一條(或者是一些,當使用訊號量的時候)執行緒使用。

互斥是實現同步的一種手段,臨界區、互斥量和訊號量都是常見的互斥實現方式。

因此在“互斥同步”這四個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。

在Java裡面,最基本的互斥同步手段就是synchronized關鍵字,這是一種塊結構的同步語法。synchronized關鍵字經過Javac編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit 這兩個位元組碼指令。這兩個位元組碼指令都需要一個reference型別的引數來指明要鎖定和解鎖的物件。如果Java原始碼中的synchronized明確指定了物件引數,那就以這個物件的引用作為reference;如果沒有明確指定,那將根據synchronized修飾的方法型別(如例項方法或類方法),來決定是取程式碼所在的物件例項還是取型別對應的Class物件來作為執行緒要持有的鎖。

根據《Java 虛擬機器規範》的要求,在執行monitorenter指令時,首先要去嘗試獲取物件的鎖。如果這個物件沒被鎖定,或者當前執行緒已經持有了那個物件的鎖,就把鎖的計數器的值增加一,而在執行 monitorexit 指令時會將鎖計數器的值減一。一旦計數器的值為零,鎖隨即就被釋放了。如果獲取物件鎖失敗,那當前執行緒就應當被阻塞等待,直到請求鎖定的物件被持有它的執行緒釋放為止。

注意:被synchronized 修飾的同步塊對同一條執行緒來說是可重人的。這意味著同一執行緒反覆進入同步塊也不會出現自己把自己鎖死的情況。

被 synchronized修飾的同步塊在持有鎖的執行緒執行完畢並釋放鎖之前,會無條件地阻塞後面其他執行緒的進人。這意味著無法強制已取鎖的執行緒釋放鎖,也無法強制正在等待鎖的執行緒中斷等待或超時退出。

從執行成本的角度看,持有鎖是一個重量級(Heavy-Weight)的操作。我們知道,Java的執行緒是對映到作業系統的原生核心執行緒之上的,如果要阻塞或喚醒一條執行緒,則需要作業系統來幫忙完成,這就不可避免地陷人使用者態到核心態的轉換中,進行這種狀態轉換需要耗費很多的處理器時間。尤其是對於程式碼特別簡單的同步塊(譬如被synchronized修飾的getter()或setter()方法),狀態轉換消耗的時間甚至會比使用者程式碼本身執行的時間還要長。

除了synchronized 關鍵字以外,自JDK5起,Java類庫中新提供了java.util.concurrent包,使用者能夠以非塊結構來實現互斥同步,從而擺脫了語言特性的束縛,改為在類庫層面去實現同步,這也為日後擴充套件出不同排程演算法、不同特徵、不同效能、不同語義的各種鎖提供了廣闊的空間。

重人鎖是Lock介面最常見的一種實現,顧名思義,它與synchronized一樣是可重人的。在基本用法上,ReentrantLock也與synchronized很相似,只是程式碼寫法上稍有區別而已。不過,ReentrantLock與synchronized 相比增加了一些高階功能,主要有以下三項:等待可中斷、可實現公平鎖及鎖可以繫結多個條件。

  1. 等待可中斷:是指當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情。可中斷特性對處理執行時間非常長的同步塊很有幫助。
  2. 公平鎖:是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock在預設情況下也是非公平的,但可以透過帶布林值的建構函式要求使用公平鎖。不過一旦使用了公平鎖,將會導致 ReentrantLock的效能急劇下降,會明顯影響吞吐量。
  3. 鎖繫結多個條件:是指一個ReentrantLock物件可以同時繫結多個 Condition 物件,在 synchronized 中,鎖物件的 wait()跟它的notify()或者notifyAll()方法配合可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外新增一個鎖;而ReentrantLock則無須這樣做,多次呼叫newCondition()方法即可。

現在看來,synchronized和ReentrantLock在效能上已經沒有什麼區別了,不同是ReentrantLock提供了一些更高階的功能,在需要使用這些高階功能時,可以考慮使用ReentrantLock。

2.2 非阻塞同步

互斥同步屬於一種悲觀的併發策略,其總是認為只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題,無論共享的資料是否真的會出現競爭,它都會進行加鎖,這將會導致使用者態到核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要被喚醒等開銷。

隨著硬體指令集的發展,我們已經有了另外一個選擇:基於衝突檢測的樂觀併發策略,通俗地說就是不管風險,先進行操作。如果沒有其他執行緒爭用共享資料,那操作就直接成功了;如果共享的資料的確被爭用,產生了衝突,那再進行其他的補償措施,最常用的補償措施是不斷地重試,直到出現沒有競爭的共享資料為止。這種樂觀併發策略的實現不再需要把執行緒阻塞掛起,因此這種同步探作被稱為非阻塞同步,使用這種措施的程式碼也常被稱為無鎖程式設計。

為什麼說使用樂觀併發策略需要“硬體指令集的發展”?因為我們必須要求操作和衝突檢測這兩個步驟具備原子性。靠什麼來保證原子性?如果這裡再使用互斥同步來保證就完全失去意義了,所以我們只能靠硬體來實現這件事情,硬體保證某些從語義上看起來需要多次操作的行為可以只透過一條處理器指令就能完成,這類指令常用的有:

  1. 測試並設定(Test-and-Set);
  2. 獲取並增加(Fetch-and-Increment);
  3. 交換(Swap);
  4. 比較並交換(Compare-and-Swap,下文稱CAS);
  5. 載入連結/條件儲存(Load-Linked/Store-Conditional,下文稱 LL/SC)。

因為Java裡最終暴露出來的是CAS操作,所以我們主要介紹CAS指令。

CAS指令需要有三個運算元,分別是記憶體位置、舊的預期值和準備設定的新值。CAS指令執行時,當且僅當V符合A時,處理器才會用B更新V的值,否則它就不執行更新。但是,不管是否更新了V的值,都會返回V的舊值,上述的處理過程是一個原子操作,執行期間不會被其他執行緒中斷。

在JDK5之後,Java類庫中才開始使用CAS操作,該操作由sun.misc.Unsate類裡面的 compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供。HotSpot 虛擬機器在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器CAS指令,沒有方法呼叫的過程,或者可以認為是無條件內聯進去了。不過由於Unsafe類在設計上就不是提供給使用者程式呼叫的類,因此在JDK9之前只有Java類庫可以使用CAS,譬如J.U.C包裡面的整數原子類,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe類的CAS操作來實現。直到JDK9之後,Java類庫才在VarHandle類裡開放了面向使用者程式使用的CAS 操作。

儘管CAS看起來很美好,既簡單又高效,但顯然這種操作無法涵蓋互斥同步的所有使用場景,並且CAS從語義上來說並不是真正完美的,它存在一個邏輯洞:如果一個變數V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然為A值,那就能說明它的值沒有被其他執行緒改變過了嗎?這是不能的,因為如果在這段期間它的值曾經被改成B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。這個漏洞稱為CAS操作的“ABA 問題”。J.U.C包為了解決這個問題,提供了一個帶有標記的原子引用類AtomicStampedReference,它可以透過控制變數值的版本來保證CAS的正確性。不過目前來說這個類處於相當雞肋的位置,大部分情況下ABA問題不會影響程式併發的正確性、如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更為高效。

2.3 無同步方案

要保證執行緒安全,也並非一定要進行阻塞或非阻塞同步,同步與執行緒安全兩者沒有必然的聯絡。同步只是保障存在共享資料爭用時正確性的手段,如果能讓一個方法本來就不涉及共享資料,那它自然就不需要任何同步措施去保證其正確性,因此會有一些程式碼天生就是執行緒安全的。這裡舉兩個例子。

  1. 可重入程式碼:這種程式碼又稱純程式碼,是指可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼,而在控制權返回後,原來的程式不會出現任何錯誤,也不會對結果有所影響。在特指多執行緒的上下文語境裡(不涉及訊號量等因素9),我們可以認為可重人程式碼是執行緒安全程式碼的一個真子集,這意味著相對執行緒安全來說,可重人性是更為基礎的特性,它可以保證程式碼執行緒安全,即所有可重入的程式碼都是執行緒安全的,但並非所有的執行緒安全的程式碼都是可重人的。

    可重入程式碼有一些共同的特徵,例如,不依賴全域性變數、儲存在堆上的資料和公用的系統資源,用到的狀態量都由引數中傳人,不呼叫非可重入的方法等。

  2. 執行緒本地儲存:如果一段程式碼中所需要的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行。如果能保證,我們就可以把共享資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。

    符合這種特點的應用並不少見,大部分使用消費佇列的架構模式(如“生產者-消費者”模式)都會將產品的消費過程限制在一個執行緒中消費完,其中最重要的一種應用例項就是經典 Web互動模型中的“一個請求對應一個伺服器執行緒”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多Web服務端應用都可以使用執行緒本地儲存來解決執行緒安全問題。

3. 鎖最佳化

各種鎖最佳化技術包括,適應性自旋、鎖消除、鎖膨脹、輕量級鎖、偏向鎖等,這些技術都是為了線上程之間更高效地共享資料及解決競爭問題,從而提高程式的執行效率

3.1 自旋鎖與自適應自旋

前面介紹互斥同步的時候,提到了互斥同步對效能最大的影響是執行緒的上下文切換。而虛擬機器的開發團隊也注意到在許多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。現在絕大多數的個人電腦和伺服器都是多路(核)處理器系統,如果物理機器有一個以上的處理器或者處理器核心,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一會”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只須讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖。

自旋鎖在JDK6中就已經改為預設開啟了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,所以如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之如果鎖被佔用的時間很長,那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有價值的工作,這就會帶來效能的浪費。因此自旋等待的時間必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒。自旋次數的預設值是十次,使用者也可以使用引數-XX:PreBlockSpin 來自行更改。

在JDK6中對自旋鎖的最佳化,引人了自適應的自旋。自適應意味著自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中、那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間,比如持續100次忙迴圈。另一方面,如果對於某個鎖,自旋很少成功獲得過鎖,那在以後要獲取這個鎖時將有可能直接省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨著程式執行時間的增長及效能監控資訊的不斷完善,虛擬機器對程式鎖的狀況預測就會越來越精準,虛擬機器就會變得越來越“聰明”了。

3.2 鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼要求同步,但是對被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的資料支援,如果判斷到一段程式碼中,在堆上的所有資料都不會逃逸出去被其他執行緒訪問到,那就可以把它們當作棧上資料對待,認為它們是執行緒私有的,同步加鎖自然就無須再進行。

簡單理解就是:對於不存線上程安全問題的程式碼加鎖,JVM會自動將鎖消除。

3.3 鎖粗化

原則上,我們在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的運算元量儘可能變少,即使存在鎖競爭,等待鎖的執行緒也能儘可能快地拿到鎖。

大多數情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體之中的,那即使沒有執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

如果虛擬機器探測到有這樣一串零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部。

3.4 輕量級鎖

輕量級鎖是JDK6時加入的新型鎖機制,它設計的初衷是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

要理解輕量級鎖,以及後面會講到的偏向鎖的原理和運作過程,必須要對HotSpot虛擬機器物件的記憶體佈局(尤其是物件頭部分)有所瞭解。

HotSpot虛擬機器的物件頭分為兩部分:第一部分用於儲存物件自身的執行時資料,如雜湊碼,GC分代年齡等。這部分資料的長度在32位和64位的Java虛擬機器中分別會佔用32個或64個位元,官方稱它為“MarkWord”。這部分是實現輕量級鎖和偏向鎖的關鍵。另外一部分用於儲存指向方法區物件型別資料的指標,如果是陣列物件,還會有一個額外的部分用於儲存陣列長度。

由於物件頭資訊是與物件自身定義的資料無關的額外儲存成本,考慮到Java虛擬機器的空間使用效率,MarkWord被設計成一個非固定的動態資料結構,以便在極小的空間記憶體儲儘量多的資訊。它會根據物件的狀態複用自己的儲存空間。例如在32位的HotSpot虛擬機器中,物件未被鎖定的狀態下,MarkWord的32個位元空間裡的25個位元將用於儲存物件雜湊碼,4個位元用於儲存物件分代年齡,2個位元用於儲存鎖標誌位,還有1個位元固定為0(這表示未進入偏向模式)。物件除了未被鎖定的正常狀態外,還有輕量級鎖定重量級鎖定、GC標記、可偏向等幾種不同狀態。

接下來介紹輕量級鎖的工作過程了:在程式碼即將進人同步塊的時候,如果此同步物件沒有被鎖定(鎖標誌位為“01”狀態),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的複製。

然後,虛擬機器將使用CAS操作嘗試把物件的Mark Word更新為指向Lock Record 的指標。如果這個更新動作成功了,即代表該執行緒擁有了這個物件的鎖,並且物件Mark Word的鎖標誌位(Mark Word的最後兩個位元)將轉變為“00”,表示此物件處於輕量級鎖定狀態。

如果這個更新操作失敗了,那就意味著至少存在一條執行緒與當前執行緒競爭獲取該物件的鎖。虛擬機器首先會檢查物件的Mark Word 是否指向當前執行緒的棧幀,如果是,說明當我程已經擁有了這個物件的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個物件已經被其他執行緒搶佔了。如果出現兩條以上的執行緒爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖,鎖標誌的狀態值變為“10”。此時 Mark Word 中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也必須進人阻塞狀態。

上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也同樣是透過CAS操作來進行的,如果物件的Mark Word仍然指向執行緒的鎖記錄,那就用CAS操作把物件當前的Mark Word 和執行緒中複製的 Displaced Mark Word 替換回來。假如能夠成功替換,那整個同步過程就順利完成了;如果替換失敗,則說明有其他執行緒嘗試過獲取該鎖,就要在釋放鎖的同時,喚醒被掛起的執行緒。

輕量級鎖能提升程式同步效能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”這一經驗法則。如果沒有競爭,輕量級鎖便透過CAS操作成功避免了使用互斥量的開銷;但如果確實存在鎖競爭,除了互斥量的本身開銷外,還額外發生了CAS操作的開銷。因此在有競爭的情況下,輕量級鎖反而會比傳統的重量級鎖更慢。

3.5 偏向鎖

偏向鎖的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS 操作都不去做了。

偏向鎖中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖一直沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。

偏向鎖原理:當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設定為“01”、把偏向模式設定為“1”,表示進入偏向模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的Mark Word之中。如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作(例如加鎖、解鎖及對Mark Word 的更新操作等)。

一旦出現另外一個執行緒去嘗試獲取這個鎖的情況,偏向模式就馬上宣告結束。根據鎖物件目前是否處於被鎖定的狀態決定是否撤銷偏向(偏向模式設定為“0”),撤銷後標誌位恢復到未鎖定(標誌位為“01”)或輕量級鎖定(標誌位為“00”)的狀態,後續的同步操作就按照上面介紹的輕量級鎖那樣去執行。

相關文章