Java 線程安全與鎖
本文主要來自自周志明的《深入理解 Java 虛擬機——JVM高級特性與最佳實踐》
文章目錄
一、 線程安全定義
當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,那這個對象是線程安全的。
二、 共享數據的分類
按照線程安全的 “安全程度” 由強至弱來排序,我們可以將 Java 語言中各種操作共享的數據分爲以下 5 類:不可變、絕對線程安全、相對線程安全、線程兼容、線程對立。
1. 不可變
在 Java 語言中,不可變一定是線程安全的。不可變就代表了無法被修改,只能被讀取,無法修改的對象,無論線程對這個對象進行什麼操作,都能得到正確的結果。
- 如果共享數據是一個基本類型,那麼可以直接用 final 關鍵字來保證不可變;
- 如果共享數據是一個對象,就要保證對象的行爲不會對其狀態產生任何影響,比如可以將對象中帶有狀態的變量都聲明成 final。一個典型的例子就是 String 類,我們調用的任何一個 String 對象的函數,都不會修改它的值,只會直接返回一個新構造的字符串對象
2. 絕對線程安全
這種類型完全滿足線程安全的定義,但這需要付出很大的代價,有時候甚至只是不切實際的。在 Java 中標註線程安全的類,也不一定滿足絕對的線程安全。
舉個例子:
Vector 是一個線程安全的容器,它的很多方法都被 synchronized 修飾,比如 add()、get()、size(),儘管這樣效率低,但確實安全了。然而,即使它的方法都被修飾成同步,也不意味着他是絕對線程安全。比如,一個線程調用 remove() 方法,一個進行遍歷操作,此時就容易產生越界異常,如果想讓他正確執行,還需要對這個 vector 對象進行同步。
3. 相對線程安全
相對的線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單獨的操作時線程安全的,我們在調用的時候不需要做額外的保障措施,但對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。
在 Java 中,大部分的線程安全類都屬於這種類型,比如 Vector、HashTable、Collections。
4. 線程兼容
線程兼容是指對象本身並不是線程安全,但是可以通過在調用端正確使用同步手段來保證對象在併發環境中可以安全地使用,我們平常說一個類不是線程安全的,絕大多數指的就是這個情況。
在 Java 中,大部分類都是線程兼容的,比如 ArrayList,HashMap 等。
5. 線程對立
線程獨立是指無論調用端是否採取了同步措施,都無法在多線程環境中併發使用地代碼。
在 Java 中這種類型的代碼很少出現,一個例子就是 Thread 的 suspend()和 resume(),如果兩個線程同時持有一個對象,一個嘗試中斷線程一個嘗試恢復線程,如果併發進行的話,無論是否進行了同步,目標線程都有存在死鎖的風險。
三、 線程安全的實現
1. 互斥同步
互斥同步是常見的一種併發正確性保障手段。同步是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一個線程使用。而互斥是實現同步的一種手段,臨界區、互斥量和信號量都是主要的互斥實現方式。
Java 中,常用的互斥同步就是 synchronized 和 ReentrantLock。
synchronized
synchronized 是 Java 中的一個關鍵字,可以用在三個地方:
- 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
- 修飾靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
- 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖
static public synchronized void test1(){
//......
}
public synchronized void test2(){
//......
}
public void test3{
Integer x = 10;
synchronized (x){
//......
}
}
synchronized 關鍵字經過編譯後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 兩個字節碼指令,這兩個字節碼都需要一個引用類型的參數來指定要鎖定和解鎖的對象。如果指定了對象參數,那就是這個對象的引用,如果沒有指定,那麼他就會去獲取實例的引用或類對象的引用作爲鎖對象。
在虛擬機的規範中,執行 monitorenter 指令首先會獲取對象的鎖,如果對象沒被鎖定或已經有了那個對象的鎖,那麼這個鎖的計數器 +1 ,相應的,執行 monitorexit 指令後會將鎖計數器 -1 ,當計數器爲 0 時,釋放這個鎖,如果獲取鎖失敗,那麼就進入阻塞,等待別的線程釋放鎖爲止。
ReentrantLock
在基本用法上, ReentrantLock 和 synchronized 很相似,不過 synchronized 是將加鎖解鎖交給虛擬機進行操作,ReentrantLock 則要求在我們在代碼上手動的加鎖解鎖。不過相比起 synchronized,ReentrantLock 擁有一些高級功能,主要是:
- 等待可中斷:如果長時間獲取不到鎖,可以中斷等待狀態,處理別的事情。
- 公平鎖:當多個線程等待某個鎖釋放時,必須要按申請鎖的時間順序來以此獲得鎖。
- 鎖綁定多個條件:ReentrantLock 對象可以同時綁定多個 Condition 對象作爲鎖,只用 new Condition() 即可。
private final Lock lock = new ReentrantLock();
public void test4(){
lock.lock();
try{
//......
} finally {
lock.unlock();
}
}
二者對比:
- synchronized 是虛擬機隱式地進行加鎖解鎖,ReentrantLock 是程序員自己在代碼中加鎖解鎖
- synchronized 已經進行多次優化,性能與 ReentrantLock 相差不大,如果不追求 ReentrantLock 的高級功能,推薦優先考慮 synchronized。
- synchronized 通信通過 wait(),notify(),notifyAll() 這三個方法,ReentrantLock 通信通過 await(),signal(),signalAll() 這三個方法。
2. 非阻塞同步
互斥同步最主要的問題就是線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱爲阻塞同步。
互斥同步屬於一種悲觀的併發策略,總是認爲只要不去做正確的同步措施,那就肯定會出現問題。無論共享數據是否真的會出現競爭,它都要進行加鎖(這裏討論的是概念模型,實際上虛擬機會優化掉很大一部分不必要的加鎖)、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。
隨着硬件指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略:先進行操作,如果沒有其它線程爭用共享數據,那操作就成功了,否則採取補償措施(不斷地重試,直到成功爲止)。這種樂觀的併發策略的許多實現都不需要將線程阻塞,因此這種同步操作稱爲非阻塞同步。
CAS
比較並交換(Compare-and-Swap,CAS),CAS 指令需要有 3 個操作數,分別是內存位置(V),舊的預期值(A),新值(B),只有當 V 中的數據等於 A 時,纔會將 B 更新到 V 上,否則就不更新。
原子類中的操作使用的就是 CAS 指令,我們可以看看 AtomicInteger 中加法的源碼:
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
var1 指示對象內存地址,var2 指示該字段相對對象內存地址的偏移,即 var1+var2 才能獲取到對象中的存放數據的 value 字段的值,var4 指示操作需要加的數值。通過 getIntVolatile(var1, var2) 得到對象中的數據,通過調用 compareAndSwapInt() 來進行 CAS 比較,如果該字段內存地址中的值等於 var5,那麼就更新對象中的數據爲 var5+var4。
CAS 存在這樣的一個邏輯漏洞:如果一個變量 V 初次讀取的時候是 A 值,並且在準備賦值的時候檢在到它仍然爲 A 值,那我們就能說它的值沒有被其他線程改變過嗎?如果在這段期間它的值曾經被改成了 B,後來又被改問爲 A,那 CAS 操作就會誤認爲它從來沒有被改變過。這個漏洞稱爲 CAS 操作的 “ ABA ” 問題。J.U.C 包中有一個帶有標記的原子引用類 “ AtomicStampedReference ”,它可以通過控制變量值的版本來保證 CAS 的正確性。不過目前來說這個類比較 “ 雞肋 ” ,大部分情況下 ABA 問題不會影響程序併發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。
3. 無同步方案
要保證線程安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保證共享數據爭用時的正確性的手段,如果一個方法本來就不涉及共享數據,那它自然就無須任何同步措施去保證正確性。
可重入代碼
這種代碼也叫做純代碼,可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤。相對線程安全來說,可重入性是更基本的特性,它可以保證線程安全,即所有的可重入的代碼都是線程安全的,但是並非所有的線程安全的代碼都是可重入的。
我們可以通過一個簡單的原則來判斷代碼是否具備可重入性:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是線程安全的。
線程本地存儲
如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。
相當於爲每一個線程提供該變量的副本,看似是共享數據進行操作,不過都是在對自己線程中的該數據的副本進行操作,不會影響到別的線程的副本數據。在 Java 中通過 ThreadLocal 類來實現。
四、鎖優化
1. 自旋鎖與自適應自旋
線程進行掛起和恢復,都會消耗許多的資源,而如果共享數據的鎖定只會持續一小段時間,爲了這一段時間,將線程掛起再恢復並不值得。我們可以讓線程進行循環等待,不放棄處理器的執行時間,只需要等待一會兒就能獲取到鎖繼續操作,這就是自旋鎖。
在 JDK1.6 中引入了自適應的自旋鎖。自適應意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。
如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次白旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如 100 個循環。另外,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。
2. 鎖消除
鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除主要是通過逃逸分析來支持,如果堆上的共享數據不可能逃逸出去被其它線程訪問到,那麼就可以把它們當成私有數據對待,也就可以將它們的鎖進行消除。
比如字符串的拼接,JDK1.5 前使用的是 StringBuffer 中的 append() 進行拼接,而 StringBuffer 是一個線程安全類,這就是一個隱式加鎖的過程,而現在底層優化了,使用的是 StringBuilder 中的 append() 進行拼接
3. 鎖粗化
如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,頻繁的加鎖操作就會導致性能損耗,因此此時需要將鎖的範圍擴大,將這些連續加鎖解鎖的操作,都放在一次加鎖解鎖中,這就是鎖粗化。
之前說的字符串的連續的 append() 方法就屬於這類情況。如果虛擬機探測到由這樣的一串零碎的操作都對同一個對象加鎖,將會把加鎖的範圍擴展(粗化)到整個操作序列的外部。對於上一節的示例代碼就是擴展到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。(JDK1.5 版本前)
4. 輕量級鎖
JDK 1.6 引入了偏向鎖和輕量級鎖,從而讓鎖擁有了四個狀態:無鎖狀態(unlocked)、偏向鎖狀態(biasble)、輕量級鎖狀態(lightweight locked)和重量級鎖狀態(inflated)。
在虛擬機中,每個對象都有一個對象頭,其中對象頭分爲兩個部分,第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode)、 GC 分代年齡(Generational GC Age)等這部分數據的長度在 32 位和 64 位的虛擬機中分別爲 32bit 和 64bit,官方稱它爲 “ Mark Word ” ,它是實現輕量級鎖和偏向鎖的關鍵。另外一部分用於存儲指向方法區對象類型數據的指針,如果是數組對象的話,還會有一個額外的部分用於存儲數組長度。
輕量級鎖主要針對的是第一部分,即:Mark Word 部分。
對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率, MarkWord 被設計成-個非固定的數據結構以便在極小的空間內存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。例如,在 32 位的 HotSpot 虛擬機中對象未被鎖定的狀態下,MarkWord 的 32bit 空間中的 25bit 用於存儲對象哈希碼(HashCode),4bit 用於存儲對象分代年齡, 2bit 用於存儲鎖標誌位, 1bit 固定爲 0,在其他狀態(輕量級鎖定、重量級鎖定、GC 標記、可偏向)下對象的存儲內容見下表,存儲內容和標誌位是一一對應的:
存儲內容 | 標誌位 | 狀態 |
---|---|---|
對象哈希碼、對象分代年齡 | 01 | 未鎖定 |
指向鎖記錄的指針 | 00 | 輕量級鎖定 |
指向重量級鎖的指針 | 10 | 重量級鎖定 |
空,不需要記錄信息 | 11 | GC 標記 |
偏向線程 ID、偏向時間戳、對象分代年齡 | 01 | 可偏向 |
下面來看一下輕量級鎖的加鎖過程圖:
當代碼進入同步塊時,如果該同步對象沒有被鎖定,該對象的對象頭會如上圖所示,標誌位爲 01 狀態,存的是哈希碼、分代年齡等信息,此時虛擬機會在當前線程的棧幀中,建立一個名爲鎖記錄的空間,即 Lock Record ,用於存儲同步對象的對象頭,如下圖中的棧幀所示。
然後,虛擬機使用 CAS 操作嘗試將對象的 Mark Word 更新爲指向 Lock Record 的指針。如果成功,就代表這個線程擁有了該對象的鎖,並將同步對象的鎖標誌位轉爲 00 狀態。
如果 CAS 操作失敗了,虛擬機首先會檢查對象的 Mark Word 是否指向當前線程的虛擬機棧,如果是的話說明當前線程已經擁有了這個鎖對象,那就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程線程搶佔了。如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹爲重量級鎖。
上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是通過 CAS 操作來進行的,如果對象的 Mark Word 仍然指向着線程的鎖記錄,那就用 CAS 操作把對象當前的 Mark Word 和線程中複製的 Displaced Mark Word 替換回來,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他線程嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程。
5. 偏向鎖
偏向鎖的 “ 偏 ” ,就是偏心的 “ 偏 ” 、偏袒的 “ 偏 ” ,它的意思是這個鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。
假設當前虛擬機啓用了偏向鎖(啓用參數-XX:+UseBiasedLocking,這是 JDK 1.6 的默認值),那麼,當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲 “ 01 ” ,即偏向模式。同時使用 CAS 操作把獲取到這個鎖的線程的 ID 記錄在對象的 MarkWord 之中,如果 CAS 操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作(例如 Locking、Unlocking 及對 Mark Word 的 Update 等)。
當有另外一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向(Revoke Bias)後恢復到未鎖定(標誌位爲“01”)或輕量級鎖定(標誌位爲 “00” )的狀態,後續的同步操作就如之前介紹的輕量級鎖那樣執行。偏問鎖、輕量級鎖的狀態轉化及對象 MarkWord 的關係如圖所示。