這篇文章分為六個部分,不同特性的鎖分類,並發鎖的不同設計,Synchronized中的鎖升級,ReentrantLock和ReadWriteLock的應用,幫助你梳理 Java 並發鎖及相關的操作。
一、鎖有哪些分類
一般我們提到的鎖有以下這些:
- 樂觀鎖/悲觀鎖
- 公平鎖/非公平鎖
- 可重入鎖
- 獨享鎖/共享鎖
- 互斥鎖/讀寫鎖
- 分段鎖
- 偏向鎖/輕量級鎖/重量級鎖
- 自旋鎖
上面是很多鎖的名詞,這些分類並不是全是指鎖的狀態,有的指鎖的特性,有的指鎖的設計,下面分別說明。
1、樂觀鎖 VS 悲觀鎖
樂觀鎖與悲觀鎖是一種廣義上的概念,體現了看待線程同步的不同角度,在Java和數據庫中都有此概念對應的實際應用。
(1)樂觀鎖
顧名思義,就是很樂觀,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。
樂觀鎖適用於多讀的應用類型,樂觀鎖在Java中是通過使用無鎖編程來實現,最常采用的是CAS算法,Java原子類中的遞增操作就通過CAS自旋實現的。
CAS全稱 Compare And Swap(比較與交換),是一種無鎖算法。在不使用鎖(沒有線程被阻塞)的情況下實現多線程之間的變量同步。java.util.concurrent包中的原子類就是通過CAS來實現了樂觀鎖。
簡單來說,CAS算法有3個三個操作數:
- 需要讀寫的內存值 V。
- 進行比較的值 A。
- 要寫入的新值 B。
當且僅當預期值A和內存值V相同時,將內存值V修改為B,否則返回V。這是一種樂觀鎖的思路,它相信在它修改之前,沒有其它線程去修改它;而Synchronized是一種悲觀鎖,它認為在它修改之前,一定會有其它線程去修改它,悲觀鎖效率很低。
(2)悲觀鎖
總是假設最壞的情況,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖。
傳統的MySQL關系型數據庫里邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。
- 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時數據正確。
- 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的性能大幅提升。
2、公平鎖 VS 非公平鎖
(1)公平鎖
就是很公平,在並發環境中,每個線程在獲取鎖時會先查看此鎖維護的等待隊列,如果為空,或者當前線程是等待隊列的第一個,就占有鎖,否則就會加入到等待隊列中,以后會按照FIFO的規則從隊列中取到自己。
公平鎖的優點是等待鎖的線程不會餓死。缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。
(2)非公平鎖
上來就直接嘗試占有鎖,如果嘗試失敗,就再采用類似公平鎖那種方式。
非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因為線程有幾率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點是處於等待隊列中的線程可能會餓死,或者等很久才會獲得鎖。
(3)典型應用
java jdk並發包中的ReentrantLock可以指定構造函數的boolean類型來創建公平鎖和非公平鎖(默認),比如:公平鎖可以使用new ReentrantLock(true)實現。
3、獨享鎖 VS 共享鎖
(1)獨享鎖
是指該鎖一次只能被一個線程所持有。
(2)共享鎖
是指該鎖可被多個線程所持有。
對於Java ReentrantLock而言,其是獨享鎖。但是對於Lock的另一個實現類ReadWriteLock,其讀鎖是共享鎖,其寫鎖是獨享鎖。
- 讀鎖的共享鎖可保證並發讀是非常高效的,讀寫,寫讀 ,寫寫的過程是互斥的。
- 獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
(3)AQS
抽象隊列同步器(AbstractQueuedSynchronizer,簡稱AQS)是用來構建鎖或者其他同步組件的基礎框架,它使用一個整型的volatile變量(命名為state)來維護同步狀態,通過內置的FIFO隊列來完成資源獲取線程的排隊工作。
concurrent包的實現結構如上圖所示,AQS、非阻塞數據結構和原子變量類等基礎類都是基於volatile變量的讀/寫和CAS實現,而像Lock、同步器、阻塞隊列、Executor和並發容器等高層類又是基於基礎類實現。
4、互斥鎖 VS 讀寫鎖
相交進程之間的關系主要有兩種,同步與互斥。所謂互斥,是指散布在不同進程之間的若干程序片斷,當某個進程運行其中一個程序片段時,其它進程就不能運行它們之中的任一程序片段,只能等到該進程運行完這個程序片段后才可以運行。所謂同步,是指散布在不同進程之間的若干程序片斷,它們的運行必須嚴格按照規定的某種先后次序來運行,這種先后次序依賴於要完成的特定的任務。
顯然,同步是一種更為復雜的互斥,而互斥是一種特殊的同步。
也就是說互斥是兩個線程之間不可以同時運行,他們會相互排斥,必須等待一個線程運行完畢,另一個才能運行,而同步也是不能同時運行,但他是必須要安照某種次序來運行相應的線程(也是一種互斥)!
總結:互斥:是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。
同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的。少數情況是指可以允許多個訪問者同時訪問資源。
(1)互斥鎖
在訪問共享資源之前對進行加鎖操作,在訪問完成之后進行解鎖操作。 加鎖后,任何其他試圖再次加鎖的線程會被阻塞,直到當前進程解鎖。
如果解鎖時有一個以上的線程阻塞,那么所有該鎖上的線程都被編程就緒狀態, 第一個變為就緒狀態的線程又執行加鎖操作,那么其他的線程又會進入等待。 在這種方式下,只有一個線程能夠訪問被互斥鎖保護的資源
(2)讀寫鎖
這個時候讀寫鎖就應運而生了,讀寫鎖是一種通用技術,並不是Java特有的。
讀寫鎖特點:
- 多個讀者可以同時進行讀
- 寫者必須互斥(只允許一個寫者寫,也不能讀者寫者同時進行)
- 寫者優先於讀者(一旦有寫者,則后續讀者必須等待,喚醒時優先考慮寫者)
互斥鎖特點:
- 一次只能一個線程擁有互斥鎖,其他線程只有等待
(3)Linux的讀寫鎖
Linux內核也支持讀寫鎖。
互斥鎖
pthread_mutex_init()
pthread_mutex_lock()
pthread_mutex_unlock()
讀寫鎖
pthread_rwlock_init()
pthread_rwlock_rdlock()
pthread_rwlock_wrlock()
pthread_rwlock_unlock()
條件變量
pthread_cond_init()
pthread_cond_wait()
pthread_cond_signal()
5、自旋鎖
自旋鎖(spinlock):是指當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那么該線程將循環等待,然后不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出循環。
在Java中,自旋鎖是指嘗試獲取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試獲取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。
典型的自旋鎖實現的例子,可以參考自旋鎖的實現
它是為實現保護共享資源而提出一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是為了解決對某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多只能有一個保持者,也就說,在任何時刻最多只能有一個執行單元獲得鎖。但是兩者在調度機制上略有不同。對於互斥鎖,如果資源已經被占用,資源申請者只能進入睡眠狀態。
但是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那里看是否該自旋鎖的保持者已經釋放了鎖,”自旋”一詞就是因此而得名。
(1)Java如何實現自旋鎖?
下面是個簡單的例子:
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
lock()方法利用的CAS,當第一個線程A獲取鎖的時候,能夠成功獲取到,不會進入while循環,如果此時線程A沒有釋放鎖,另一個線程B又來獲取鎖,此時由於不滿足CAS,所以就會進入while循環,不斷判斷是否滿足CAS,直到A線程調用unlock方法釋放了該鎖。
(2)自旋鎖存在的問題
- 如果某個線程持有鎖的時間過長,就會導致其它等待獲取鎖的線程進入循環等待,消耗CPU。使用不當會造成CPU使用率極高。
- 上面Java實現的自旋鎖不是公平的,即無法滿足等待時間最長的線程優先獲取鎖。不公平的鎖就會存在“線程飢餓”問題。
(3)自旋鎖的優點
- 自旋鎖不會使線程狀態發生切換,一直處於用戶態,即線程一直都是active的;不會使線程進入阻塞狀態,減少了不必要的上下文切換,執行速度快
- 非自旋鎖在獲取不到鎖的時候會進入阻塞狀態,從而進入內核態,當獲取到鎖的時候需要從內核態恢復,需要線程上下文切換。 (線程被阻塞后便進入內核(Linux)調度狀態,這個會導致系統在用戶態與內核態之間來回切換,嚴重影響鎖的性能)
二、並發鎖的不同設計方式
根據所鎖的設計方式和應用,有分段鎖,讀寫鎖等。
1、分段鎖技術,並發鎖的一種設計方案
分段鎖其實是一種鎖的設計,並不是具體的一種鎖,對於ConcurrentHashMap而言,其並發的實現就是通過分段鎖的形式來實現高效的並發操作。
以ConcurrentHashMap來說一下分段鎖的含義以及設計思想,ConcurrentHashMap中的分段鎖稱為Segment,它即類似於HashMap(JDK7與JDK8中HashMap的實現)的結構,即內部擁有一個Entry數組,數組中的每個元素又是一個鏈表;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。
當需要put元素的時候,並不是對整個hashmap進行加鎖,而是先通過hashcode來知道他要放在那一個分段中,然后對這個分段進行加鎖,所以當多線程put的時候,只要不是放在一個分段中,就實現了真正的並行的插入。
但是,在統計size的時候,可就是獲取hashmap全局信息的時候,就需要獲取所有的分段鎖才能統計。
分段鎖的設計目的是細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。
2、鎖消除和鎖膨脹(粗化)
鎖消除,如無必要,不要使用鎖。Java 虛擬機也可以根據逃逸分析判斷出加鎖的代碼是否線程安全,如果確認線程安全虛擬機會進行鎖消除提高效率。
鎖粗化。如果一段代碼需要使用多個鎖,建議使用一把范圍更大的鎖來提高執行效率。Java 虛擬機也會進行優化,如果發現同一個對象鎖有一系列的加鎖解鎖操作,虛擬機會進行鎖粗化來降低鎖的耗時。
3、輪詢鎖與定時鎖
輪詢鎖是通過線程不斷嘗試獲取鎖來實現的,可以避免發生死鎖,可以更好地處理錯誤場景。Java 中可以通過調用鎖的 tryLock 方法來進行輪詢。tryLock 方法還提供了一種支持定時的實現,可以通過參數指定獲取鎖的等待時間。如果可以立即獲取鎖那就立即返回,否則等待一段時間后返回。
4、讀寫鎖
讀寫鎖 ReadWriteLock 可以優雅地實現對資源的訪問控制,具體實現為 ReentrantReadWriteLock。讀寫鎖提供了讀鎖和寫鎖兩把鎖,在讀數據時使用讀鎖,在寫數據時使用寫鎖。
讀寫鎖允許有多個讀操作同時進行,但只允許有一個寫操作執行。如果寫鎖沒有加鎖,則讀鎖不會阻塞,否則需要等待寫入完成。
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
三、synchronized中的鎖
synchronized 代碼塊是由一對兒 monitorenter/monitorexit 指令實現的,Monitor 對象是同步的基本實現單元。
在 Java 6 之前,Monitor 的實現完全是依靠操作系統內部的互斥鎖,因為需要進行用戶態到內核態的切換,所以同步操作是一個無差別的重量級操作。
現代的(Oracle)JDK 中,JVM 對此進行了大刀闊斧地改進,提供了三種不同的 Monitor 實現,也就是常說的三種不同的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其性能。
1、synchronized中鎖的狀態
鎖的狀態是通過對象監視器在對象頭中的字段來表明的。
四種狀態會隨着競爭的情況逐漸升級,而且是不可逆的過程,即不可降級。
這四種狀態都不是Java語言中的鎖,而是Jvm為了提高鎖的獲取與釋放效率而做的優化(使用synchronized時)。
- 無鎖狀態
- 偏向鎖狀態
- 輕量級鎖狀態
- 重量級鎖狀態
2、偏向鎖、輕量級鎖、重量級鎖
這三種鎖是指鎖的狀態,並且是針對Synchronized。在Java 5通過引入鎖升級的機制來實現高效Synchronized。這三種鎖的狀態是通過對象監視器在對象頭中的字段來表明的。
偏向鎖是指一段同步代碼一直被一個線程所訪問,那么該線程會自動獲取鎖。降低獲取鎖的代價。
輕量級鎖是指當鎖是偏向鎖的時候,被另一個線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,提高性能。
重量級鎖是指當鎖為輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低。
3、synchronized的鎖升級
所謂鎖的升級、降級,就是 JVM 優化 synchronized 運行的機制,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。
當沒有競爭出現時,默認會使用偏斜鎖。JVM 會利用 CAS 操作(compare and swap),在對象頭上的 Mark Word 部分設置線程 ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖。這樣做的假設是基於在很多應用場景中,大部分對象生命周期中最多會被一個線程鎖定,使用偏斜鎖可以降低無競爭開銷。
如果有另外的線程試圖鎖定某個已經被偏斜過的對象,JVM 就需要撤銷(revoke)偏斜鎖,並切換到輕量級鎖實現。輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級為重量級鎖。
四、看下ReentrantLock
ReentrantLock,一個可重入的互斥鎖,它具有與使用synchronized方法和語句所訪問的隱式監視器鎖相同的一些基本行為和語義,但功能更強大。
1、基本用法
public class LockTest {
private Lock lock = new ReentrantLock();
public void testMethod() {
lock.lock();
for (int i = 0; i < 5; i++) {
System.out.println("ThreadName=" + Thread.currentThread().getName()
+ (" " + (i + 1)));
}
lock.unlock();
}
}
2、Condition應用
synchronized與wait()和nitofy()/notifyAll()方法相結合可以實現等待/通知模型,ReentrantLock同樣可以,但是需要借助Condition,且Condition有更好的靈活性,具體體現在:
- 一個Lock里面可以創建多個Condition實例,實現多路通知
- notify()方法進行通知時,被通知的線程時Java虛擬機隨機選擇的,但是ReentrantLock結合Condition可以實現有選擇性地通知,這是非常重要的
3、Condition類和Object類
- Condition類的awiat方法和Object類的wait方法等效
- Condition類的signal方法和Object類的notify方法等效
- Condition類的signalAll方法和Object類的notifyAll方法等效
五、再看下ReadWriteLock
在並發場景中用於解決線程安全的問題,我們幾乎會高頻率的使用到獨占式鎖,通常使用java提供的關鍵字synchronized(關於synchronized可以看這篇文章)或者concurrents包中實現了Lock接口的ReentrantLock。
它們都是獨占式獲取鎖,也就是在同一時刻只有一個線程能夠獲取鎖。而在一些業務場景中,大部分只是讀數據,寫數據很少,如果僅僅是讀數據的話並不會影響數據正確性(出現臟讀),而如果在這種業務場景下,依然使用獨占鎖的話,很顯然這將是出現性能瓶頸的地方。
針對這種讀多寫少的情況,java還提供了另外一個實現Lock接口的ReentrantReadWriteLock(讀寫鎖)。讀寫所允許同一時刻被多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他的寫線程都會被阻塞。
1、ReadWriteLock接口
ReadWriteLock,顧明思義,讀寫鎖在讀的時候,上讀鎖,在寫的時候,上寫鎖,這樣就很巧妙的解決synchronized的一個性能問題:讀與讀之間互斥。
ReadWriteLock也是一個接口,原型如下:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
該接口只有兩個方法,讀鎖和寫鎖。
也就是說,我們在寫文件的時候,可以將讀和寫分開,分成2個鎖來分配給線程,從而可以做到讀和讀互不影響,讀和寫互斥,寫和寫互斥,提高讀寫文件的效率。
2、ReentrantReadWriteLock應用
下面的實例參考《Java並發編程的藝術》,使用讀寫鎖實現一個緩存。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache {
static Map<String,Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
static Lock readLock = readWriteLock.readLock();
static Lock writeLock = readWriteLock.writeLock();
public static final Object getByKey(String key){
readLock.lock();
try{
return map.get(key);
}finally{
readLock.unlock();
}
}
public static final Object getMap(){
readLock.lock();
try{
return map;
}finally{
readLock.unlock();
}
}
public static final Object put(String key,Object value){
writeLock.lock();
try{
return map.put(key, value);
}finally{
writeLock.unlock();
}
}
public static final Object remove(String key){
writeLock.lock();
try{
return map.remove(key);
}finally{
writeLock.unlock();
}
}
public static final void clear(){
writeLock.lock();
try{
map.clear();
}finally{
writeLock.unlock();
}
}
public static void main(String[] args) {
List<Thread> threadList = new ArrayList<Thread>();
for(int i =0;i<6;i++){
Thread thread = new PutThread();
threadList.add(thread);
}
for(Thread thread : threadList){
thread.start();
}
put("ji","ji");
System.out.println(getMap());
}
private static class PutThread extends Thread{
public void run(){
put(Thread.currentThread().getName(),Thread.currentThread().getName());
}
}
}
3、讀寫鎖的鎖降級
讀寫鎖支持鎖降級,遵循按照獲取寫鎖,獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成為讀鎖,不支持鎖升級,關於鎖降級下面的示例代碼摘自ReentrantWriteReadLock源碼中:
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}