本文主要講述Java中各類鎖機制的特點,包括重入鎖、悲觀/樂觀鎖、偏向/輕量級/重量級鎖、分段鎖和自旋鎖,闡述其優缺點及一些適用場景。 ...
引言
在多線程併發編程場景中,鎖作為一種至關重要的同步工具,承擔著協調多個線程對共用資源訪問秩序的任務。其核心作用在於確保在特定時間段內,僅有一個線程能夠對資源進行訪問或修改操作,從而有效地保護數據的完整性和一致性。鎖作為一種底層的安全構件,有力地防止了競態條件和數據不一致性的問題,尤其在涉及多線程或多進程共用數據的複雜場景中顯得尤為關鍵。
而瞭解鎖的分類,能幫助我們何種業務場景下使用選擇哪種鎖。
基於鎖的獲取與釋放方式分類
計劃於所得獲取與釋放方式進行分類,Java中的鎖可以分為:顯式鎖和隱式鎖。
隱式鎖
Java中的隱式鎖(也稱為內置鎖或自動鎖)是通過使用synchronized
關鍵字實現的一種線程同步機制。當一個線程進入被synchronized
修飾的方法或代碼塊時,它會自動獲得對象級別的鎖,退出該方法或代碼塊時則會自動釋放這把鎖。
在Java中,隱式鎖的實現機制主要包括以下兩種類型:
-
互斥鎖(Mutex): 雖然Java標準庫並未直接暴露操作系統的互斥鎖提供使用,但在Java虛擬機對
synchronized
關鍵字處理的底層實現中,當鎖競爭激烈且必須升級為重量級鎖時,會利用操作系統的互斥量機制來確保在同一時刻僅允許一個線程持有鎖,從而實現嚴格的線程互斥控制。 -
內部鎖(Intrinsic Lock)或監視器鎖(Monitor Lock): Java語言為每個對象內建了一個監視器鎖,這是一個更高級別的抽象。我們可以通過使用
synchronized
關鍵字即可便捷地管理和操作這些鎖。當一個線程訪問被synchronized
修飾的方法或代碼塊時,會自動獲取相應對象的監視器鎖,併在執行完畢後自動釋放,這一過程對用戶透明,故被稱為隱式鎖。每個Java對象均與一個監視器鎖關聯,以此來協調對該對象狀態訪問的併發控制。
優點:
- 簡潔易用:程式員無需手動管理鎖的獲取和釋放過程,降低了編程複雜性。
- 安全性:隱式鎖確保了線程安全,避免了競態條件,因為一次只有一個線程能持有鎖並執行同步代碼塊。
- 異常處理下的自動釋放:即使在同步代碼塊中拋出異常,隱式鎖也會在異常退出時被釋放,防止死鎖。
缺點:
- 鎖定粒度:隱式鎖的粒度通常是對象級別,這意味著如果一個大型對象的不同部分實際上可以獨立地被不同線程訪問,但由於整個對象被鎖定,可能導致不必要的阻塞和較低的併發性能。
- 不靈活:相對於顯示鎖(如
java.util.concurrent.locks.Lock
介面的實現類),隱式鎖的功能較有限,無法提供更細粒度的控制,如嘗試獲取鎖、定時等待、可中斷的獲取鎖等高級特性。 - 鎖競爭影響:在高併發環境下,若多個線程競爭同一把鎖,可能會引發“鎖爭用”,導致性能下降,特別是在出現鎖鏈和死鎖的情況下。
適用場景: 隱式鎖適用於相對簡單的多線程同步需求,尤其是在只需要保護某個對象狀態完整性,且無需過多關註鎖策略靈活性的場合。對於要求更高併發性和更複雜鎖管理邏輯的應用場景,顯示鎖通常是一個更好的選擇。
顯式鎖
顯式鎖是由java.util.concurrent.locks.Lock
介面及其諸多實現類提供的同步機制,相較於通過synchronized
關鍵字實現的隱式鎖機制,顯式鎖賦予開發者更為精細和靈活的控制能力,使其能夠在多線程環境中精準掌控同步動作。顯式鎖的核心作用在於確保在任何時刻僅有一個線程能夠訪問關鍵代碼段或共用數據,從而有效防止數據不一致性問題和競態條件。
相較於隱式鎖,顯式鎖提供了更為多樣化的鎖操作選項,包括但不限於支持線程在等待鎖時可被中斷、根據先後順序分配鎖資源的公平鎖與非公平鎖機制,以及能夠設定鎖獲取等待時間的定時鎖功能。這些特性共同增強了顯式鎖在面對複雜併發場景時的適應性和可調控性,使之成為解決高度定製化同步需求的理想工具。
日常開發中,常見的顯式鎖分類有如下幾種:
- ReentrantLock:可重入鎖,繼承自
Lock
介面,支持可中斷鎖、公平鎖和非公平鎖的選擇。可重入意味著同一個線程可以多次獲取同一線程持有的鎖。 - ReentrantReadWriteLock:讀寫鎖,提供了兩個鎖,一個是讀鎖,允許多個線程同時讀取;另一個是寫鎖,同一時間內只允許一個線程寫入,寫鎖會排斥所有讀鎖和寫鎖。
- StampedLock:帶版本戳的鎖,提供了樂觀讀、悲觀讀寫模式,適合於讀多寫少的場景,可以提升系統性能。
優點:
- 靈活控制:顯式鎖提供了多種獲取和釋放鎖的方式,可以根據實際需求進行選擇,比如中斷等待鎖的線程,設置超時獲取鎖等。
- 性能優化:在某些特定場景下,顯式鎖可以提供比隱式鎖更好的性能表現,尤其是當需要避免死鎖或優化讀多寫少的情況時。
- 公平性選擇:顯式鎖允許創建公平鎖,按照線程請求鎖的順序給予響應,保證所有線程在等待鎖時有一定的公平性。
缺點:
- 使用複雜:相較於隱式鎖,顯式鎖需要手動調用
lock()
和unlock()
方法,增加了編程複雜性,如果不正確地使用(如忘記釋放鎖或未捕獲異常導致鎖未釋放),容易造成死鎖或其他併發問題。 - 性能開銷:在某些簡單場景下,顯式鎖的額外API調用和鎖狀態管理可能帶來額外的性能開銷,尤其當公平鎖啟用時,由於需要維護線程隊列和線程調度,可能會影響整體性能。
- 錯誤可能性:由於顯式鎖的操作更加細緻,因此更容易出錯,開發者需要具備較高的併發編程意識和技能才能妥善使用。
基於對資源的訪問許可權
按照線程對資源的訪問許可權來分類,可以將鎖分為:獨占鎖(Exclusive Lock)和共用鎖(Shared Lock)。
獨占鎖
獨占鎖(Exclusive Lock),又稱排他鎖或寫鎖,是一種同步機制,它確保在任一時刻,最多只有一個線程可以獲得鎖並對受保護的資源進行訪問或修改。一旦線程獲得了獨占鎖,其他所有試圖獲取同一鎖的線程將被阻塞,直到擁有鎖的線程釋放鎖為止。獨占鎖主要用於保護那些在併發環境下會被多個線程修改的共用資源,確保在修改期間不會有其他線程干擾,從而維護數據的一致性和完整性。
對於獨占鎖就像圖書館里的某本書,這本書只有唯一的一本。當一個讀者想要借閱這本書時,他會去圖書管理員那裡登記並拿到一個“借書憑證”(相當於獨占鎖)。此時,這本書就被鎖定了,其他讀者無法借閱這本書,直至第一個讀者歸還書本並交回“借書憑證”。這就像是線程獲得了獨占鎖,只有擁有鎖的線程可以修改或操作資源(書本),其他線程必須等待鎖的釋放才能執行相應的操作。
而獨占鎖的實現方式,主要有如下兩種:
synchronized
關鍵字:通過synchronized
關鍵字實現的隱式鎖,它是獨占鎖的一種常見形式,任何時刻只有一個線程可以進入被synchronized
修飾的方法或代碼塊。ReentrantLock
:可重入的獨占鎖,提供了更多的控制方式,包括可中斷鎖、公平鎖和非公平鎖等。
優點:
- 簡單易用:對於
synchronized
關鍵字,語法簡單直觀,易於理解和使用。 - 線程安全:確保了對共用資源的獨占訪問,避免了併發環境下的數據競爭問題。
- 可重入性:像
ReentrantLock
這樣的鎖,支持同一個線程重覆獲取同一把鎖,提高了線程間協作的便利性。
缺點:
- 粒度固定:對於
synchronized
,鎖的粒度是固定的,無法動態調整,可能導致不必要的阻塞。 - 缺乏靈活性:隱式鎖不能主動中斷等待鎖的線程,也無法設置超時等待。
- 性能瓶頸:在高度競爭的環境中,
synchronized
可能會造成上下文切換頻繁,效率低下;而顯式鎖雖提供了更靈活的控制,但如果使用不當也可能導致額外的性能損失。
共用鎖
共用鎖(Shared Lock)也稱為讀鎖(Read Lock),是一種多線程或多進程併發控制的同步機制,它允許多個線程同時讀取共用資源,但不允許任何線程修改資源。在資料庫系統和併發編程中廣泛使用,確保在併發讀取場景下數據的一致性。
共用鎖就像圖書館里有一套多人閱讀的雜誌合訂本,這套合訂本可以被多個讀者同時翻閱,但是任何人都不能帶走或在上面做標記。當一個讀者要閱讀時,他會向圖書管理員申請“閱讀憑證”(相當於共用鎖)。如果有多個讀者想閱讀,圖書管理員會給他們每人一份閱讀憑證,這樣大家都可以坐在閱覽室里一起閱讀這套合訂本,但是都不能單獨占有或改變它。在併發編程中,多個線程可以同時獲取共用鎖進行讀取操作,但都不能修改數據,這就像是多個線程同時持有共用鎖讀取資源,但不允許在此期間進行寫操作。
實現共用鎖的關鍵機制是讀寫鎖(ReadWriteLock),這是一種特殊類型的共用鎖機制,它巧妙地將對共用資源的訪問許可權劃分為了讀取許可權和寫入許可權兩類。在讀寫鎖的控制下,多個線程可以同時進行對共用數據的讀取操作,形成併發讀取,而對數據的寫入操作則採取獨占式處理,確保同一時間段內僅有一個線程執行寫入操作。在寫入操作正在進行時,無論是其他的讀取操作還是寫入操作都會被暫時阻塞,直至寫操作結束。
讀寫鎖包含兩種鎖模式:讀鎖(ReadLock) 和 寫鎖(WriteLock)。當多個線程需要訪問同一份共用數據時,只要這些線程都是進行讀取操作,則都能成功獲取並持有讀鎖,從而實現並行讀取。然而,一旦有線程嘗試進行寫入操作,那麼不論是其他正在執行讀取的線程還是準備進行寫入的線程,都無法繼續獲取讀鎖或寫鎖,直至當前寫操作全部完成並釋放寫鎖。這樣,讀寫鎖有效地平衡了讀取密集型任務的併發性和寫入操作的原子性要求。
優點:
- 提高併發性:對於讀多寫少的場景,共用鎖可以使多個讀取操作並行執行,顯著提高系統的併發性能。
- 數據保護:在讀取階段避免了數據被意外修改,確保讀取到的是穩定的數據狀態。
缺點:
- 寫操作阻塞:只要有共用鎖存在,其他事務就不能對數據加排他鎖(Exclusive Lock)進行寫操作,這可能導致寫操作長時間等待,降低系統的寫入性能。
- 可能導致死鎖:在複雜的事務交互中,如果沒有合適的鎖管理策略,共用鎖可能會參與到死鎖迴圈中,導致事務無法正常完成。
- 數據一致性問題:雖然共用鎖能保護讀取過程中數據不被修改,但並不能阻止數據在讀取操作之後立即被其他事務修改,對於要求強一致性的應用可能不夠。
如以下使用共用鎖示例:
public class SharedResource {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
private int data;
public void modifyData(int newData) {
// 獲取寫鎖(獨占鎖),在同一時刻只有一個線程可以獲取寫鎖
writeLock.lock();
System.out.println(Thread.currentThread().getName() + " Modify Data");
try {
// 修改數據
this.data = newData;
// 數據修改相關操作...
} finally {
// 無論如何都要確保解鎖
writeLock.unlock();
}
}
public int readData() {
// 獲取讀鎖(共用鎖),允許多個線程同時獲取讀鎖進行讀取操作
readLock.lock();
System.out.println(Thread.currentThread().getName() + " Read Data");
try {
// 讀取數據,此時其他讀取線程也可以同時讀取,但不允許寫入
return this.data;
}finally {
// 釋放讀鎖
readLock.unlock();
}
}
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread reader1 = new Thread(() -> System.out.println("Reader 1 reads: " + resource.readData()), "Reader1");
Thread reader2 = new Thread(() -> System.out.println("Reader 2 reads: " + resource.readData()), "Reader1");
Thread writer = new Thread(() -> resource.modifyData(42), "Writer1");
reader1.start();
reader2.start();
writer.start();
// 等待所有線程執行完成
try {
reader1.join();
reader2.join();
writer.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
列印結果:
在這個示例中,使用了 ReentrantReadWriteLock
來控制對 data
的讀寫操作。readData()
方法使用讀鎖,允許多個線程同時讀取數據,而 modifyData()
方法使用寫鎖,確保同一時間只有一個線程可以修改數據。這樣就可以在併發場景下既保證數據讀取的併發性,又避免了數據因併發寫入而造成的不一致性問題。
基於鎖的占有權是否可重入
按照鎖的占有權是否可以重入,可以把鎖分為:可重入鎖以及不可重入鎖。
可重入鎖
可重入鎖(Reentrant Lock)作為一種線程同步機制,具備獨特的重入特性,即當線程已經獲取了鎖後,它可以再次請求併成功獲得同一把鎖,從而避免了在遞歸調用或嵌套同步塊中產生的死鎖風險。這意味著在執行鎖保護的代碼區域時,即便調用了其他同樣被該鎖保護的方法或代碼片段,持有鎖的線程也能順利完成操作。
在多線程環境下,可重入鎖扮演著至關重要的角色,它嚴格限制了同一時間只能有一個線程訪問特定的臨界區,有效防止了併發訪問引發的數據不一致和競態條件問題。此外,通過允許線程在持有鎖的狀態下重新獲取該鎖,可重入鎖巧妙地解決了同類鎖之間由於互相等待而形成的潛在死鎖狀況,從而提升了多線程同步的安全性和可靠性。
可重入鎖主要可以通過以下三種方式實現:
synchronized
關鍵字:synchronized
關鍵字實現的隱式鎖就是一種可重入鎖。ReentrantLock
:java.util.concurrent.locks.ReentrantLock
類實現了Lock
介面,提供了顯式的可重入鎖功能,它允許更細粒度的控制,例如支持公平鎖、非公平鎖,以及可中斷鎖、限時鎖等。ReentrantReadWriteLock
:ReentrantReadWriteLock
是一種特殊的可重入鎖,它通過讀寫鎖的設計,既實現了可重入特性的線程安全,又能高效地處理讀多寫少的併發場景。
優點:
- 線程安全性:確保了在多線程環境下的數據一致性。
- 可重入性:簡化了代碼編寫,特別是在遞歸調用或嵌套同步塊的場景中。
- 靈活性:顯式可重入鎖(如ReentrantLock)提供了更多控制選項,如嘗試獲取鎖、設置鎖的公平性、中斷等待線程等。
缺點:
- 使用複雜性:相比於隱式鎖(synchronized),顯式鎖需要手動管理鎖的獲取和釋放,增加了編程複雜性和出錯概率。
- 性能開銷:在某些情況下,顯式鎖可能因為額外的API調用和狀態管理而帶來一定的性能開銷。
- 死鎖風險:如果開發者不謹慎地管理鎖的獲取和釋放順序,或者濫用鎖的特性,可能會導致死鎖的發生。尤其是對於顯式鎖,如果未正確釋放,可能會導致資源無法回收。
以下為可重入鎖使用示例:
public class ReentrantLockExample {
private final Lock lock = new ReentrantLock();
// 假設這是一個需要同步訪問的共用資源
private int sharedResource;
public void increment() {
// 獲取鎖
lock.lock();
try {
// 在鎖保護下執行操作
sharedResource++;
// 這裡假設有個內部方法也需要同步訪問sharedResource
doSomeOtherWork();
} finally {
// 無論發生什麼情況,最後都要釋放鎖
lock.unlock();
}
}
// 可重入的內部方法
private void doSomeOtherWork() {
// 因為當前線程已經持有鎖,所以可以再次獲取
lock.lock();
try {
// 執行依賴於sharedResource的操作
sharedResource -= 1;
System.out.println("Inner method executed with sharedResource: " + sharedResource);
} finally {
// 釋放鎖
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantLockExample example = new ReentrantLockExample();
Thread thread1 = new Thread(example::increment);
Thread thread2 = new Thread(example::increment);
thread1.start();
thread2.start();
// 等待兩個線程執行完畢
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 輸出最終的sharedResource值
System.out.println("Final sharedResource value: " + example.sharedResource);
}
}
示例中,increment()
方法和內部的doSomeOtherWork()
方法都需要在獲取鎖的情況下執行。由於ReentrantLock是可重入的,所以在increment()
方法內部調用doSomeOtherWork()
時,線程仍然可以成功獲取鎖,並繼續執行操作。當所有操作完成時,通過finally
塊確保了鎖的釋放。這樣可以避免死鎖,並確保在多線程環境下對共用資源的訪問是線程安全的。
不可重入鎖
不可重入鎖(Non-reentrant Lock)是一種線程同步機制,它的核心特征在於禁止同一個線程在已經持有鎖的前提下再度獲取相同的鎖。若一個線程已取得不可重入鎖,在其執行路徑中遇到需要再次獲取該鎖的場景時,該線程將會被迫等待,直至原先獲取的鎖被釋放,其他線程才有可能獲取並執行相關臨界區代碼。
此類鎖機制同樣服務於多線程環境下的資源共用保護,旨在確保同一時間內僅有單一線程能夠訪問臨界資源,從而有效規避數據不一致性和競態條件等問題。相較於可重入鎖,不可重入鎖在遞歸調用或涉及鎖嵌套的複雜同步場景下表現出局限性,因其可能導致線程阻塞和潛在的死鎖風險,降低了線程同步的靈活性和安全性。在實際開發中,除非有特殊的需求或場景約束,否則更建議採用可重入鎖以實現更為穩健高效的線程同步控制。
在Java標準庫中並沒有直接提供名為“不可重入鎖”的內置鎖,通常我們會通過對比ReentrantLock(可重入鎖)來理解不可重入鎖的概念。理論上,任何不具備可重入特性的鎖都可以認為是不可重入鎖。但在實際應用中,Java的synchronized
關鍵字修飾的方法或代碼塊在早期版本中曾經存在過類似不可重入的行為,但在目前Java的所有版本中,synchronized
關鍵字所實現的鎖實際上是可重入的。
優點:
- 簡單性:從實現角度來看,不可重入鎖可能在設計和實現上相對簡單,因為它不需要處理遞歸鎖定的複雜性。
缺點:
- 容易引發死鎖:如果在一個線程已持有不可重入鎖的情況下,它又試圖再次獲取同一把鎖,那麼就可能導致死鎖。因為線程自身無法進一步推進,也無法釋放已持有的鎖,其他線程也無法獲取鎖,從而形成死鎖狀態。
- 限制性較強:不可重入鎖極大地限制了線程的自由度,特別是在遞歸調用或含有嵌套鎖的複雜同步結構中,往往無法滿足需求。
- 線程棧跟蹤複雜:對於編程者而言,需要更加小心地管理鎖的層次結構,以防止無意間陷入死鎖或資源浪費的情況。
基於鎖的獲取公平性
按照獲取鎖的公平性,也即請求順序,將鎖分為公平鎖盒非公平鎖。
公平鎖
公平鎖是一種線程調度策略,在多線程環境下,當多個線程嘗試獲取鎖時,鎖的分配遵循“先請求先服務”(First-Come, First-Served, FCFS)原則,即按照線程請求鎖的順序來分配鎖資源。這意味著等待時間最長的線程將優先獲得鎖。公平鎖可以有效避免某個線程長期得不到鎖而導致的饑餓現象,所有線程都有平等獲取鎖的機會。它確保了線程的調度更加有序,減少了不公平競爭導致的不確定性。
公平鎖的實現,可以通過java.util.concurrent.locks.ReentrantLock
的構造函數傳入true
參數,可以創建一個公平的ReentrantLock
實例。
ReentrantLock fairLock = new ReentrantLock(true); //創建一個公平鎖
優點:
- 公平性:所有線程都遵循先來後到的原則,不會出現新來的線程總是搶占鎖的現象,提高了系統的公平性和穩定性。
- 避免線程饑餓:減少或消除了由於鎖的不公平分配而導致的線程長時間等待鎖的情況。
缺點:
- 性能開銷:公平鎖在每次釋放鎖後,都需要檢查是否有等待時間更長的線程,這通常涉及到線程調度的額外開銷,可能會降低系統的整體併發性能。
- 線程上下文切換頻繁:為了實現公平性,可能需要頻繁地進行線程上下文切換,而這本身就是一種相對昂貴的操作。
- 可能導致“convoy effect”:即大量線程因等待前麵線程釋放鎖而形成隊列,即使後來的線程只需要很短時間處理,也會不得不等待整個隊列中的線程依次完成,從而降低了系統的吞吐量。
以下使用公平鎖示例:
public class FairLockExample {
private final ReentrantLock fairLock = new ReentrantLock(true); // 使用true參數創建公平鎖
public void criticalSection() {
fairLock.lock(); // 獲取公平鎖
try {
// 在此區域內的代碼是臨界區,同一時間只有一個線程可以執行
System.out.println(Thread.currentThread().getName() + " entered the critical section at " + LocalDateTime.now());
// 模擬耗時操作
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
fairLock.unlock(); // 釋放公平鎖
}
}
public static void main(String[] args) {
final FairLockExample example = new FairLockExample();
Runnable task = () -> {
example.criticalSection();
};
// 創建並啟動多個線程
for (int i = 0; i < 10; i++) {
Thread t = new Thread(task);
t.start();
}
}
}
在這個示例中,我們創建一個公平鎖,我們創建了多個線程,每個線程都在執行criticalSection
方法,該方法內部的代碼塊受到公平鎖的保護,因此在任何時候只有一個線程能在臨界區內執行。當多個線程嘗試獲取鎖時,它們會按照請求鎖的順序來獲取鎖,確保線程調度的公平性。
非公平鎖
非公平鎖是一種線程調度策略,在多線程環境下,當多個線程嘗試獲取鎖時,鎖的分配不遵循“先請求先服務”(First-Come, First-Served, FCFS)原則,而是允許任何等待鎖的線程在鎖被釋放時嘗試獲取,即使其他線程已經在等待隊列中等待更長時間。非公平鎖在某些場景下可以提高系統的併發性能,因為它允許剛釋放鎖的線程或者其他新到達的線程立刻獲取鎖,而不是強制排隊等待。
實現方式也同公平鎖,也是通過java.util.concurrent.locks.ReentrantLock
的構造函數,但是我們要傳入false
參數,可以創建一個非公平的ReentrantLock
實例。
ReentrantLock fairLock = new ReentrantLock(false); //創建一個非公平鎖
優點:
- 性能優化:非公平鎖在某些條件下可能會提供更高的系統吞吐量,因為它允許線程更快地獲取鎖,減少線程上下文切換次數,尤其在鎖競爭不激烈的場景下,這種效果更為明顯。
缺點:
- 線程饑餓:非公平鎖可能導致某些線程長時間無法獲取鎖,即存線上程饑餓的風險,因為新到達的線程可能連續多次獲取鎖,而早前就已經在等待的線程始終得不到執行機會。
- 難以預測的線程調度:非公平鎖會導致線程調度的不確定性增大,不利於系統的穩定性和性能分析。
- 潛在的連鎖反應:非公平鎖可能導致線程之間的依賴關係變得複雜,可能會引發連鎖反應,影響整體系統的性能和穩定性。
基於對共用資源的訪問方式
我們常說或者常用的悲觀鎖以及樂觀鎖就是以對共用資源的訪問方式來區分的。
悲觀鎖
悲觀鎖(Pessimistic Lock)是一種併發控制策略,它假設在併發環境下,多個線程對共用資源的訪問極有可能發生衝突,因此在訪問資源之前,先嘗試獲取並鎖定資源,直到該線程完成對資源的訪問並釋放鎖,其他線程才能繼續訪問。悲觀鎖的主要作用是在多線程環境中防止數據被併發修改,確保數據的一致性和完整性。當一個線程獲取了悲觀鎖後,其他線程必須等到鎖釋放後才能訪問相應資源,從而避免了數據競態條件和臟讀等問題。悲觀鎖適合寫操作較多且讀操作較少的併發場景。
而悲觀鎖的實現可以通過synchronized
關鍵字實現的對象鎖或類鎖。或者通過java.util.concurrent.locks.Lock
介面的實現類,如ReentrantLock
。
悲觀鎖雖然在併發場景下數據的一致性和完整性。但是他卻有一些缺點,例如:
- 性能開銷:頻繁的加鎖和解鎖操作可能帶來較大的性能消耗,尤其是在高併發場景下,可能導致線程頻繁上下文切換。
- 可能導致死鎖:如果多個線程間的鎖獲取順序不當,容易造成死鎖。
- 資源利用率低:在讀多寫少的場景下,悲觀鎖可能導致大量的讀取操作等待,降低系統的併發能力和響應速度。
以下我們使用顯式鎖ReentrantLock
實現一個悲觀鎖的示例:
import java.util.concurrent.locks.ReentrantLock;
public class Account {
private final ReentrantLock lock = new ReentrantLock();
private double balance;
public void deposit(double amount) {
lock.lock();
try {
// 持有鎖進行存款操作
balance += amount;
// 更新賬戶餘額的其他邏輯...
} finally {
lock.unlock(); // 保證鎖一定會被釋放
}
}
public void withdraw(double amount) {
lock.lock();
try {
// 持有鎖進行取款操作
if (balance >= amount) {
balance -= amount;
// 更新賬戶餘額的其他邏輯...
}
} finally {
lock.unlock();
}
}
}
樂觀鎖
樂觀鎖並不是Java本身提供的某種內置鎖機制,而是指一種併發控制策略,它基於樂觀假設:即在併發訪問環境下,認為數據競爭不太可能發生,所以在讀取數據時並不會立即加鎖。樂觀鎖適用於讀多寫少的場景或者併發較少的場景。
Java中的樂觀鎖通過CAS(Compare and Swap / Compare and Set)
演算法實現,而資料庫層面我們常使用版本號或者時間戳等進行控制。
CAS(Compare and Swap / Compare and Set): Java提供了java.util.concurrent.atomic
包中的原子類,如AtomicInteger
、AtomicLong
等,它們通過CAS操作來實現樂觀鎖。CAS操作是一個原子指令,它只會修改數據,當且僅當該數據的當前值等於預期值時才進行修改。例如,AtomicInteger
中的compareAndSet
方法就是在樂觀鎖思想下實現的一種無鎖化更新操作。
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger counter = new AtomicInteger(0);
// 樂觀鎖更新示例
public void incrementCounter() {
while (true) {
int expected = counter.get();
int updated = expected + 1;
if (counter.compareAndSet(expected, updated)) {
// 更新成功,退出迴圈
break;
}
// 更新失敗,意味著有其他線程在此期間改變了值,繼續嘗試
}
}
優點:
- 更高的併發性能:因為在讀取階段不加鎖,所以理論上可以支持更多的併發讀取操作。
- 降低死鎖可能性:因為不存在長時間的加鎖過程,從而減少了死鎖的發生機會。
缺點:
- 衝突處理成本:如果併發更新較為頻繁,樂觀鎖會導致大量事務因併發衝突而重試甚至失敗,這在某些情況下反而會增加系統開銷。
- 迴圈依賴問題:在遇到連續的併發更新時,樂觀鎖可能導致事務不斷重試,形成“ABA”問題(即某個值被改回原值後再次更改)。
基於鎖的升級以及優化
在Java中,JVM為瞭解決多線程環境下的同步問題,對鎖機制進行了優化,將其分為偏向鎖、輕量級鎖和重量級鎖三種狀態。
偏向鎖
偏向鎖是一種Java虛擬機(JVM)在多線程環境下優化同步性能的鎖機制,它適用於大多數時間只有一個線程訪問同步代碼塊的場景。當一個線程訪問同步代碼塊時,JVM會把鎖偏向於這個線程,後續該線程在進入和退出同步代碼塊時,無需再做任何同步操作,從而大大降低了獲取鎖和釋放鎖的開銷。偏向鎖是Java記憶體模型中鎖的三種狀態之一,位於輕量級鎖和重量級鎖之前。
優點:
對於沒有或很少發生鎖競爭的場景,偏向鎖可以顯著減少鎖的獲取和釋放所帶來的性能損耗。
缺點:
-
額外存儲空間:偏向鎖會在對象頭中存儲一個偏向線程ID等相關信息,這部分額外的空間開銷雖然較小,但在大規模併發場景下,累積起來也可能成為可觀的成本。
-
鎖升級開銷:當一個偏向鎖的對象被其他線程訪問時,需要進行撤銷(revoke)操作,將偏向鎖升級為輕量級鎖,甚至在更高競爭情況下升級為重量級鎖。這個升級過程涉及到CAS操作以及可能的線程掛起和喚醒,會帶來一定的性能開銷。
-
適用場景有限:偏向鎖最適合於絕大部分時間只有一個線程訪問對象的場景,這樣的情況下,偏向鎖的開銷可以降到最低,有利於提高程式性能。但如果併發程度較高,或者線程切換頻繁,偏向鎖就可能不如輕量級鎖或重量級鎖高效。
輕量級鎖
輕量級鎖是一種在Java虛擬機(JVM)中實現的同步機制,主要用於提高多線程環境下鎖的性能。它不像傳統的重量級鎖那樣,每次獲取或釋放鎖都需要操作系統級別的互斥操作,而是儘量在用戶態完成鎖的獲取與釋放,避免了頻繁的線程阻塞和喚醒帶來的開銷。輕量級鎖的作用主要是減少線程上下文切換的開銷,通過自旋(spin-wait)的方式讓線程在一段時間內等待鎖的釋放,而不是立即掛起線程,這樣在鎖競爭不是很激烈的情況下,能夠快速獲得鎖,提高程式的響應速度和併發性能。
在Java中,輕量級鎖主要作為JVM鎖狀態的一種,它介於偏向鎖和重量級鎖之間。當JVM發現偏向鎖不再適用(即鎖的競爭不再局限於單個線程)時,會將鎖升級為輕量級鎖。
輕量級鎖適用於同步代碼塊執行速度快、線程持有鎖的時間較短且鎖競爭不激烈的場景,如短期內只有一個或少數幾個線程競爭同一線程資源的情況。
在Java中,輕量級鎖的具體實現體現在java.util.concurrent.locks
包中的Lock
介面的一個具體實現:java.util.concurrent.locks.ReentrantLock
,它支持可配置為公平或非公平模式的輕量級鎖機制,當使用預設構造函數時,預設是非公平鎖(類似於輕量級鎖的非公平性質)。不過,JVM的內置synchronized
關鍵字在JDK 1.6之後引入了鎖升級機制,也包含了偏向鎖和輕量級鎖的優化。
優點:
- 低開銷:輕量級鎖通過CAS操作嘗試獲取鎖,避免了重量級鎖中涉及的線程掛起和恢復等高昂開銷。
- 快速響應:在無鎖競爭或者鎖競爭不激烈的情況下,輕量級鎖使得線程可以迅速獲取鎖並執行同步代碼塊。
缺點:
- 自旋消耗:當鎖競爭激烈時,線程可能會長時間自旋等待鎖,這會消耗CPU資源,導致性能下降。
- 升級開銷:如果自旋等待超過一定閾值或者鎖競爭加劇,輕量級鎖會升級為重量級鎖,這個升級過程本身也有一定的開銷。
重量級鎖
重量級鎖是指在多線程編程中,為了保護共用資源而採取的一種較為傳統的互斥同步機制,通常涉及到操作系統的互斥量(Mutex)或者監視器鎖(Monitor)。在Java中,通過synchronized
關鍵字實現的鎖機制在預設情況下就是重量級鎖。確保任何時刻只有一個線程能夠訪問被鎖定的資源或代碼塊,防止數據競爭和不一致。保證了線程間的協同工作,確保在併發環境下執行的線程按照預定的順序或條件進行操作。
在Java中,重量級鎖主要指的是由synchronized
關鍵字實現的鎖,它在JVM內部由Monitor實現,屬於內建的鎖機制。另外,java.util.concurrent.locks
包下的ReentrantLock
等類也可實現重量級鎖,這些鎖可以根據需要調整為公平鎖或非公平鎖。
優點:
- 強一致性:重量級鎖提供了最強的線程安全性,確保在多線程環境下數據的完整性和一致性。
- 簡單易用:
synchronized
關鍵字的使用簡潔明瞭,不易出錯。
缺點:
- 性能開銷大:獲取和釋放重量級鎖時需要操作系統介入,可能涉及線程的掛起和喚醒,造成上下文切換,這對於頻繁鎖競爭的場景來說性能代價較高。
- 延遲較高:線程獲取不到鎖時會被阻塞,導致等待時間增加,進而影響系統響應速度。
重量級鎖適用於:
- 高併發且鎖競爭激烈的場景,因為在這種情況下,保證數據的正確性遠比微小的性能損失重要。
- 對於需要長時間持有鎖的操作,因為短暫的上下文切換成本相對於長時間的操作來說是可以接受的。
- 當同步代碼塊中涉及到IO操作、資料庫訪問等耗時較長的任務時,重量級鎖能夠較好地防止其它線程餓死。
在Java中,偏向鎖、輕量級鎖和重量級鎖之間的轉換是Java虛擬機(JVM)為了優化多線程同步性能而設計的一種動態調整機制。轉換條件如下:
-
偏向鎖到輕量級鎖的轉換:
當有第二個線程嘗試獲取已經被偏向的鎖時,偏向鎖就會失效並升級為輕量級鎖。這是因為偏向鎖假定的是只有一個線程反覆獲取鎖,如果有新的線程參與競爭,就需要進行鎖的升級以保證線程間的互斥。 -
輕量級鎖到重量級鎖的轉換:
當輕量級鎖嘗試獲取失敗(CAS操作失敗),即出現了鎖競爭時,JVM會認為當前鎖的持有者無法很快釋放鎖,因此為了避免後續線程無休止地自旋等待,會將輕量級鎖升級為重量級鎖。這個轉換過程通常發生在自旋嘗試獲取鎖達到一定次數(自旋次數是可配置的)或者系統處於高負載狀態時。 -
偏向鎖到重量級鎖的轉換:
如果當前線程不是偏向鎖指向的線程,那麼首先會撤銷偏向鎖(解除偏向狀態),然後升級為輕量級鎖,之後再根據輕量級鎖的規則判斷是否需要進一步升級為重量級鎖。
鎖狀態的轉換是為了在不同的併發環境下,既能保證數據的正確性,又能儘可能地提高系統性能。JVM會根據實際情況自動調整鎖的狀態,無需我們手動干預。
分段鎖
分段鎖(Segmented Lock 或 Partitions Lock)是一種將數據或資源劃分為多個段(segments),並對每個段分配單獨鎖的鎖機制。這樣做的目的是將鎖的粒度細化,以便在高併發場景下提高系統的併發性能和可擴展性,特別是針對大型數據結構如哈希表時非常有效。通過減少鎖的粒度,可以使得在多線程環境下,不同線程可以同時訪問不同段的數據,減小了鎖爭搶,提高了系統的並行處理能力。在大規模數據結構中,如果只有一個全局鎖,可能會因為熱點區域引發大量的鎖競爭,分段鎖則能有效地分散鎖的壓力。
Java中,分段鎖在實現上可以基於哈希表的分段鎖,例如Java中的ConcurrentHashMap
,將整個哈希表分割為多個段(Segment),每個段有自己的鎖,這樣多個線程可以同時對不同段進行操作。例外也可以基於數組或鏈表的分段鎖,根據數據索引將數據分佈到不同的段,每段對應一個獨立的鎖。
分段鎖可以提高併發性能,減少鎖競爭,增加系統的並行處理能力。其優點:
- 減小鎖的粒度:通過將一個大的鎖分解為多個小鎖,確實可以提高併發程度,降低鎖的粒度,減少單點瓶頸,提高系統性能。
- 減少鎖衝突:確實可以降低不同線程間對鎖資源的競爭,減少線程等待時間,從而提升併發度。
- 提高系統的可伸縮性:通過分段,可以更好地支持分散式和集群環境下的系統擴展,增強系統的併發處理能力和可擴展性。
分段鎖也有一些缺點:
- 增加了鎖的管理複雜度:確實需要額外的記憶體和複雜度來管理和維護多個鎖,確保鎖的正確使用和釋放,以及在不同分段間的一致性和可靠性。
- 可能導致線程饑餓:分段不合理或者熱點分段可能導致某些線程長時間等待鎖資源,出現線程饑餓問題。
- 可能會降低併發度:如果分段策略設計不當,可能會增加鎖競爭,降低併發性能。設計合理的分段策略和鎖協調機制對於分段鎖的效能至關重要,同時也增加了開發和維護的複雜度。
- 記憶體占用:每個分段所需的鎖信息和相關數據會占用額外的記憶體空間,對系統記憶體有一定的消耗。
分段鎖適用於大數據結構的併發訪問,如高併發環境下對哈希表的操作。以及分散式系統中,某些分散式緩存或資料庫系統也採用類似的分片鎖策略來提高併發性能。
自旋鎖
自旋鎖(Spin Lock)是一種簡單的鎖機制,用於多線程環境中的同步控制,它的工作原理是當一個線程試圖獲取已經被另一個線程持有的鎖時,該線程不會立即進入睡眠狀態(阻塞),而是不斷地迴圈檢查鎖是否已經被釋放,直到獲取到鎖為止。這種“迴圈等待”的行為被稱為“自旋”。自旋鎖主要用於保證同一時刻只有一個線程訪問臨界區資源,防止數據競爭。相比傳統阻塞式鎖,自旋鎖在持有鎖的線程很快釋放鎖的情況下,可以減少線程的上下文切換開銷。
我們使用AtomicInteger
實現一個簡單的自旋鎖:
import java.util.concurrent.atomic.AtomicInteger;
class SimpleSpinLock {
private AtomicInteger locked = new AtomicInteger(0);
public void lock() {
while (locked.getAndSet(1) == 1) {
// 自旋等待
}
// 已經獲取鎖,執行臨界區代碼
}
public void unlock() {
locked.set(0);
}
}
自旋鎖優點:
- 對於持有鎖時間很短的場景,自旋鎖能有效減少線程上下文切換,提高系統性能。
- 自旋鎖適用於多處理器或多核心系統,因為在這種環境下,線程可以在等待鎖釋放時繼續占用CPU時間。
自旋鎖缺點:
- 如果持有鎖的線程需要很長時間才能釋放鎖,自旋鎖會導致等待鎖的線程持續消耗CPU資源,浪費CPU周期。
- 在單處理器系統中,自旋鎖的效率不高,因為等待鎖的線程無法執行任何有用的工作,只是空轉。
死鎖
說到各種鎖,就會想到死鎖問題,對於死鎖有興趣的可以參考這篇文章:
這裡就不過多贅述。
總結
本文介紹了多種Java中的鎖機制,包括可重入鎖(Reentrant Lock)、公平鎖、非公平鎖、悲觀鎖、樂觀鎖、偏向鎖、輕量級鎖、重量級鎖、分段鎖以及自旋鎖。這些鎖各有優缺點和適用場景,如可重入鎖支持遞歸鎖定,悲觀鎖確保數據一致性但可能引起性能開銷,樂觀鎖在讀多寫少場景下表現優異,偏向鎖和輕量級鎖用於優化單線程重覆訪問,重量級鎖提供嚴格的互斥性,分段鎖通過減小鎖粒度提高併發性能,而自旋鎖則在短時間內獲取鎖的場景中能減少線程上下文切換。根據不同的併發需求和性能考量,開發者可以選擇合適的鎖機制。
本文已收錄於我的個人博客:碼農Academy的博客,專註分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中間件、架構設計、面試題、程式員攻略等