1. 程式人生 > 實用技巧 >詳細介紹各種鎖

詳細介紹各種鎖

前言

通常情況下,每學習一塊知識點我都會先找相應的文章來作鋪墊,今天輪到鎖相關介紹了,發現了一篇好文章,本想著直接貼個連結就完事了,想想自己還是好好總結下,下面的內容可能大部分摘自該文章鎖的詳細介紹,對本文沒興趣的讀者直接移至該連結即可,速速開始吧。

進入正題

Java中有好幾種鎖,什麼悲觀鎖、樂觀鎖、自旋鎖等,第一次看的時候覺得這是什麼亂七八糟的,實際上每種鎖在被設計時就考慮了其使用場景,也就是說鎖的特性決定了它用於哪種場景下的效率會最高,所以只要理解了使用場景的理念,基本上掌握鎖是沒什麼問題了。在上面提供的連結中作者結合了Java原始碼進行介紹,這邊的話也會適當性的加入一些,接下來通過一張表格來對鎖進行分組歸類:

樂觀鎖 VS 悲觀鎖

悲觀鎖:同一個資料的操作,悲觀鎖認為自己在使用資料的時候一定有其他執行緒在修改資料,所以要確保資料的準確性必須要先加鎖。Java中,synchronized關鍵字和lock的實現類都是悲觀鎖。

樂觀鎖:同一個資料的操作,樂觀鎖認為自己在使用資料的時候不會有其他執行緒在修改資料,所以不會加鎖,只是在更新資料時判斷之前有沒有其他執行緒更新了資料,如果資料沒有被更新,當前執行緒將自己修改的資料成功寫入,如果資料已經被其他執行緒更新過,則根據不同的實現方式執行不同的操作,如報錯或重試。Java中,樂觀鎖使用無鎖程式設計來實現,最常採用的是CAS演算法。

根據其特性,我們可知:

  • 悲觀鎖適合寫操作多的場景,加鎖可以保證資料的正確性

  • 樂觀鎖適合讀操作多的場景,不加鎖能夠使讀操作的效能大幅提升

悲觀鎖是顯式的加鎖後才開始操作同步資源的,而樂觀鎖卻是直接操作同步資源。那麼,為何樂觀鎖能夠做到不鎖定同步資源也可以正確地實現執行緒同步呢?說到底就是要理解CAS演算法是如何實現的,接下來是筆者自己總結的知識點。

CAS操作利用了CPU提供的cmpxchg指令,被該指令操作的記憶體區域會加鎖,也就是說多個CPU同時訪問該記憶體區域的話只會有一個CPU能成功訪問,當將其值回寫到記憶體區域時會利用快取一致性機制使其他CPU對應的快取行無效,從而保證了原子性操作,嚴格上來說是保證了更新的原子性。CAS操作的底層程式碼的變數使用volatile來修飾是為了禁止該指令與讀寫指令重排序。

在簡單說下CAS操作的使用:比較舊值(預期值)是否發生變化,若沒有發生變化則更新成新值,若發生了變化則直接返回,通常情況下,需要迴圈執行CAS操作直到成功為止。CAS操作雖然很高效,但也存在一些問題:

  • ABA問題:CAS操作要先檢查舊值是否發生變化,那麼就要先獲取舊值,假設舊值是a,執行緒A獲取到了舊值還沒開始進行比較,執行緒B突然將其值由a變成了b,在變成a,那麼執行緒A在進行比較時會發現值並沒有變化,繼而就更新了,這導致了執行緒B的操作相當於是沒有一樣,很明顯這是邏輯上的錯誤。解決該問題的方式是在變數前面新增版本號,每次更新變數的時候都把版本號加一,比如由原來的a -> b -> a變成了1a -> 2b -> 3a,或者使用JDK提供的AtomicStampedReference。

  • 迴圈時間開銷長:CAS操作如果長時間不成功,會導致一直迴圈,給CPU帶來非常大的執行開銷。

  • 只能保證一個共享變數的原子操作:對一個共享變數執行操作時,CAS操作能保證原子性,但是對多個共享變數操作時,CAS操作無法保證操作的原子性。這種情況下可以使用鎖,JDK提供了AtomicReference類可以把多個變數放在一個物件裡進行操作。

自旋鎖 VS 適應性自旋鎖

自旋鎖:阻塞或喚醒一個執行緒都會進行一次上下文切換,如果同步程式碼塊的內容過於簡單,那麼頻繁的上下文切換所帶來的開銷可能會使得系統的效能下降,顯得因小失大了。在多處理器下,為了使未能獲取到同步資源的執行緒不阻塞,即不讓其放棄CPU的執行時間,允許執行緒發生自旋,如果在短時間內成功獲取到鎖,就可以避免切換執行緒的開銷,而如果長時間未獲取到鎖,那就白白浪費了CPU資源,所以自旋的次數必須要有一定的限制,如果自旋次數超過了指定次數(預設是10次,可以使用-XX:PreBlockSpin來更改)還未獲得鎖,就應當掛起執行緒。自旋鎖是在JDK1.4中引入的,使用-XX:+UseSpinning來開啟,JDK6中變為預設開啟,並且引入了自適應的自旋鎖(適應性自旋鎖)。

適應性自旋鎖:一種更加聰明的自旋鎖。自適應意味著自旋的次數不在固定,執行緒通過自旋成功獲取到鎖,那麼下次自旋的次數會增加,因為虛擬機器認為既然上次成功了,那麼此次自旋也很有可能會再次成功,所以它就允許自旋等待持續的次數更多。反之,如果執行緒通過自旋很少獲取到鎖,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞執行緒,避免浪費資源。

還有一些自旋鎖的變體,如TicketLock、CLHLock、MCSLock,簡單介紹下。

  • TicketLock:對於多個執行緒發生自旋來說,無法滿足等待時間最長的執行緒優先獲取鎖,可能會存線上程飢餓問題。為了解決公平性問題,引出了TicketLock。每當有執行緒在獲取鎖的時候,就給該執行緒分配一個遞增的標識,稱之為排隊號,同時鎖對應一個服務號,每當有執行緒釋放鎖,服務號會遞增,此時如果服務號與排隊號一致,那麼該執行緒就獲到鎖,由於排隊號是遞增的,所以就保證了最先請求獲取鎖的執行緒可以優先獲取到,實現了公平性。可以想象成銀行辦理業務排隊,排隊的每一個客戶都代表著一個請求鎖的執行緒,銀行視窗表示鎖,每當服務完一個客戶就把服務號加1,此時在排隊中的所有客戶中,只有服務號與排隊號一致的客戶才能得到服務。服務號對於排隊中的所有執行緒來說必須是已知的,也就是說但凡一個執行緒修改了服務號,其他的執行緒必須重新從主記憶體獲取同步資源,這導致了記憶體壓力上升。

  • CLHLock:基於連結串列的可擴充套件、高效能、公平的自旋鎖,每個請求鎖的執行緒對應一個節點,按照請求順序關聯上下節點,所以每個執行緒在請求鎖時只需要關注上一個節點是否釋放鎖了,若釋放了則可以獲取到鎖了,這避免了TicketLock會造成記憶體壓力的問題。

  • MCSLock:同CLHLock類似,只不過它的重心是在當前節點上。

無鎖 VS 偏向鎖 VS 輕量級鎖 VS 重量級鎖

這四種鎖是指鎖的狀態,之前在理解synchronized關鍵字時提到過。早起的synchronized效率較低,是因為在簡單的同步方法中頻繁的進行上下文切換所帶來的開銷會降低系統的效能,所以引入了偏向鎖和輕量級鎖。

無鎖:所有的執行緒都能併發訪問同一個資源,但同時只有一個執行緒能修改成功,而其他執行緒會不斷的重試修改直到成功為止。上面介紹的CAS原理便是無鎖的實現。

偏向鎖:HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一個執行緒多次獲取,在第一次獲取到鎖時會儲存當前執行緒的ID,以後該執行緒在進入和退出同步方法時不需要CAS操作進行加鎖或解鎖,只需要簡單測試下物件頭裡是否儲存了指向當前執行緒的偏向鎖。偏向鎖在遇到其他執行緒嘗試競爭時會發生偏向鎖的撤銷,之所以稱之為撤銷是因為偏向鎖並不存在真正意義上的釋放鎖的操作。偏向鎖的撤銷,需要等待全域性安全點(沒有任何執行緒執行的時刻),首先會暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否還活著,個人理解是同步程式碼是否執行完畢,若已執行完畢說明偏向鎖已經過期無效,注意,是隻有在出現其他執行緒競爭的情況下才會發生偏向鎖的撤銷,而沒有競爭的情況下執行緒是不會主動撤銷偏向鎖,以便於下次獲取鎖時可直接進入到同步程式碼中,若還未執行同步程式碼說明偏向鎖仍然有效,此時升級到輕量級鎖狀態。實際上偏向鎖的撤銷還遠不止於此,還有偏向鎖的重新偏向,沒辦法,我不懂...

輕量級鎖:執行緒在執行同步程式碼之前,JVM會先在當前執行緒的棧幀(每個執行緒都有自己獨立的記憶體空間,棧幀就是其中一部分)中建立用於儲存鎖記錄的空間,並將物件頭的Mark Word複製到鎖記錄中,官方稱之為Displaced Mark Word(個人理解此時的鎖記錄被稱之為該名稱,但不敢保證正確性)。然後執行緒嘗試使用CAS操作將物件頭中的Mark Word替換為指向鎖記錄的指標,如果成功,當前執行緒獲得鎖,如果失敗則當前執行緒便嘗試使用自旋來獲取鎖。輕量級鎖在解鎖時,會使用CAS操作將Mark Word替換成原值,即使用Displaced Mark Word替換,如果成功,則表示沒有競爭發生,如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。關於輕量級鎖的理論摘自《併發程式設計的藝術》書籍,書中所寫的內容表述不夠準確清晰,故加上了自己的理解,並不敢保證其正確性。

重量級鎖:升級為重量級鎖時,物件頭中的Mark Word儲存的是指向互斥量(重量級鎖)的指標,未獲取到鎖的其他執行緒都會進入到阻塞狀態。

此小節中不太確定的理論,筆者只是簡單地加上了自己的理解,再次強調不敢保證其正確性,這裡也為大家貼上一篇更為詳細的文章,此篇文章的作者是從原始碼的角度進行分析的。突然發現我看原始碼只是站在Java層面上看,別人那才是大牛,直接到C了...以下是鎖的優缺點比較:

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於只有一個執行緒訪問同步資源
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度 始終得不到鎖的執行緒使用自旋會消耗CPU 追求響應時間,同步程式碼執行速度非常快
重量級鎖 競爭的執行緒不使用自旋直接阻塞,不會消耗CPU 執行緒阻塞,響應時間緩慢 同步程式碼執行速度較長

公平鎖 VS 非公平鎖

公平鎖:是指多個執行緒按照申請鎖的順序來獲取鎖,執行緒直接進入佇列中排隊,佇列中的第一個執行緒才能獲得鎖。優點是佇列中的執行緒不會出現餓死,缺點是整體吞吐率相對非公平鎖低,佇列中除第一個執行緒以外的所有執行緒都會阻塞,CPU喚醒阻塞執行緒的開銷比非公平鎖大。

非公平鎖:多個執行緒直接嘗試獲取鎖,未獲取到的執行緒才會進入到佇列中等待。如果此時鎖剛好可用,那麼當前執行緒可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現後申請鎖的執行緒先獲取到鎖的場景。優點是可以減少要喚醒的執行緒,整體的吞吐率較高,因為執行緒有機率不阻塞直接獲取到鎖,缺點是佇列中的執行緒可能會餓死,或者說等很久才能獲取到鎖。

JDK中ReentrantLock類就提供了公平鎖與非公平鎖,會另外新起文章講解ReentrantLock的原始碼,這裡就不作展開了。

可重入鎖 VS 不可重入鎖

可重入鎖:又名遞迴鎖,指同一個執行緒在外層方法成功獲取到鎖後,而後又進入到內層方法會自動獲取鎖,前提是鎖是同一個物件或者class,不會因為之前獲取到的鎖還未釋放就阻塞。Java中ReentrantLock和synchronized都是可重入鎖,優點是可一定程度上避免死鎖。

不可重入鎖:指同一個執行緒在外層方法成功獲取到鎖後,而後又進入到內層方法還要再次獲取鎖,容易出現死鎖的情況。


    public class Test {

        public synchronized void A() {
            System.out.println("A");
            B();
        }

        public synchronized void B() {
            System.out.println("B");
        }
    }

因為synchronized是可重入鎖,所以同一個執行緒在獲取到鎖後,接著呼叫B方法時可直接獲得鎖進行操作。如果是一個不可重入鎖,那麼當前執行緒在呼叫B之前需要先釋放呼叫A時獲取到的鎖,其實鎖已經被當前執行緒所持有,且無法釋放,所以會出現死鎖。

獨享鎖 VS 共享鎖

獨享鎖:又可以叫排他鎖、獨佔鎖、互斥鎖,指一個鎖只能被一個執行緒鎖持有,獲得該鎖的執行緒既能讀取又能修改資料,ReentrantLock是獨享鎖。

共享鎖:指一個鎖可被多個執行緒同時持有,獲得該鎖的執行緒只能讀資料,不能修改資料,否則就出問題了。Java中ReentrantReadWriteLock是共享鎖,內部中有兩個鎖,一個讀鎖,一個寫鎖,讀鎖是共享鎖,寫鎖是獨享鎖,讀寫是互斥的,也就是說當有執行緒先獲取了讀鎖,那麼寫鎖自然不能被其他執行緒獲取,如果有執行緒是先獲取了寫鎖,那麼讀鎖自然要被阻塞。

這裡就不從原始碼的角度進行後續的分析了。

結束語

Java中的很多鎖都是基於AQS來實現的,瞭解了其特性後,最好能夠去看看對應的原始碼實現幫助更加深入地理解。