Java中各種鎖的詳細解讀

在沒有實際接觸和詳細瞭解Java的各種鎖之前,可能覺得Java 中的各種鎖,會很多很複雜,不是的,看一遍不行,再看一遍,就差不多了,還是比較好理解的。雖然距離實際使用還是有點距離,但是,要跨出第一步,瞭解之後,再考慮如何使用和高級使用。

Java 各種鎖的簡單精闢總結

這個圖畫的也很好

1. 樂觀鎖 VS 悲觀鎖

樂觀鎖與悲觀鎖是一種廣義上的概念,體現了看待線程同步的不同角度。在Java和數據庫中都有此概念對應的實際應用。
先說概念。對於同一個數據的併發操作,悲觀鎖認爲自己在使用數據的時候一定有別的線程來修改數據,因此在獲取數據的時候會先加鎖,確保數據不會被別的線程修改。Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖
而樂觀鎖認爲自己在使用數據時不會有別的線程修改數據,所以不會添加鎖,只是在更新數據的時候去判斷之前有沒有別的線程更新了這個數據。如果這個數據沒有被更新,當前線程將自己修改的數據成功寫入。如果數據已經被其他線程更新,則根據不同的實現方式執行不同的操作(例如報錯或者自動重試)。
樂觀鎖在Java中是通過使用無鎖編程來實現,最常採用的是CAS算法,Java原子類中的遞增操作就通過CAS自旋實現的。

(圖還是很值得一看滴)

根據從上面的概念描述我們可以發現:

  • 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時數據正確。
  • 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的性能大幅提升。

看下樂觀鎖和悲觀鎖的調用方式示例:

// ------------------------- 悲觀鎖的調用方式 -------------------------
// synchronized
public synchronized void testMethod() {
	// 操作同步資源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保證多個線程使用的是同一個鎖
public void modifyPublicResources() {
	lock.lock();
	// 操作同步資源
	lock.unlock();
}

// ------------------------- 樂觀鎖的調用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保證多個線程使用的是同一個AtomicInteger
atomicInteger.incrementAndGet(); //執行自增1

通過調用方式示例,我們可以發現悲觀鎖基本都是在顯式的鎖定之後再操作同步資源,而樂觀鎖則直接去操作同步資源。cas不瞭解的可以看下面的鏈接,瞭解一下cas的優缺點。

Java 多線程 併發中CAS詳解

2. 自旋鎖 VS 適應性自旋鎖

在介紹自旋鎖前,我們需要介紹一些前提知識來幫助大家明白自旋鎖的概念。
阻塞或喚醒一個Java線程需要操作系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步代碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。
在許多場景中,同步資源的鎖定時間很短,爲了這一小段時間去切換線程,線程掛起和恢復現場的花費可能會讓系統得不償失。如果物理機器有多個處理器,能夠讓兩個或以上的線程同時並行執行,我們就可以讓後面那個請求鎖的線程不放棄CPU的執行時間,看看持有鎖的線程是否很快就會釋放鎖。
而爲了讓當前線程“稍等一下”,我們需讓當前線程進行自旋,如果在自旋完成後前面鎖定同步資源的線程已經釋放了鎖,那麼當前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷。這就是自旋鎖。

自旋鎖本身是有缺點的,它不能代替阻塞。自旋等待雖然避免了線程切換的開銷,但它要佔用處理器時間。如果鎖被佔用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被佔用的時間很長,那麼自旋的線程只會白浪費處理器資源。所以,自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(默認是10次,可以使用-XX:PreBlockSpin來更改)沒有成功獲得鎖,就應當掛起線程。

自旋鎖的實現原理同樣也是CAS,AtomicInteger中調用unsafe進行自增操作的源碼中的do-while循環就是一個自旋操作,如果修改數值失敗則通過循環來執行自旋,直至修改成功。

自旋鎖在JDK1.4.2中引入,使用-XX:+UseSpinning來開啓。JDK 6中變爲默認開啓,並且引入了自適應的自旋鎖(適應性自旋鎖)。

自適應意味着自旋的時間(次數)不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

在自旋鎖中 另有三種常見的鎖形式:TicketLock、CLHlock和MCSlock,本文中僅做名詞介紹,不做深入講解,感興趣的同學可以自行查閱相關資料。

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

這四種鎖是指鎖的狀態,專門針對synchronized的。

Java 多線程-synchronized用法和實現原理

理解Java對象頭與Monitor
在JVM中,對象在內存中的佈局分爲三塊區域:對象頭、實例數據和對齊填充。如下:

 

其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化後新增加的

無鎖

無鎖沒有對資源進行鎖定,所有的線程都能訪問並修改同一個資源,但同時只有一個線程能修改成功。

無鎖的特點就是修改操作在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有衝突就修改成功並退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。上面我們介紹的CAS原理及應用即是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。

偏向鎖

偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖,降低獲取鎖的代價。

在大多數情況下,鎖總是由同一線程多次獲得,不存在多線程競爭,所以出現了偏向鎖。其目標就是在只有一個線程執行同步代碼塊時能夠提高性能。

當一個線程訪問同步代碼塊並獲取鎖時,會在Mark Word裏存儲鎖偏向的線程ID。在線程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裏是否存儲着指向當前線程的偏向鎖。引入偏向鎖是爲了在無多線程競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因爲輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。

偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態。撤銷偏向鎖後恢復到無鎖(標誌位爲“01”)或輕量級鎖(標誌位爲“00”)的狀態。

偏向鎖在JDK 6及以後的JVM裏是默認啓用的。可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程序默認會進入輕量級鎖狀態。

輕量級鎖

是指當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。

在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,然後拷貝對象頭中的Mark Word複製到鎖記錄中。

拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock Record裏的owner指針指向對象的Mark Word。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,表示此對象處於輕量級鎖定狀態。

如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。

若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級爲重量級鎖。

重量級鎖

升級爲重量級鎖時,鎖標誌的狀態值變爲“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態。

整體的鎖狀態升級流程如下:

綜上,偏向鎖通過對比Mark Word,解決加鎖問題,避免執行CAS操作。而輕量級鎖是通過用CAS操作和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能。重量級鎖是將除了擁有鎖的線程以外的線程都阻塞。

4. 公平鎖 VS 非公平鎖

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

非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到纔會到等待隊列的隊尾等待。但如果此時鎖剛好可用,那麼這個線程可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現後申請鎖的線程先獲取鎖的場景。非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因爲線程有機率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點是處於等待隊列中的線程可能會餓死,或者等很久纔會獲得鎖。

直接用語言描述可能有點抽象,這裏作者用從別處看到的一個例子來講述一下公平鎖和非公平鎖。

如上圖所示,假設有一口水井,有管理員看守,管理員有一把鎖,只有拿到鎖的人才能夠打水,打完水要把鎖還給管理員。每個過來打水的人都要管理員的允許並拿到鎖之後才能去打水,如果前面有人正在打水,那麼這個想要打水的人就必須排隊。管理員會查看下一個要去打水的人是不是隊伍裏排最前面的人,如果是的話,纔會給你鎖讓你去打水;如果你不是排第一的人,就必須去隊尾排隊,這就是公平鎖。

但是對於非公平鎖,管理員對打水的人沒有要求。即使等待隊伍裏有排隊等待的人,但如果在上一個人剛打完水把鎖還給管理員而且管理員還沒有允許等待隊伍裏下一個人去打水時,剛好來了一個插隊的人,這個插隊的人是可以直接從管理員那裏拿到鎖去打水,不需要排隊,原本排隊等待的人只能繼續等待。如下圖所示:

5. 可重入鎖 VS 非可重入鎖

可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會因爲之前已經獲取過還沒釋放而阻塞。Java中ReentrantLock和synchronized都是可重入鎖,可重入鎖的一個優點是可一定程度避免死鎖。下面用示例代碼來進行分析:

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1執行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2執行...");
    }
}

在上面的代碼中,類中的兩個方法都是被內置鎖synchronized修飾的,doSomething()方法中調用doOthers()方法。因爲內置鎖是可重入的,所以同一個線程在調用doOthers()時可以直接獲得當前對象的鎖,進入doOthers()進行操作。

如果是一個不可重入鎖,那麼當前線程在調用doOthers()之前需要將執行doSomething()時獲取當前對象的鎖釋放掉,實際上該對象鎖已被當前線程所持有,且無法釋放。所以此時會出現死鎖。

而爲什麼可重入鎖就可以在嵌套調用時可以自動獲得鎖呢?我們通過圖示和源碼來分別解析一下。

還是打水的例子,有多個人在排隊打水,此時管理員允許鎖和同一個人的多個水桶綁定。這個人用多個水桶打水時,第一個水桶和鎖綁定並打完水之後,第二個水桶也可以直接和鎖綁定並開始打水,所有的水桶都打完水之後打水人才會將鎖還給管理員。這個人的所有打水流程都能夠成功執行,後續等待的人也能夠打到水。這就是可重入鎖。

但如果是非可重入鎖的話,此時管理員只允許鎖和同一個人的一個水桶綁定。第一個水桶和鎖綁定打完水之後並不會釋放鎖,導致第二個水桶不能和鎖綁定也無法打水。當前線程出現死鎖,整個等待隊列中的所有線程都無法被喚醒。

ReentrantLock和synchronized都是重入鎖

6. 獨享鎖 VS 共享鎖

獨享鎖和共享鎖同樣是一種概念。ReentrantLock和ReentrantReadWriteLock分別是獨享鎖和共享鎖。

獨享鎖也叫排他鎖,是指該鎖一次只能被一個線程所持有。如果線程T對數據A加上排它鎖後,則其他線程不能再對A加任何類型的鎖。獲得排它鎖的線程即能讀數據又能修改數據。JDK中的synchronized和JUC中Lock的實現類就是互斥鎖。

共享鎖是指該鎖可被多個線程所持有。如果線程T對數據A加上共享鎖後,則其他線程只能對A再加共享鎖,不能加排它鎖。獲得共享鎖的線程只能讀數據,不能修改數據。

獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章