【大廠面試07期】說一說你對synchronized鎖的理解?
- 2020 年 6 月 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 record
的obj
字段設置為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。 | 線程阻塞,響應時間緩慢。 | 追求吞吐量。同步塊執行速度較長。 |
參考鏈接:
//github.com/farmerjohngit/myblog/issues/12
//redspider.group:4000/article/02/9.html
//blog.csdn.net/bohu83/article/details/51141836
//blog.csdn.net/Dev_Hugh/article/details/106577862