寫在前面
鎖的出現,是多線程併發編程所需要的,如果程序在併發執行,同時對一個資源進行操作,這是很容易出現問題的:多個線程同時運行,就像是活在同一個地球上不同維度的生物,它們互相感知不到對方,卻在操作同一個東西,可能操作着操作着,突然東西就不見了,或者變多了。這是因爲它們在同時操作,而且操作的時候沒有互相告知。Java 原生的有兩種實現鎖的機制,一種是通過底層實現的 synchronized
關鍵字,另一種是 Doug Lea 在 JDK1.5 實現的 java.util.concurrent
包中的 Lock
類。這兩種方法一種是 Java 關鍵字,另一種是用對象的方式,兩種都實現了併發狀態下對公共資源的加鎖。
synchronized 原理鋪墊
synchronized 是一個 Java 的關鍵字,能夠對併發資源上鎖,它由 JVM 實現,也就是說 synchronized 跟底層有關係。synchronized 關鍵字從 JDK 1.0 就存在了,最開始是一種代價很大的保證線程安全的方法(但也是唯一一種),在 JDK 6 被重新設計,性能大大提升。這一性能提升,一方面歸功於軟件代碼設計的進步,另一方面也要歸功於硬件的發展。
最初的 synchronized 關鍵字
最開始的 synchronized 關鍵字,基於互斥同步的原理來實現。互斥同步的意思是說:如果一個線程在使用資源,另一個線程想要使用資源,就要等,等到能獲取資源爲止,這裏的等就是互斥的表現,一方使用,另一方就不準使用(即阻塞)。互斥同步是一種很消耗性能的操作,這是因爲實現互斥的方式:阻塞,是一種很消耗性能的操作。
這裏要提到操作系統的用戶態和內核態。主流的 Java 虛擬機對於 Java 線程的實現,是直接將 Java 線程映射到操作系統的原生內核線程之上的,因此實現線程阻塞和線程喚起,必須需要操作系統來幫忙完成,操作系統的用戶態和內核態之間進行轉換。一個線程嘗試獲取資源時,發生線程阻塞,這裏的阻塞是操作系統來幫助進行的,操作系統由用戶態轉爲內核態,在內核態狀態下將這一條線程阻塞住。
用戶態和內核態是很重要的操作系統的概念,這裏不多進行學習,只記住有這麼一回事就可以了。用戶態和內核態在進行轉換的過程中,需要保存上下文信息,將兩種狀態的信息都存儲下來,這是很消耗資源的。因此互斥同步是很很消耗性能的,用戶態和內核態之間進行轉換消耗的處理器時間,甚至比同步狀態下的代碼執行時間還要長,這是一種非常重量級的操作,由此造成了最初的 synchronized 關鍵字性能很差。
經過改進的 synchronized 關鍵字
最初 synchronized 關鍵字性能差的原因,是因爲互斥同步是通過線程阻塞來實現的,而線程阻塞必然導致操作系統在用戶態和內核態之間做轉換,因而性能差。如果 synchronized 關鍵字不通過互斥同步實現(不通過阻塞線程來實現安全),那麼性能說不定就會好很多。
阻塞,是一種無奈之舉,因爲不阻塞住線程,就不敢保證操作數據的過程是安全的。不安全最常見的現象是:一條線程讀取完數據進行操作,還沒保存,另一條線程就修改了數據,那麼這時再保存,就會無視剛剛修改的數據,換言之,另一條線程的操作被“無效化”了。
一種比較常見的處理辦法是,獲取資源時記錄下數據的值,在保存的時候,先比對數據是否還是當時的大小,如果是,就默認資源沒有問題,可以進行保存。這也就是併發中非常重要的概念:CAS(compare and swap - 比較後交換),先比較預期的數據,如果是預期的大小,就交換值(保存)。值得一提的是,CAS 是另一種實現線程安全的方式:JUC 包的核心邏輯。(但是這實際上還是有潛在問題的,比如我在早晨 10 點獲取到數據知道是 1,在下午 5 點發現數據還是 1,這並不能保證數據在這段時間中沒有被改過,有可能改了又改回來了,即“ABA 問題”,但好在大多數的情況下 ABA 問題不會影響程序併發的正確性)
如果通過 CAS 操作數據,就可以代替阻塞,性能提高。CAS 的英文原名是 compare & swap,這是指 compare 和 swap 必須在一起進行,執行完 compare 就必須接着執行 swap,即這兩個動作合在一起是原子性的,是不能拆開的。這也就是爲什麼 synchronized 關鍵字性能提升是需要藉助於硬件技術的提高的,因爲 CAS 必須由硬件執行,而不能是軟件(如果是軟件實現,那還是通過互斥同步的方式進行,這就沒有意義了),最初的 cpu 在硬件指令集中是沒有 CAS 操作的,之後纔出現這一指令,JDK 5 的 Java 類庫開始使用 CAS 操作,在 JDK 6 中使用該操作對 synchronized 進行了改造。
粗略地講,synchronized 通過 CAS 操作進行改造的原理,是分了兩種情況:如果只有一個線程使用資源(但在理論上有可能有別的線程搶資源),直接 CAS 保存數據就可以了,不需要阻塞線程;如果線程一多爭搶資源,那沒有辦法,乖乖地阻塞線程,通過互斥同步來實現線程安全。
補充
原始的 synchronized 通過互斥同步來實現線程安全,新的 synchronized 通過 CAS 操作來部分實現線程安全,這實際上也是兩種思路,兩種在面對併發風險時的思路。
- 互斥同步的思路是,有可能發生併發風險,那麼我提前準備,一條線程使用,另一條線程就不準使用。
- CAS 的思路是,有可能發生併發風險,不用提前準備,先進行 CAS 操作保存,真發現了數據不一樣再說。
一種是提前應對風險,將風險扼殺在搖籃中,另一種是不管風險先進行操作,產生了衝突再進行補償措施。這兩種思路實際上就是鎖機制當中的“樂觀鎖“和”悲觀鎖“的思路。樂觀和悲觀指的是面對併發風險時的態度:
- 樂觀的話,先不管風險,幹了再說,有問題回來找補(對應於 CAS 操作)
- 悲觀的話,先考慮風險,萬無一失,再進行數據處理(對應於互斥同步)
因此樂觀鎖回滾重試,悲觀鎖阻塞事務。JDK 6 之後的 synchronized 關鍵字就是先樂觀,樂觀不起來了再悲觀。
synchronized 原理
學習 synchronized 關鍵字需要對 JVM 中對象的內存佈局(尤其是對象頭部分)有所瞭解。對象頭的內容,在此不多贅述。
對象存儲在 JVM 堆裏,鑑於內存寸土寸金,需要儘可能地縮減對象頭的大小,因此對象頭有五種狀態,在不同的狀態下存儲不同的信息。上圖的前四種與 synchronized 關鍵字有關,分別在【沒有鎖】、【偏向鎖】、【輕量級鎖】和【重量級鎖】
狀態下,存儲不同的信息。換個角度理解,這意味着 synchronized 也有四種場景。
synchronized 關鍵字的原理(改進之後),就像是開車掛擋,起步一檔,速度上來之後掛二檔,最後一腳油門上了三擋。
- 如果只有一個線程在使用資源,那麼掛一檔:偏向鎖
- 如果有少數幾個線程在使用資源,那麼掛二檔:輕量級鎖
- 如果有好幾個線程在使用資源,那麼掛三擋:重量級鎖
這三種檔位是針對於 JDK 6 之後的 synchronized,在這之前起步直接三擋。
對應於這三個檔位(外加上空擋)一共有四種狀態,這四種狀態的標誌位如下:
鎖 | 偏向模式(1 bit) | 鎖標誌位(2 bit) |
---|---|---|
無鎖 | 0 | 01 |
偏向鎖 | 1 | 01 |
輕量級鎖 | (沒有該字段) | 00 |
重量級鎖 | (沒有該字段) | 10 |
幾種狀態鎖
偏向鎖
對於上鎖的對象,有一個資源爭搶的升級過程。最開始的情況,是隻有一條線程在使用資源,這時並不存在競爭的情況。如果不存在競爭,上鎖是沒有必要的,或者說上重量級的鎖是沒有必要的,畢竟沒其他線程搶資源。
偏向鎖的偏向,是“偏心”的“偏”、“偏袒”的“偏”,其含義是偏向線程,偏向於第一個獲取到它的線程。如果之後一直沒有其他線程出現,則持有偏向鎖的線程永遠不需要進行同步。如果出現了新的線程,偏向鎖立即終止。
因此,如果只有一條線程使用資源,則使用偏向鎖。如果出現了第二條線程,不論這兩條線程是否存在競爭,鎖都會膨脹,偏向鎖即刻作廢。(還是有一些例外的示,如果前一條線程死亡了,新的線程來申請資源,還是能繼續使用偏向鎖的)在這種意義上,偏向鎖是不需要解鎖的,因爲它從始至終只會有一個鎖的主人,出現了第二個主人時,它就作廢了,沒有解鎖是偏向鎖相比於輕量級鎖、重量級鎖的一個區別。
偏向鎖的具體實現,實際上還是比較繁瑣的。總體上講,是把偏向線程的線程ID記錄在對象頭中,之後再此使用前比對線程ID,如果就是當前線程則無需同步,如果不是當前線程那麼偏向鎖立即停止使用。
細緻地講,偏向鎖的上鎖過程如下(自行對照上面對象頭示意圖):
確保可以上偏向鎖
首先對象應處於未上鎖狀態(鎖標誌位是 01),且對象應爲可偏向(偏向標誌位是 1),因此對象頭的標記部分應爲 101 結尾。由於無鎖和偏向鎖的鎖標誌位是相同的(都是 01),因此另用 1 bit 來表示對象是否可偏向。JDK 6 下的 HotSpot 虛擬機默認開啓偏向鎖,可以手動設置參數關閉。
參照上圖,對象頭在無鎖的狀態下會保存對象的哈希碼(hashcode),實際上這並不一定,如果對象沒有計算過哈希碼(例如調用 Object :: hashCode()
會計算哈希碼),那麼哈希碼將不會保存在對象頭中。但一旦計算過哈希碼,對象頭中就會儲存哈希碼,這個對象就再也不會進入偏向鎖狀態了,如需上鎖,它只會一步到位膨脹成重量級鎖。
(附上 64 位 JVM 的對象頭標記字段,在無鎖和偏向鎖狀態下的內容:)
|------------------------------------------------------------------------------|----------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |(無鎖)
|------------------------------------------------------------------------------|----------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |(偏向鎖)
|------------------------------------------------------------------------------|----------------|
通過 CAS 嘗試上偏向鎖
在確保對象進入偏向模式的前提下,JVM 將會使用 CAS 操作把獲取到這個鎖的線程的ID記錄在對象的標記字段中(圖中的對象頭標記字段,在偏向模式下的偏向 ID 部分,32 位虛擬機佔 23 bit,64 位虛擬機佔 54 bit)。如果 CAS 記錄線程 ID 成功,那麼認爲偏向鎖上鎖成功,以後持有偏向鎖的線程每次進入這個鎖相關的同步塊時,都不需要進行任何同步操作。如果 CAS 記錄線程 ID 失敗,那麼偏向模式馬上就宣告結束。
-
如果此時對象沒有上鎖,那麼該對象將先撤銷偏向(將偏向標誌位設置爲 0),再升級爲輕量級鎖(這一步的撤銷偏向是有一定的性能損耗的)
-
如果此時對象已經上了偏向鎖,那麼該對象將繼續申請輕量級鎖
(下圖爲《深度理解 Java 虛擬機》的配圖,描述了偏向鎖膨脹到輕量級鎖的過程)
對象頭的標記字段中,還有一個字段:偏向時間戳(epoch)
這個字段的作用是統計重偏向次數。重偏向的概念是這樣的,如果有一個類實例化 20 個對象出來,這 20 個對象先經歷線程 1,再經歷線程 2,上鎖時需要發生 20 次的撤銷偏向,再升級到輕量級鎖的過程。偏向鎖的撤銷是比較昂貴的(原理暫不考究),如果這種現象多次出現,就意味着這個類不適合使用偏向鎖。
對於這種場景 JVM 單獨做了優化,類記錄了一個 epoch 值,對象在創建時也將有一個 epoch 值(創建時與類的相同)。如果類對象發生了一次大規模的撤銷偏向行爲,類的 epoch 值將加 1(以後創建的對象也會採用新的 epoch 值),如果類的 epoch 值超過某個閾值,則證明該類不適合使用偏向鎖,以後的對象也將不會再使用偏向鎖,直接使用輕量級鎖。
對象頭中的 epoch 值是爲了和類的 epoch 值對比用的,如果不一樣,則將直接膨脹到輕量級鎖。
輕量級鎖
當資源不再只被一條線程獲取,出現了兩個及以上的線程時,偏向鎖立即作廢,膨脹爲輕量級鎖。
輕量級鎖存在的意義是,如果有多個線程獲取資源,但是是交替獲取的,並沒有發生資源競爭的風險,那麼加一個輕量級鎖,保證其中一條線程在運行時另一條線程不會並行操作即可。因此輕量級鎖的目的,是爲了消除數據在無競爭情況下的同步原語,提高程序的運行性能。
(附上 64 位 JVM 的對象頭標記字段,在偏向鎖和輕量級鎖狀態下的內容:)
|---------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |(偏向鎖)
|---------------------------------------------------------------------|--------------------|
| ptr_to_lock_record | lock:2 | Lightweight Locked |(輕量級鎖)
|---------------------------------------------------------------------|--------------------|
無論是無鎖還是偏向鎖,這兩種狀態的鎖標誌位都是 01,都可以膨脹到輕量級鎖,將鎖標誌位修改爲 00。對於輕量級鎖而言,上鎖的對象,其對象頭的標記字段只有兩部分內容:分別是鎖標誌位(2 bit,值爲 00),以及正在佔有對象的線程ID。
無鎖膨脹到輕量級鎖的過程是這樣的(偏向鎖的話,要先撤銷偏向到無鎖狀態,再進行膨脹):
-
確保對象沒有被鎖定,鎖標誌爲是 01。
-
備份對象頭的標記字段
將對象頭的標記字段(Mark Word)拷貝到當前線程的棧幀中。也就是把那 8 字節包含着哈希碼、分代年齡、偏向狀態、鎖標誌位等信息的標記字段,存儲在當前線程的 JVM 棧中。標記字段保存在線程棧幀的地址,叫做“鎖記錄”(Lock Record),換種表述方法,這塊 Lock Record 用來存儲對象目前的 Mark Word 的拷貝。 -
CAS 更新對象頭,上輕量級鎖
虛擬機使用 CAS 操作嘗試將對象頭的 Mark Word 更新爲指向 Lock Record 的指針(就是上一步中,線程棧幀備份對象頭的地址),並將對象頭的鎖標記更新爲輕量級鎖(00)。
如果這步 CAS 操作能夠成功,那麼輕量級鎖就上好了,如果沒有成功,則證明在同一時間有多個線程在競爭資源,輕量級鎖不再有效,鎖進一步膨脹爲重量級鎖。
如果對象已經上了輕量級鎖,當有線程再次申請資源時:
- 如果是同一個線程,則是一次鎖重入。每次鎖重入依舊會在線程棧幀中創建一個 Lock Record,只不過重入創建的 Lock Record 的值爲 null,即它不再是對象頭標記字段的備份。
- 如果是另一個線程,說明存在多個線程競爭鎖,鎖膨脹爲重量級鎖。
輕量級鎖有解鎖的操作,當線程操作完對象資源後,需要將輕量級鎖解除。解鎖的方法,是將對象頭的 Mark Word 和線程棧中的 Lock Record 通過 CAS 替換回來,如果 CAS 操作失敗代表有其他線程在競爭資源,鎖膨脹。
重量級鎖
當出現兩個或更多的線程,在同一時間操作資源時,會發生線程競爭,此時鎖膨脹爲最強的重量級鎖,採取互斥同步的方式,讓同一時間只有一個線程操作資源,其他線程阻塞等待。
重量級鎖通過一個 monitor 對象實現多線程競爭時的互斥同步,monitor(監視器)是併發設計中很重要的設計,在不同的語言中有不同的實現。monitor 作爲監視器,監視的是資源,每一個類或者每一個對象只能有一個 monitor 對象,這個 monitor 由 JVM 創建,能夠保證同一時間只會有一個線程使用資源,其他線程都乖乖阻塞。
在 JVM 中 monitor 是 ObjectMonitor 類的實例對象,該類源碼由 C++ 編寫,代碼如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //monitor進入數
_waiters = 0,
_recursions = 0; //線程的重入次數
_object = NULL;
_owner = NULL; //標識擁有該monitor的線程
_WaitSet = NULL; //等待線程組成的雙向循環鏈表,_WaitSet是第一個節點
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多線程競爭鎖進入時的單項鍊表
FreeNext = NULL ;
_EntryList = NULL ; //處於等待鎖block狀態的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
通過這個類實例化的 monitor 對象,一對一地監控着每一個需要互斥同步的類或對象,由 _owner 屬性記錄併發競爭成功的線程,執行完後換下一個線程,實現重量級鎖。如果有其他線程嘗試獲取 monitor,會由於線程重入次數不爲 0 而被迫阻塞。
synchronized 是以代碼塊的形式使用的,例如:
public void func() {
synchronized (this) {
// ...
}
}
JVM 將 Java 代碼解釋成 CPU 原語時,解析 synchronized 關鍵字,會分別將代碼塊的開始和結束,解釋成 monitorenter 和 monitorexit,這兩個原語非常形象,就是進入 monitor 和離開 monitor。通過這兩個 CPU 原語,JVM 使每個線程都要去 monitor 處報到,等待重新調度。
synchronized 使用
synchronized 關鍵字有四種使用表現,分別是同步對象、類、方法、靜態方法,而同步方法和靜態方法,實際上還是在同步對象和類,因此從原理上 synchronized 關鍵字同步的是對象或類。
同步一個對象
對任意一個對象加 synchronized,代碼塊當中的代碼都會同步。
Object object = new Object();
synchronized (object) {
// ...
}
例如下列代碼:實現一個 Runnable 接口,按順序打印 1-10
// r1不同步
Runnable r1 = () -> {
for (int i = 1; i <= 10; i++) {
System.out.print(i);
}
};
// r2同步
Runnable r2 = () -> {
synchronized (object) {
for (int i = 1; i <= 10; i++) {
System.out.print(i);
}
}
};
此時在線程池中分別跑不同步的 r1 和同步的 r2,每次線程池中跑兩個線程
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(r1);
executorService.execute(r1);
// 打印結果:1 1 2 2 3 4 5 6 7 8 9 10 3 4 5 6 7 8 9 10
executorService.execute(r2);
executorService.execute(r2);
// 打印結果:1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10
一種非常常見的同步對象的方式,是在類方法中同步 this,即表示同步當前對象。
synchronized (this) {
// ...
}
同步一個類
當 synchronized 同步一個類時,使用該類的所有線程,無論是在操作哪一個對象,都將進行同步。
public void func() {
synchronized (SynchronizedExample.class) {
// ...
}
}
例如如下代碼:自定義一個 MyClass 類,該類只有一個按順序打印 1-10 的方法。生成兩個該類的對象,並調用兩個線程分別執行這兩個類的打印數字方法。
// 創建一個包含打印數字方法的類
class MyClass {
void testSync() {
for (int i = 1; i <= 10; i++) {
System.out.print(i);
}
}
}
// 創建兩個類對象
MyClass clazz1 = new MyClass();
MyClass clazz2 = new MyClass();
// 在線程池中執行打印數字的方法
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> clazz1.testSync());
executorService.execute(() -> clazz2.testSync());
// 打印結果:1 2 3 4 5 1 2 3 4 5 6 7 8 9 10 6 7 8 9 10
如果對類方法進行 synchronized 同步,同步的內容是一個類(任意一個類都可以),則可實現線程間的同步。
void testSync() {
synchronized (Object.class) {
for (int i = 1; i <= 10; i++) {
System.out.print(i);
}
}
}
// (其他代碼略)打印結果:1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10
同步一個方法
public synchronized void func () {
// ...
}
它作用於同一個對象。(這就是 HashTable 不如 ConcurrentHashMap 的地方,因爲它在方法上同步,鎖住了整個對象,太過笨重)
同步一個靜態方法
public synchronized static void fun() {
// ...
}
它作用於整個類。