Java中的鎖原理

閱讀時間 > 20min

最近在複習Java併發相關內容,突然發現日記本躺了一篇一年前寫好的文章,估計是寫完,忘了點發布了,😂

隨着集成電路越來越發達,多計算核心的機器大行其道,爲了解決多個並行執行分支對某一塊資源的同步訪問,操作系統層面提供了 互斥信號量 的概念。在幾乎所有的支持多線程編程模型的語言中,基本上都提供了與互斥信號量對應的概念,在Java中我們稱之爲,我們今天就來討論一下Java中的鎖。

說起Java中的鎖,其實大部分人第一反應就是synchronized,我們可以把這個關鍵字放到方法或者代碼塊上,表示使用某一個對象作爲鎖,去同步這個方法或者代碼塊。在Java中任何非NULL對象(我們稱這個對象爲鎖對象)都可以作爲一把鎖給synchronized使用,這就是我們常說的內置鎖,也叫監視鎖。多個線程只有去訪問同一個監視鎖保護的臨界區時纔會發生競爭。

爲什麼叫內置鎖,因爲它是作爲Java的一個關鍵字被引入進來的,可以理解爲Java內置的功能;爲什麼也叫監視鎖?因爲JVM內部是通過monitorentermonitorexit這兩個字節指令來獲取鎖和釋放鎖的,monitor就是監視的意思,同時它的Java層實現類名字爲ObjectMonitor

一、內置鎖的使用

內置鎖最大的有時就是它的使用非常簡單,從前面的描述中我們可以確定使用synchronized時,我們只需要給這個關鍵字一個對象作爲鎖即可,所以不管
茴香豆的茴字有幾種寫法,本質上它都是給synchronized一個對象作爲監視的對象。

// (1):加靜態方法上面,表示會監視這個類對象
public static synchronized void staticFunc() {
    //dosomething
}

//(2):加實例方法上面,表示監視當前這個實例對象,我們常說的this
public synchronized void virtualFunc() {
    //dosomething
}

public void monitorThis() {
    //(3):加代碼塊上面,括號裏面傳入的是需要監視的對象,這裏是this
    synchronized (this){
    }
}

private Object lock = new Object();
public void monitorObject() {
    //(4):自己new了一個lock對象,然後監視lock對象
    synchronized (lock) {
    }
}

如果你在閱讀一些SDK代碼的時候,可能會發現有些場景下SDK開發人員會使用第四種方式,通過自己new一個對象,然後監視這個對象,那這種方式和前面三種相比,有什麼優勢呢?如果在一個類中存在兩個臨界區需要同步,即需要兩把鎖,Lock1Lock2,那麼此時一個this對象就不夠用了...

二、內置鎖的特點

  • synchronized鎖是可重入鎖
    可重入指的是如果一個線程已經持有了一把監視鎖時,這個線程如果需要再次獲取這把鎖,不需要再次競爭,可以直接得到。

      public synchronized void test() {
        reentrant();
      }
    
      public synchronized void reentrant() { 
      }
    

test()reentrant()都需要this對象上面的監視鎖,由於synchronized是 可重入的,所以test()獲取了鎖之後,調用reentrant()時,需要再次獲取鎖,由於可重入性,test()方法是沒有問題的。

  • synchronized鎖是阻塞的
    如果線程1試圖去獲取監視鎖,失敗之後,線程1會加入到阻塞隊列中等待鎖的釋放。

  • 內置鎖是不公平鎖
    公平鎖指的是,比如一把鎖現在正在被線程1持有,此時線程2試圖獲取鎖,當然線程2會失敗,然後放到阻塞隊列中,如果線程1長時間持有鎖,那麼阻塞隊列中的線程會越來越多;如果此時線程1使用鎖完畢,開始釋放鎖,此時,JVM會喚醒那個線程呢?如果是按照加入阻塞隊列的順序來依次喚醒,那麼就是公平鎖;否則,就是非公平鎖;由於公平鎖的性能通常來說比不上不公平鎖(自己腦補一下,公平鎖明顯需要一些額外的消耗,比如記錄加入順序;同時如果在線程釋放鎖時,剛好有一個線程在獲取鎖,那麼公平鎖需要把這個線程阻塞,然後從隊列總取出對頭,而非公平鎖就可以直接把鎖分配給先來的線程),JVM對內置鎖的實現是非公平的,實際上Java的內置鎖在進入阻塞隊列前,會使用自旋鎖等待一段超時時間,這樣這樣就形成了後來先用,當然不公平啦。

三 內置鎖的狀態

由於內置鎖是通過JVMmonitorentermonitorexit指令實現的,所以可想而知它的加鎖釋放鎖都會在JVM中實現。我們前面說了,操作系統會提供一個叫互斥信號量的東西,它的本質就是一把鎖。在早期的JVM實現中,monitorentermonitorexit比較嚴重的依賴於操作系統的互斥信號量,這就存在一個問題:使用系統的互斥信號量就需要將線程從用戶態切換到內核態,由於這種切換存在一定的性能問題,所以Java開發人員對synchronized使用都比較謹慎,甚至給它扣了一頂影響性能的帽子。

實際上從1.6版本開始,針對這個問題Java進行了一系列的優化。首先就是對鎖的狀態進行了區分,監視鎖不在是簡單的一把鎖,它還有各種狀態。鎖的狀態流轉其實就對應着多個線程對鎖的競爭程度,如果對這個鎖的競爭比較低,那麼JVM不會去通過系統信號量來實現同步,隨着競爭加劇,獲取鎖的代價越來越大,最後退化成依賴於系統信號量的 重量級鎖。

3.1 鎖信息存儲

我們前面說了監視鎖可以加在任何一個對象上面,那麼對象如何去表示自己當前被監視了呢?在HotSpot虛擬機中,每個對象在內存中分爲三個部分:對象頭、實例數據和對齊填充。而鎖信息就存放在對象頭中,由於對象頭的長度和具體機器字長相關,一般來說目前存在兩種字長,32和64位,對象頭的格式如下圖:

對象頭大致分爲三部分,Mark Word,類型指針和數組長度(如果對象是數組)。而我們的鎖就是存放在Mark Word中的。我們先說後面兩部分:類型指針,指向當前對象類型,表明這個實例是什麼類型的;數組長度是可選的,只有當前對象是數組的時候纔會存在,表明數組的長度。最後我們來看看最複雜的Mark Word,它的格式如下:


Mark Word中涉及到了Java中的太多核心內容,比如GC分代的年齡,對象的hashCode、是否被GC標記、是否有監視鎖等等,虛擬機從執行的效率來考慮,它給了一個字長(32 / 64)。在不同的狀態下,Mark Word中每個字段代表的含義不一樣。所謂狀態,指的就是Mark Word的最後兩位,圖中的標誌位。這個看起來好像很複雜的樣子,你可以先不關心各種名詞,什麼偏向鎖 輕量級鎖 xxx(這些一會兒我們後面會說),因爲它們只代表各種狀態。我們從最簡單的開始:

  • 可GC 標誌位:11
    這個狀態就是Mark Word最後兩個Bit是11,如果設置了這個標記,那麼表示當前這個類是可GC的,前面的bitfields裏面的內容已經不重要了,因爲這個類馬上就要被回收。

  • 無鎖 標誌位:01
    我們注意到無鎖和偏向鎖都使用了01標誌位,這樣沒有辦法,我們只好再往前佔一個bit來區分,這個位用來標識偏向鎖是否禁止,無鎖狀態就表示當前對象禁止偏向;

  • 偏向鎖 標誌位:01
    由於此時這個對象是可以偏向的,它存在三種情況:
    (1): 匿名偏向(Anonymously biased)
    表示當前還沒有線程偏向這個對象,第一個試圖獲取鎖的線程可以使用CAS指令去改變鎖對象的Mark Word指向自己。這個狀態是可偏向對象鎖的初始狀態。
    (2): 可重偏向(Rebiasable)
    epoch字段無效,可以理解爲之前這個鎖對象偏向於某個線程,但是這個線程已經退出了臨界區,這個時候如果另外一個線程來獲取鎖,可以使用CAS指令去改變鎖對象的Mark Word指向自己。
    (3): 已偏向(Biased)
    epoch字段有效,表示鎖對象當前已經偏向某一個線程。
    以上內容參考:偏向鎖

  • 輕量級鎖 標誌位:00
    偏向鎖存在競爭時,進入輕量級鎖的狀態,此時獲取鎖的線程開始自旋等待。

  • 重量級鎖 標誌位:10
    競爭鎖的各個線程開始使用系統的信號量做同步,回到最原始的狀態。

3.2 鎖狀態流轉

從上面一節中我們已經從方法頭裏面已經看到一個鎖對象有各種四種狀態:
無鎖 -> 偏向鎖->輕量級鎖->重量級鎖
在1.6中,偏向鎖是默認打開的,所以和鎖相關的狀態其實只有三種。隨着獲取鎖的競爭加劇,鎖的狀態會從偏向鎖升級到輕量級鎖,最後到重量級鎖。

3.2.1 偏向鎖

大部分情況下,對一個鎖的獲取都是同一個線程(不存在競爭),爲了減少獲取鎖的代價,引入偏向鎖。因爲每次加鎖/解鎖都會涉及到一些CAS操 作(比如對等待隊列的CAS操作),CAS操作會延遲本地調用,因此偏向鎖的想法是一旦線程第一次獲得了監視對象,之後讓監視對象“偏向”這個 線程,之後的多次調用則可以避免CAS操作。說白了就是給鎖對象設置個變量,線程在獲取時,只需要看一下當前這個變量是不是自己,如果是自己,就不需要再去走獲取鎖的邏輯了。
獲取偏向鎖主要有以下幾個步驟:

(1): 檢查鎖的偏向是否打開,如果沒有打開,則進入輕量級鎖路徑來獲取鎖;
(2): 檢查當前對象偏向鎖的狀態,1>如果是匿名偏向,那麼簡單的CAS獲取偏向,如果成功,那麼當前鎖對象偏向當前線程;如果失敗,表明當前存在競爭,退化到輕量級鎖;2>如果是可重偏向,那麼CAS獲取偏向,如果成功,那麼當前鎖對象偏向當前線程;如果失敗,表明當前存在競爭,退化到輕量級鎖;3>已偏向,表明當前鎖已經有偏向線程,此時退化到輕量級鎖。
基本上就是一條原則,如果獲取偏向失敗,那麼就撤銷鎖的偏向,轉入輕量級鎖的路徑來獲取。

釋放偏向鎖就是最簡單的事情,那就是啥都不做,因爲這就是偏向鎖的意義,下次需要獲取鎖的時候,判斷一下是否還是偏向自己就ok了。

有沒有細心的盆友發現,鎖對象處於偏向狀態下,和 無鎖狀態下,Mark Word的內容區別在於 偏向的狀態下沒有hashcode字段?那麼hashcode信息不就丟失了?
hash的對象不可被用作偏向鎖。 注意,Mark Word的hashcode是依據內存地址計算的那個,也就是說一旦我們調用了系統的hashcode計算,那麼這個對象就不能被偏向啦~
對於允許偏向的對象在進行hashcode計算時,首先要吊銷(revoke)所有的偏向(不管是有效的還是無效的),然後使用CAS將計算好的hashcode值放到MarkWord中,儘管這僅僅適用於“identity hashcode(使用Object類的hashcode()方法進行計算)”。普通Java類型hashcode的計算需要重載Objecthashcode()方法,但不必要去顯示調用這個方法;因此,對於沒有顯示調用Object#hashcode()方法的類的對象,仍然適用於偏向鎖的機制——可被用作鎖對象使用。關於Hashcode更多信息,可以戳這裏

輕量級鎖

每一個線程都會有一個私有的數據結構,稱爲Moniter Record列表,每一個Moniter Record都是用來做什麼的呢?它會記錄這把鎖的對象是誰、鎖重入數,這個鎖上阻塞或者等待的線程列表,以及從鎖對象中copy來的Mark Word,它包括了對象的HashCode GC age等信息。

每一個被鎖住的對象都會和一個Moniter Record關聯,對象頭中的內容就指向這個線程的Moniter RecordMoniter Record中的Owner指向鎖對象。

爲啥Moniter Record需要copy一份Mark Word,我們可以看到處於鎖定過程中的Mark Word中除了標誌位,其實只有一個地址,它就指向當前這個Moniter Record,試想一下如果Moniter Record不存Mark Word,那麼這個對象的GC年齡就有可能丟失了..

輕量級鎖獲取過程如下:
(1)當對象處於無鎖狀態時(狀態位爲001),線程首先從自己的可用Moniter Record列表中取得一個空閒的Moniter Record,線程通過CAS原子指令設置該Moniter Record的起始地址到對象頭,如果存在其他線程競爭鎖的情況而調用CAS失敗,則只需要簡單的回到monitorenter重新開始獲取鎖的過程即可。

(2)對象已經處於輕量級鎖情況下(狀態爲00),說明當前鎖已經被其他線程鎖住,這個時候當前線程自旋一定次數(或時間),看看鎖的狀態是否改變,如果次數到了狀態還沒有改變,那麼這個線程升級鎖的狀態爲重量級鎖,請求系統接入調度;

重量級鎖

通過系統 互斥信號量接介入來達到同步,代價最高,因爲涉及到了當前線程在用戶態和核心態的切換,這也是爲什麼Java中要做前面優化的原因。

內置鎖三種狀態的比較

  • 偏向鎖
    優點:代價最低,在低競爭的情況下,如果大部分情況都是同一個線程進入臨界區,一旦鎖對象進行了偏向,那麼幾乎沒有什麼成本(僅僅是多了一次是否是自己持有鎖的判斷);
    缺點:如果鎖上有激烈的競爭,那麼偏向帶來的性能優勢就會消失殆盡,因爲偏向之後,還需要撤銷偏向。

  • 輕量級鎖
    優點:競爭的線程不會阻塞,提高了程序的響應性
    缺點:消耗CPU資源

  • 重量級鎖
    優點:不用消耗CPU自旋
    缺點:阻塞線程,響應時間長

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