【大廠面試07期】說一說你對synchronized鎖的理解?

NotFound9發表於2020-06-12

synchronized鎖的原理也是大廠面試中經常會涉及的問題,本文主要通過對以下問題進行分析講解,來幫助大家理解synchronized鎖的原理。

1.synchronized鎖是什麼?鎖的物件是什麼?

2.偏向鎖,輕量級鎖,重量級鎖的執行流程是怎樣的?

3.為什麼說是輕量級,重量級鎖是不公平的?

4.重量級鎖為什麼需要自旋操作?

5.什麼時候會發生鎖升級,鎖降級?

6.偏向鎖,輕量鎖,重量鎖的適用場景,優缺點是什麼?

1.synchronized鎖是什麼?鎖的物件是什麼?

synchronized的英文意思就是同步的意思,就是可以讓synchronized修飾的方法,程式碼塊,每次只能有一個執行緒在執行,以此來實現資料的安全。

一般可以修飾同步程式碼塊、例項方法、靜態方法,加鎖物件分別為同步程式碼塊塊括號內的物件、例項物件、類。

在實現原理上,

  • synchronized修飾同步程式碼塊,javac在編譯時,在synchronized同步塊的進入的指令前和退出的指令後,會分別生成對應的monitorenter和monitorexit指令進行對應,代表嘗試獲取鎖和釋放鎖。
    (為了保證拋異常的情況下也能釋放鎖,所以javac為同步程式碼塊新增了一個隱式的try-finally,在finally中會呼叫monitorexit命令釋放鎖。)
  • synchronized修飾方法,javac為方法的flags屬性新增了一個ACC_SYNCHRONIZED關鍵字,在JVM進行方法呼叫時,發現呼叫的方法被ACC_SYNCHRONIZED修飾,則會先嚐試獲得鎖。
public class SyncTest {
    private Object lockObject = new Object();
    public void syncBlock(){
        //修飾程式碼塊,加鎖物件為lockObject
        synchronized (lockObject){
            System.out.println("hello block");
        }
    }
    //修飾例項方法,加鎖物件為當前的例項物件
    public synchronized void syncMethod(){
        System.out.println("hello method");
    }
    //修飾靜態方法,加鎖物件為當前的類
    public static synchronized void staticSyncMethod(){
        System.out.println("hello method");
    }
}

2.偏向鎖,輕量級鎖,重量級鎖的執行流程是怎樣的?

在JVM中,一個Java物件其實由物件頭+例項資料+對齊填充三部分組成,而物件頭主要包含Mark Word+指向物件所屬的類的指標組成(如果是陣列物件,還會包含長度)。像下圖一樣:

Mark Word:儲存物件自身的執行時資料,例如hashCode,GC分代年齡,鎖狀態標誌,執行緒持有的鎖等等。在32位系統佔4位元組,在64位系統中佔8位元組,所以它能儲存的資料量是有限的,所以主要通過設立是否偏向鎖的標誌位鎖標誌位用於區分其他位數儲存的資料是什麼,具體請看下圖:

鎖資訊都是存在鎖物件的Mark Word中的,當物件狀態為偏向鎖時,Mark Word儲存的是偏向的執行緒ID;當狀態為輕量級鎖時,Mark Word儲存的是指向執行緒棧中Lock Record的指標;當狀態為重量級鎖時,Mark Word為指向堆中的monitor物件的指標。

這是網上找到的一個流程圖,可以先看流程圖,結合著文字來了解執行流程

偏向鎖

Hotspot的作者經過以往的研究發現大多數情況下鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,於是引入了偏向鎖。

簡單的來說,就是主要鎖處於偏向鎖狀態時,會在Mark Word中存當前持有偏向鎖的執行緒ID,如果獲取鎖的執行緒ID與它一致就說明是同一個執行緒,可以直接執行,不用像輕量級鎖那樣執行CAS操作來加鎖和解鎖。

偏向鎖的加鎖過程:

場景一:當鎖物件第一次被執行緒獲得鎖的時候

執行緒發現是匿名偏向狀態(也就是鎖物件的Mark Word沒有儲存執行緒ID),則會用CAS指令,將mark word中的thread id由0改成當前執行緒Id。如果成功,則代表獲得了偏向鎖,繼續執行同步塊中的程式碼。否則,將偏向鎖撤銷,升級為輕量級鎖。

場景二:當獲取偏向鎖的執行緒再次進入同步塊時

發現鎖物件儲存的執行緒ID就是當前執行緒的ID,會往當前執行緒的棧中新增一條Displaced Mark Word為空的Lock Record中,然後繼續執行同步塊的程式碼,因為操縱的是執行緒私有的棧,因此不需要用到CAS指令;由此可見偏向鎖模式下,當被偏向的執行緒再次嘗試獲得鎖時,僅僅進行幾個簡單的操作就可以了,在這種情況下,synchronized關鍵字帶來的效能開銷基本可以忽略。

場景二:當沒有獲得鎖的執行緒進入同步塊時

當沒有獲得鎖的執行緒進入同步塊時,發現當前是偏向鎖狀態,並且儲存的是其他執行緒ID(也就是其他執行緒正在持有偏向鎖),則會進入到撤銷偏向鎖的邏輯裡,一般來說,會在safepoint中去檢視偏向的執行緒是否還存活

  • 如果執行緒存活且還在同步塊中執行,
    則將鎖升級為輕量級鎖,原偏向的執行緒繼續擁有鎖,只不過持有的是輕量級鎖,繼續執行程式碼塊,執行完之後按照輕量級鎖的解鎖方式進行解鎖,而其他執行緒則進行自旋,嘗試獲得輕量級鎖。
  • 如果偏向的執行緒已經不存活或者不在同步塊中,
    則將物件頭的mark word改為無鎖狀態(unlocked)

由此可見,偏向鎖升級的時機為:當一個執行緒獲得了偏向鎖,在執行時,只要有另一個執行緒嘗試獲得偏向鎖,並且當前持有偏向鎖的執行緒還在同步塊中執行,則該偏向鎖就會升級成輕量級鎖。

偏向鎖的解鎖過程

因此偏向鎖的解鎖很簡單,其僅僅將執行緒的棧中的最近一條lock recordobj欄位設定為null。需要注意的是,偏向鎖的解鎖步驟中並不會修改鎖物件Mark Word中的thread id,簡單的說就是鎖物件處於偏向鎖時,Mark Word中的thread id 可能是正在執行同步塊的執行緒的id,也可能是上次執行完已經釋放偏向鎖的thread id,主要是為了上次持有偏向鎖的這個執行緒在下次執行同步塊時,判斷Mark Word中的thread id相同就可以直接執行,而不用通過CAS操作去將自己的thread id設定到鎖物件Mark Word中。
這是偏向鎖執行的大概流程:

輕量級鎖

重量級鎖依賴於底層的作業系統的Mutex Lock來實現的,但是由於使用Mutex Lock需要將當前執行緒掛起並從使用者態切換到核心態來執行,這種切換的代價是非常昂貴的,而在大部分時候可能並沒有多執行緒競爭,只是這段時間是執行緒A執行同步塊,另外一段時間是執行緒B來執行同步塊,僅僅是多執行緒交替執行,並不是同時執行,也沒有競爭,如果採用重量級鎖效率比較低。以及在重量級鎖中,沒有獲得鎖的執行緒會阻塞,獲得鎖之後執行緒會被喚醒,阻塞和喚醒的操作是比較耗時間的,如果同步塊的程式碼執行比較快,等待鎖的執行緒可以進行先進行自旋操作(就是不釋放CPU,執行一些空指令或者是幾次for迴圈),等待獲取鎖,這樣效率比較高。所以輕量級鎖天然瞄準不存在鎖競爭的場景,如果存在鎖競爭但不激烈,仍然可以用自旋鎖優化,自旋失敗後再升級為重量級鎖。

輕量級鎖的加鎖過程

JVM會為每個執行緒在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,我們稱為Displaced Mark Word。如果一個執行緒獲得鎖的時候發現是輕量級鎖,會把鎖的Mark Word複製到自己的Displaced Mark Word裡面。

然後執行緒嘗試用CAS操作將鎖的Mark Word替換為自己執行緒棧中拷貝的鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示Mark Word已經被替換成了其他執行緒的鎖記錄,說明在與其它執行緒競爭鎖,當前執行緒就嘗試使用自旋來獲取鎖。

自旋:不斷嘗試去獲取鎖,一般用迴圈來實現。

自旋是需要消耗CPU的,如果一直獲取不到鎖的話,那該執行緒就一直處在自旋狀態,白白浪費CPU資源。

JDK採用了適應性自旋,簡單來說就是執行緒如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。

自旋也不是一直進行下去的,如果自旋到一定程度(和JVM、作業系統相關),依然沒有獲取到鎖,稱為自旋失敗,那麼這個執行緒會阻塞。同時這個鎖就會升級成重量級鎖。

輕量級鎖的釋放流程

在釋放鎖時,當前執行緒會使用CAS操作將Displaced Mark Word的內容複製回鎖的Mark Word裡面。如果沒有發生競爭,那麼這個複製的操作會成功。如果有其他執行緒因為自旋多次導致輕量級鎖升級成了重量級鎖,那麼CAS操作會失敗,此時會釋放鎖並喚醒被阻塞的執行緒。
輕量級鎖的加鎖解鎖流程圖:

重量級鎖

當多個執行緒同時請求某個重量級鎖時,重量級鎖會設定幾種狀態用來區分請求的執行緒:

Contention List:所有請求鎖的執行緒將被首先放置到該競爭佇列,我也不知道為什麼網上的文章都叫它佇列,其實這個佇列是先進後出的,更像是棧,就是當Entry List為空時,Owner執行緒會直接從Contention List的佇列尾部(後加入的執行緒中)取一個執行緒,讓它成為OnDeck執行緒去競爭鎖。(主要是剛來獲取重量級鎖的執行緒是回進行自旋操作來獲取鎖,獲取不到才會進從Contention List,所以OnDeck執行緒主要與剛進來還在自旋,還沒有進入到Contention List的執行緒競爭)

Entry List:Contention List中那些有資格成為候選人的執行緒被移到Entry List,主要是為了減少對Contention List的併發訪問,因為既會新增新執行緒到隊尾,也會從隊尾取執行緒。

Wait Set:那些呼叫wait方法被阻塞的執行緒被放置到Wait Set。

OnDeck:任何時刻最多隻能有一個執行緒正在競爭鎖,該執行緒稱為OnDeck。

Owner:獲得鎖的執行緒稱為Owner

!Owner:釋放鎖的執行緒

重量級鎖執行流程:

流程圖如下:

步驟1是執行緒在進入Contention List時阻塞等待之前,程會先嚐試自旋使用CAS操作獲取鎖,如果獲取不到就進入Contention List佇列的尾部。

步驟2是Owner執行緒在解鎖時,如果Entry List為空,那麼會先將Contention List中佇列尾部的部分執行緒移動到Entry List

步驟3是Owner執行緒在解鎖時,如果Entry List不為空,從Entry List中取一個執行緒,讓它成為OnDeck執行緒,Owner執行緒並不直接把鎖傳遞給OnDeck執行緒,而是把鎖競爭的權利交給OnDeck,OnDeck需要重新競爭鎖,JVM中這種選擇行為稱為 “競爭切換”。(主要是與還沒有進入到Contention
List,還在自旋獲取重量級鎖的執行緒競爭)

步驟4就是OnDeck執行緒獲取到鎖,成為Owner執行緒進行執行。

步驟5就是Owner執行緒呼叫鎖物件的wait()方法進行等待,會移動到Wait Set中,並且會釋放CPU資源,也同時釋放鎖,

步驟6.就是當其他執行緒呼叫鎖物件的notify()方法,之前呼叫wait方法等待的這個執行緒才會從Wait Set移動到Entry List,等待獲取鎖。

3.為什麼說是輕量級,重量級鎖是不公平的?

偏向鎖由於不涉及到多個執行緒競爭,所以談不上公平不公平,輕量級鎖獲取鎖的方式是多個執行緒進行自旋操作,然後使用用CAS操作將鎖的Mark Word替換為指向自己執行緒棧中拷貝的鎖記錄的指標,所以誰能獲得鎖就看運氣,不看先後順序。重量級鎖不公平主要在於剛進入到重量級的鎖的執行緒不會直接進入Contention List佇列,而是自旋去獲取鎖,所以後進來的執行緒也有一定的機率先獲得到鎖,所以是不公平的。

4.重量級鎖為什麼需要自旋操作?

因為那些處於ContetionList、EntryList、WaitSet中的執行緒均處於阻塞狀態,阻塞操作由作業系統完成(在Linxu下通過pthread_mutex_lock函式)。執行緒被阻塞後便進入核心(Linux)排程狀態,這個會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能。如果同步塊中程式碼比較少,執行比較快的話,後進來的執行緒先自旋獲取鎖,先執行,而不進入阻塞狀態,減少額外的開銷,可以提高系統吞吐量。

5.什麼時候會發生鎖升級,鎖降級?

偏向鎖升級為輕量級鎖:
就是有不同的執行緒競爭鎖時。具體來看就是當一個執行緒發現當前鎖狀態是偏向鎖,然後鎖物件儲存的Thread id是其他執行緒的id,並且去Thread id對應的執行緒棧查詢到的lock record的obj欄位不為null(代表當前持有偏向鎖的執行緒還在執行同步塊)。那麼該偏向鎖就會升級成輕量級鎖。

輕量級鎖升級為重量級鎖:
就是在輕量級鎖中,沒有獲取到鎖的執行緒進行自旋,自旋到一定次數還沒有獲取到鎖就會進行鎖升級,因為自旋也是佔用CPU的,長時間自旋也是很耗效能的。
鎖降級
因為如果沒有多執行緒競爭,還是使用重量級鎖會造成額外的開銷,所以當JVM進入SafePoint安全點(可以簡單的認為安全點就是所有使用者執行緒都停止的,只有JVM垃圾回收執行緒可以執行)的時候,會檢查是否有閒置的Monitor,然後試圖進行降級。

6.偏向鎖,輕量鎖,重量鎖的適用場景,優缺點是什麼?

篇幅有限,下面是各種鎖的優缺點,來自《併發程式設計的藝術》:

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

參考連結:
https://github.com/farmerjohngit/myblog/issues/12
http://redspider.group:4000/article/02/9.html
https://blog.csdn.net/bohu83/article/details/51141836
https://blog.csdn.net/Dev_Hugh/article/details/106577862

相關文章