目錄
面試的時候,一般大型互聯網或者數據公司都對併發很重視,但是爲了保證線程安全,就需要鎖來控制同一時刻一個線程訪問資源。鎖的知識也就是面試必問的知識了。這一章可能比較長,因爲咱們着重介紹下各種鎖。其實吧Synchronized和Volatile稱作鎖也沒關係因爲很多面試官都把他們也當成鎖去提問。我們說的鎖現在就只在jdk1.8的基礎上面來說了。
從代碼層級來說。鎖總體可以分爲2類:
1、Synchronized JVM內部實現(cpp)通過操作系統指令實現,屬於內核態。
2、Lock 在JUC包下面,基於jdk實現的,有源碼可以閱讀。沒有內核態。
總體說性能差不多,沒有好壞之分。
這2類在工作中如何選取呢?
個人見解:
1、Synchronized 簡單,方便,加個關鍵字就行了
2、Lock 需要手動釋放鎖,有一部分高級功能。
也就是說:Synchronized的功能,Lock都有,
Lock的功能,Synchronized有一部分沒有。
但是一般人工作中Synchronized全能滿足,當Synchronized滿足不了的時候再使用Lock。比如使用公平鎖,sync就不行了。
在網上瀏覽的時候發現了一張腦圖,雖然感覺總結的不是很到位,但是整體把各種鎖都涵蓋了。我們這節可以按照這幅圖來展開。
一、悲觀鎖 VS 樂觀鎖
樂觀鎖和悲觀鎖是一種廣義上的概念。對線程不同角度的描述,數據庫也有這2個概念。
悲觀鎖認爲在自己使用數據的時候一定會有別的線程來修改數據。因此在獲取數據的時候會先加鎖,確保數據不會被別的線程修改。Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖。
樂觀鎖正好相反認爲在自己使用數據的時候不會有別的線程來修改數據。所以不會添加鎖。只是在更新數據的時候去判斷之前有沒有別的線程更新了這個數據。如果這個數據沒有被更新,當前線程將自己修改的數據成功寫入。如果數據已經被其他線程更新,則根據不同的實現方式執行不同的操作(例如報錯或者自動重試)。樂觀鎖在Java中是通過使用無鎖編程來實現,最常採用的是CAS算法,Java原子類中的遞增操作就通過CAS自旋實現的。
- 悲觀鎖適合 寫多讀少 場景,先加鎖,可以保證寫操作數據正確。
- 樂觀鎖適合 讀多寫少 場景,不加鎖能夠使讀操作的性能大幅提升。
使用方式:
---------------------- 悲觀鎖 -------------------
//synchronized
public synchronized void methed(){
xxxxxxxxxx
}
//ReentrantLock,需要保證多個線程用一個lock不要new 多個lock;
private ReentrantLock lock = new ReentrantLock();
public void lockmethod(){
lock.lock();
xxxxxxx
lock.unlock();
}
-------------------- 樂觀鎖 ----------------------------
//保證多線程用同一個AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
二、自旋鎖 VS 適應性自旋鎖
2.1、自旋鎖
阻塞或喚醒一個Java線程需要操作系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步代碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。
在許多場景中,同步資源的鎖定時間很短,爲了這一小段時間去切換線程,線程掛起和恢復現場的花費可能會讓系統得不償失。如果物理機器有多個處理器,能夠讓兩個或以上的線程同時並行執行,我們就可以讓後面那個請求鎖的線程不放棄CPU的執行時間,看看持有鎖的線程是否很快就會釋放鎖。
而爲了讓當前線程“稍等一下”,我們需讓當前線程進行自旋,比如執行幾個空循環,如果在自旋完成後前面鎖定同步資源的線程已經釋放了鎖,那麼當前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷。這就是自旋鎖。
自旋鎖本身是有缺點的,它不能代替阻塞。自旋等待雖然避免了線程切換的開銷,但它要佔用處理器時間。如果鎖被佔用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被佔用的時間很長,那麼自旋的線程只會白浪費處理器資源。所以,自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(默認是10次,可以使用-XX:PreBlockSpin來更改)沒有成功獲得鎖,就應當掛起線程。
自旋鎖的實現原理同樣也是CAS,AtomicInteger中調用unsafe進行自增操作的源碼中的do-while循環就是一個自旋操作,如果修改數值失敗則通過循環來執行自旋,直至修改成功。
JDK 6中變爲默認開啓自旋鎖,並且引入了自適應的自旋鎖(適應性自旋鎖)。
2.2、自適應鎖
自適應意味着自旋的時間(次數)不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。
自適應鎖用到的很少,大家瞭解這個東西就行了。不用深究。
三、無鎖 VS 偏向鎖 VS 輕量級鎖 VS 重量級鎖
在synchronized 一節中介紹過這四種鎖狀態。這四種鎖是指鎖的狀態,專門針對synchronized的。
3.1、無鎖
無鎖就是不對資源進行鎖定。所有的線程都能訪問並修改同一個資源,但同時只有一個線程能修改成功。同一時刻大家應該理解的就是同時修改那一瞬間,當然多線程操作 會被修改好多次。
無鎖的特點就是修改操作在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有衝突就修改成功並退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。上面我們介紹的CAS原理及應用即是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。
3.2、偏向鎖
偏向鎖顧名思義就是偏向於第一次獲取它的線程。所以會能夠被一個線程不斷佔有。大多數情況下,鎖總是由同一個線程多次獲得,不存在競爭,所以叫偏向。其目標就是在只有一個線程執行同步代碼塊時能夠提高性能。
當一個線程訪問同步代碼塊並獲取鎖時,會在Mark Word裏存儲鎖偏向的線程ID。在線程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裏是否存儲着指向當前線程的偏向鎖。引入偏向鎖是爲了在無多線程競爭的情況下儘量減少不必要的輕量級鎖執行,因爲輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。
在運行過程中,當其他的線程搶佔鎖的時候,持有偏向鎖的線程會被掛起,JVM會消除它身上的偏向鎖,將鎖升級到輕量級鎖。可以使用CAS算法去升級偏向鎖,偏向鎖與無鎖狀態的執行時間很接近,所以競爭不激烈的情況可以使用偏向鎖。
3.3、輕量級鎖
線程有交替使用,互斥性不是很強的時候可以去使用它,CAS檢測失敗就將鎖標記00。
當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。
Markword 存儲對象的hashCode或鎖信息(這裏把Synchronized那一節的知識挪過來下)
下面簡單說下如何升級成輕量級鎖,不過大家不用太過度研究,瞭解下就行了:
在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0” 因爲偏向鎖和無鎖的鎖標誌位一樣,靠是否偏向標誌位區分),1)建空間。虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,2)拷貝。然後拷貝對象頭中的Mark Word複製到鎖記錄中。
拷貝成功後,3)更改指向。虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock Record裏的owner指針指向對象的Mark Word。
如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,表示此對象處於輕量級鎖定狀態。
如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。
若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級爲重量級鎖。
3.4、重量級鎖
升級爲重量級鎖時,鎖標誌的狀態值變爲“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態。
整體的鎖狀態升級流程如下:
綜上
偏向鎖通過對比Mark Word解決加鎖(會記錄線程id)問題,避免執行CAS操作。
輕量級鎖是通過用CAS操作和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能。
重量級鎖是將除了擁有鎖的線程以外的線程都阻塞。
因爲重量級鎖調用了操作系統的函數,而偏量鎖沒有。
四、公平鎖 VS 非公平鎖
4.1、公平鎖
公平鎖是指多個線程按照申請鎖的順序來獲取鎖,線程直接進入隊列中排隊,隊列中的第一個線程才能獲得鎖。類似FIFO。公平鎖的優點是等待鎖的線程不會餓死。缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。爲什麼呢?
公平鎖lock源碼:
// 公平鎖FairSync
final void lock() {
acquire(1);
}
非公平鎖: 比公平鎖多了一個CAS獲取鎖的步驟。如果失敗在acquire
// 非公平鎖NonfairSync
final void lock() {
// 在調用acquire()方法獲取鎖之前,先CAS搶鎖
if (compareAndSetState(0, 1)) // state=0時,CAS設置state=1
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
釋放公平鎖的過程大體描述如下:
1)AQS.release()方法:state 改爲 0,exclusiveOwnerThread 設置爲 null
2)喚醒 AQS 隊列中 head 的後繼結點線程去獲取鎖
如果在線程 2 在線程 1 釋放鎖的過程中調用 lock()方法獲取鎖,
對於公平鎖:線程 2 只能先加入同步隊列的隊尾,等隊列中在它之前的線程獲取、釋放鎖之後纔有機會去搶鎖。這也就保證了公平,先到先得。這樣自己必須先要掛起,等到自己的時候才被喚醒。
對於非公平鎖:線程 1 釋放鎖過程執行到一半,“state 改爲 0,exclusiveOwnerThread 設置爲 null”已經完成,此時線程 2 調用 lock(),那麼 CAS 就搶鎖成功。這種情況下線程 2 是可以先獲取非公平鎖而不需要進入隊列中排隊的,也就不用被掛起和等待喚醒了。對前面的線程不公平。
從代碼角度看公平鎖和非公平鎖區別大體有2處:
非公平鎖和公平鎖只有兩處不同:
1)lock()方法: 公平鎖直接調用 acquire(),當前線程到同步隊列中排隊等鎖。 非公平鎖會先利用 CAS 搶鎖,搶不到鎖纔會調用 acquire()。
2) tryAcquire()方法: 公平鎖在同步隊列還有線程等鎖時,即使鎖沒有被佔用,也不能獲取鎖。非公平鎖不管同步隊列中是什麼情況,直接去搶鎖。
4.2 非公平鎖
非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到纔會到等待隊列的隊尾等待。但如果此時鎖剛好可用,那麼這個線程可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現後申請鎖的線程先獲取鎖的場景。非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因爲線程有機率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點是處於等待隊列中的線程可能會餓死,或者等很久纔會獲得鎖。
ReentrantLock默認狀態下是非公平鎖(1.8jdk)。但是可以設置變成爲公平鎖。所以ReentrantLock很具有研究性。我們從源碼角度來分析下。
public ReentrantLock() {
sync = new NonfairSync();
}
根據代碼可知,ReentrantLock裏面有一個內部類Sync,Sync繼承AQS(AbstractQueuedSynchronizer),添加鎖和釋放鎖的大部分操作實際上都是在Sync中實現的。它有公平鎖FairSync和非公平鎖NonfairSync兩個子類。ReentrantLock默認使用非公平鎖,也可以通過構造器來顯示的指定使用公平鎖。
通過上圖中的源代碼對比,我們可以明顯的看出公平鎖與非公平鎖的lock()方法唯一的區別就在於公平鎖在獲取同步狀態時多了一個限制條件:hasQueuedPredecessors()。
再進入hasQueuedPredecessors(),可以看到該方法主要做一件事情:hasQueuedPredecessors()方法是判斷 AQS 隊列中是否還有結點,如果隊列中沒有結點返回 false。
綜上,公平鎖就是通過同步隊列來實現多個線程按照申請鎖的順序來獲取鎖,從而實現公平的特性。非公平鎖加鎖時不考慮排隊等待問題,直接嘗試獲取鎖,所以存在後申請的卻先獲得鎖的情況。
五、可重入鎖 VS 非重入鎖
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會因爲之前已經獲取過還沒釋放而阻塞。Java中ReentrantLock和synchronized都是可重入鎖,可重入鎖的一個優點是可一定程度避免死鎖。下面用示例代碼來進行分析:
public synchronized void method1(){
System.out.println("method1");
method2();
}
public synchronized void method2(){
System.out.println("method2");
}
在上面的代碼中,類中的兩個方法都是被內置鎖synchronized修飾的,method1()方法中調用method2()方法。因爲內置鎖是可重入的,所以同一個線程在調用method2()時可以直接獲得當前對象的鎖,進入method2()進行操作。
如果是一個不可重入鎖,那麼當前線程在調用method2()之前需要將執行method1()時獲取當前對象的鎖釋放掉,實際上該對象鎖已被當前線程所持有,且無法釋放。所以此時會出現死鎖。
什麼可重入鎖就可以在嵌套調用時可以自動獲得鎖呢?我們通過源碼來分別解析一下。
說道重入有想到了ReentrantLock。
ReentrantLock和synchronized都是重入鎖,那麼我們通過重入鎖ReentrantLock以及非可重入鎖NonReentrantLock的源碼來對比分析一下爲什麼非可重入鎖在重複調用同步資源時會出現死鎖。
首先ReentrantLock和NonReentrantLock都繼承父類AQS,其父類AQS中維護了一個同步狀態status來計數重入次數,status初始值爲0。
當線程嘗試獲取鎖時,可重入鎖先嚐試獲取並更新status值,如果status == 0表示沒有其他線程在執行同步代碼,則把status置爲1,當前線程開始執行。如果status != 0,則判斷當前線程是否是獲取到這個鎖的線程,如果是的話執行status+1,且當前線程可以再次獲取鎖。而非可重入鎖是直接去獲取並嘗試更新當前status的值,如果status != 0的話會導致其獲取鎖失敗,當前線程阻塞。
釋放鎖時,可重入鎖同樣先獲取當前status的值,在當前線程是持有鎖的線程的前提下。如果status-1 == 0,則表示當前線程所有重複獲取鎖的操作都已經執行完畢,然後該線程纔會真正釋放鎖。而非可重入鎖則是在確定當前線程是持有鎖的線程之後,直接將status置爲0,將鎖釋放。
六、獨享鎖 VS 共享鎖
獨享鎖和共享鎖同樣是一種概念。我們先介紹一下具體的概念,然後通過ReentrantLock和ReentrantReadWriteLock(讀寫鎖)的源碼來介紹獨享鎖和共享鎖。
獨享鎖也叫排他鎖,是指該鎖一次只能被一個線程所持有。如果線程T對數據A加上排它鎖後,則其他線程不能再對A加任何類型的鎖。獲得排它鎖的線程即能讀數據又能修改數據。JDK中的synchronized和JUC中Lock的實現類就是互斥鎖。
共享鎖是指該鎖可被多個線程所持有。如果線程T對數據A加上共享鎖後,則其他線程只能對A再加共享鎖,不能加排它鎖。獲得共享鎖的線程只能讀數據,不能修改數據。
獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
簡單看下ReentrantReadWriteLock 源碼
我們看到ReentrantReadWriteLock有兩把鎖:ReadLock和WriteLock,由詞知意,一個讀鎖一個寫鎖,合稱“讀寫鎖”。再進一步觀察可以發現ReadLock和WriteLock是靠內部類Sync實現的鎖。Sync是AQS的一個子類,這種結構在CountDownLatch、ReentrantLock、Semaphore裏面也都存在。
在ReentrantReadWriteLock裏面,讀鎖和寫鎖的鎖主體都是Sync,但讀鎖和寫鎖的加鎖方式不一樣。讀鎖是共享鎖,寫鎖是獨享鎖。讀鎖的共享鎖可保證併發讀非常高效,而讀寫、寫讀、寫寫的過程互斥,因爲讀鎖和寫鎖是分離的。所以ReentrantReadWriteLock的併發性相比一般的互斥鎖有了很大提升。
獲取讀鎖,但是不能獲取寫鎖; 如果獲取了寫鎖就可以繼續獲取寫鎖或者讀鎖。鎖是可以降級的,不能升級。
所謂降級就是:加了寫鎖再加讀鎖;升級:加了讀鎖再加寫鎖
爲什麼不能升級呢?會造成死循環。
看如下僞代碼:
ReadWriteLock lock = new ReentrantReadWriteLock();
lock.readLock().lock();
xxxx;
lock.writeLock().lock();
xxxxx
如果多個線程都是這樣寫的代碼,thread1,thread2都能夠獲取讀鎖,但是寫鎖是排他的,所以當執行到 lock.writeLock().lock();的時候就會等待所有線程都釋放了讀鎖,所以thread1等待thread2,thread2等待thread1.從而造成死鎖。
可以降級
ReadWriteLock lock = new ReentrantReadWriteLock();
lock.writeLock().lock();
xxxx;
lock.readLock().lock();
xxxxx
因爲寫鎖是排他的,此時只有一個線程獲取,當在加讀鎖的時候,是可以的,因爲讀鎖是共享的。
那讀鎖和寫鎖的具體加鎖方式有什麼區別呢?在瞭解源碼之前我們需要回顧一下其他知識。
在最開始提及AQS的時候我們也提到了state字段(int類型,32位),該字段用來描述有多少線程獲持有鎖。
在獨享鎖中這個值通常是0或者1(如果是重入鎖的話state值就是重入的次數),在共享鎖中state就是持有鎖的線程的數量。但是在ReentrantReadWriteLock中有讀、寫兩把鎖,所以需要在一個整型變量state上分別描述讀鎖和寫鎖的數量(或者也可以叫狀態)。於是將state變量“按位切割”切分成了兩個部分,高16位表示讀鎖狀態(讀鎖個數),低16位表示寫鎖狀態(寫鎖個數)。如下圖所示:
瞭解了概念之後我們再來看代碼,先看寫鎖的加鎖源碼:
- 這段代碼首先取到當前鎖的個數c,然後再通過c來獲取寫鎖的個數w。因爲寫鎖是低16位,所以取低16位的最大值與當前的c做與運算( int w = exclusiveCount(c); ),高16位和0與運算後是0,剩下的就是低位運算的值,同時也是持有寫鎖的線程數目。
- 在取到寫鎖線程的數目後,首先判斷是否已經有線程持有了鎖。如果已經有線程持有了鎖(c!=0),則查看當前寫鎖線程的數目,如果寫線程數爲0(即此時存在讀鎖)或者持有鎖的線程不是當前線程就返回失敗(涉及到公平鎖和非公平鎖的實現)。
- 如果寫入鎖的數量大於最大數(65535,2的16次方-1)就拋出一個Error。
- 如果當且寫線程數爲0(那麼讀線程也應該爲0,因爲上面已經處理c!=0的情況),並且當前線程需要阻塞那麼就返回失敗;如果通過CAS增加寫線程數失敗也返回失敗。
- 如果c=0,w=0或者c>0,w>0(重入),則設置當前線程或鎖的擁有者,返回成功!
tryAcquire()除了重入條件(當前線程爲獲取了寫鎖的線程)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被獲取,原因在於:必須確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那麼正在運行的其他讀線程就無法感知到當前寫線程的操作。
因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當前線程獲取,而寫鎖一旦被獲取,則其他讀寫線程的後續訪問均被阻塞。寫鎖的釋放與ReentrantLock的釋放過程基本類似,每次釋放均減少寫狀態,當寫狀態爲0時表示寫鎖已被釋放,然後等待的讀寫線程才能夠繼續訪問讀寫鎖,同時前次寫線程的修改對後續的讀寫線程可見。
接着是讀鎖的代碼:
可以看到在tryAcquireShared(int unused)方法中,如果其他線程已經獲取了寫鎖,則當前線程獲取讀鎖失敗,進入等待狀態。如果當前線程獲取了寫鎖或者寫鎖未被獲取,則當前線程(線程安全,依靠CAS保證)增加讀狀態,成功獲取讀鎖。讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態,減少的值是“1<<16”。所以讀寫鎖才能實現讀讀的過程共享,而讀寫、寫讀、寫寫的過程互斥。
是否共享在源碼級別體現主要是是否是當前線程佔用。
此時,我們再回頭看一下互斥鎖ReentrantLock中公平鎖和非公平鎖的加鎖源碼:
我們發現在ReentrantLock雖然有公平鎖和非公平鎖兩種,但是它們添加的都是獨享鎖。根據源碼所示,當某一個線程調用lock方法獲取鎖時,如果同步資源沒有被其他線程鎖住,那麼當前線程在使用CAS更新state成功後就會成功搶佔該資源。而如果公共資源被佔用且不是被當前線程佔用,那麼就會加鎖失敗。所以可以確定ReentrantLock無論讀操作還是寫操作,添加的鎖都是都是獨享鎖。
源碼這裏看不懂沒啥事,下一章我着重講AQS的源碼,到時候會着重講解的。