面試題:再談Synchronized實現原理!
前言
線程安全是并發編程中的重要關注點。
造成線程安全問題的主要誘因有兩點,一是存在共享數據(也稱臨界資源),二是存在多條線程共同操作共享數據。
為了解決這個問題,我們可能需要這樣一個方案,當存在多個線程操作共享數據時,需要保證同一時刻有且只有一個線程在操作共享數據,其他線程必須等到該線程處理完數據后再進行。
在 Java 中,關鍵字 Synchronized可以保證在同一個時刻,只有一個線程可以執行某個方法或者某個代碼塊(主要是對方法或者代碼塊中存在共享數據的操作)。
下面來一起探索Synchronized的基本使用、實現機制。
面試題來自:社招一年半面經分享(含阿里美團頭條京東滴滴)
用法
兩類鎖:
對象鎖:包括方法鎖(默認鎖對象為this當前實例對象)和同步代碼塊鎖(自己指定鎖對象)。
類鎖:指Synchronized修飾靜態的方法或指定鎖為Class對象。
當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,而退出或拋出異常時必須釋放鎖。
- 給普通方法加鎖時,上鎖的對象是 this;
- 給靜態方法加鎖時,鎖的是 class 對象;
- 給代碼塊加鎖,可以指定一個具體的對象作為鎖。
代碼示例如下:
- public class SynchronizedTest {
- /**
- * 修飾靜態方法, 等同于下面注釋的方法
- */
- public synchronized static void test1() {
- System.out.println("月伴飛魚");
- }
- // public static void test1() {
- // synchronized (SynchronizedTest.class){
- // System.out.println("月伴飛魚");
- // }
- // }
- /**
- * 修飾實例方法, 等同于下面注釋的方法
- */
- public synchronized void test2(){
- System.out.println("月伴飛魚");
- }
- // public void test2(){
- // synchronized (this){
- // System.out.println("月伴飛魚");
- // }
- // }
- /**
- * 修飾代碼塊
- */
- public void test3(){
- synchronized (this){
- System.out.println("月伴飛魚");
- }
- }
- }
多線程訪問同步方法的幾種情況:
兩個線程同時訪問一個對象的同步方法。
- 由于同步方法鎖使用的是this對象鎖,同一個對象的this鎖只有一把,兩個線程同一時間只能有一個線程持有該鎖,所以該方法將會串行運行。
兩個線程訪問的是兩個對象的同步方法。
- 由于兩個對象的this鎖互不影響,Synchronized將不會起作用,所以該方法將會并行運行。
兩個線程訪問的是Synchronized的靜態方法。
- Synchronized修飾的靜態方法獲取的是當前類模板對象的鎖,該鎖只有一把,無論訪問多少個該類對象的方法,都將串行執行。
同時訪問同步方法與非同步方法
- 非同步方法不受影響。
訪問同一個對象的不同的普通同步方法。
- 由于this對象鎖只有一個,不同線程訪問多個普通同步方法將串行運行。
同時訪問靜態Synchronized和非靜態Synchronized方法
- 靜態Synchronized方法的鎖為class對象的鎖,非靜態Synchronized方法鎖為this的鎖,它們不是同一個鎖,所以它們將并行運行。
使用優化
大家在使用synchronized關鍵字的時候,可能經常會這么寫:
- synchronized (this) {
- ...
- }
它的作用域是當前對象,鎖的就是當前對象,誰拿到這個鎖誰就可以運行它所控制的代碼。
當有一個明確的對象作為鎖時,就可以這么寫,但是當沒有一個明確的對象作為鎖,只想讓一段代碼同步時,可以創建一個特殊的變量(對象)來充當鎖:
- public class Demo {
- private final Object lock = new Object();
- public void methonA() {
- synchronized (lock) {
- ...
- }
- }
- }
這樣寫沒問題。但是用new Object()作為鎖對象是否是一個最佳選擇呢?
我在StackOverFlow看到這么一篇文章:object-vs-byte0-as-lock
大意就是用new byte[0]作為鎖對象更好,會減少字節碼操作的次數。
- public class Demo {
- private final byte[] lock = new byte[0];
- }
具體細節大家,可以看看這篇文章,算提供一種思路吧!
實現原理
因為Synchronized鎖的是對象,在講解原理之前先介紹下對象結構相關知識。
HotSpot虛擬機中,對象在內存中存儲的布局可以分為三塊區域:對象頭、實例數據和對齊填充。
對象頭
對象頭包括兩部分信息:運行時數據Mark Word和類型指針
如果對象是數組對象,那么對象頭占用3個字寬(Word)(需要記錄數組長度),如果對象是非數組對象,那么對象頭占用2個字寬(1word = 2Byte = 16bit)
對象頭的類型指針指向該對象的類元數據,虛擬機通過這個指針可以確定該對象是哪個類的實例
Mark Word
用于存儲對象自身的運行時數據, 如哈希碼(HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等等,它是實現輕量級鎖和偏向鎖的關鍵。
這部分數據的長度在32位和64位的虛擬機(暫不考慮開啟壓縮指針的場景)中分別為32個和64個Bits。
Synchronized鎖對象就存儲在MarkWord中,下面是MarkWord的布局:
32位虛擬機
實例數據
實例數據就是在程序代碼中所定義的各種類型的字段,包括從父類繼承的
對齊填充
由于HotSpot的自動內存管理要求對象的起始地址必須是8字節的整數倍,即對象的大小必須是8字節的整數倍,對象頭的數據正好是8的整數倍,所以當實例數據不夠8字節整數倍時,需要通過對齊填充進行補全
意思是每次分配的內存大小一定是8的倍數,如果對象頭+實例數據的值不是8的倍數,那么會重新計算一個較大值,進行分配
底層實現
下面的代碼,在命令行執行 javac,然后再執行javap -v -p,就可以看到它具體的字節碼。
可以看到,在字節碼的體現上,它只給方法加了一個 flag:ACC_SYNCHRONIZED。
- synchronized void syncMethod() {
- System.out.println("syncMethod");
- }
- synchronized void syncMethod();
- descriptor: ()V
- flags: ACC_SYNCHRONIZED
- Code:
- stack=2, locals=1, args_size=1
- 0: getstatic #4
- 3: ldc #5
- 5: invokevirtual #6
- 8: return
我們再來看下同步代碼塊的字節碼。可以看到,字節碼是通過 monitorenter 和monitorexit 兩個指令進行控制的。
- void syncBlock(){
- synchronized (Test.class){
- }
- }
- void syncBlock();
- descriptor: ()V
- flags:
- Code:
- stack=2, locals=3, args_size=1
- 0: ldc #2
- 2: dup
- 3: astore_1
- 4: monitorenter
- 5: aload_1
- 6: monitorexit // 兩個monitorexit是表示正常退出和異常退出的場景
- 7: goto 15
- 10: astore_2
- 11: aload_1
- 12: monitorexit
- 13: aload_2
- 14: athrow
- 15: return
- Exception table:
- from to target type
- 5 7 10 any
- 10 13 10 any
這兩者雖然顯示效果不同,但他們都是通過 monitor 來實現同步的。
其中在Java虛擬機(HotSpot)中,Monitor是由ObjectMonitor實現的,其主要數據結構如下(位于HotSpot虛擬機源碼ObjectMonitor.hpp文件,C++實現的):
- ObjectMonitor() {
- _header = NULL;
- _count = 0; // 用來記錄該對象被線程獲取鎖的次數,這也說明了synchronized是可重入的
- _waiters = 0,
- _recursions = 0;
- _object = NULL;
- _owner = NULL; // 指向持有ObjectMonitor對象的線程
- _WaitSet = NULL; // 處于wait狀態的線程,會被加入到_WaitSet,調用了wait方法之后會進入這里
- _WaitSetLock = 0 ;
- _Responsible = NULL ;
- _succ = NULL ;
- _cxq = NULL ;
- FreeNext = NULL ;
- _EntryList = NULL ; // 處于等待鎖block狀態的線程,會被加入到該列表
- _SpinFreq = 0 ;
- _SpinClock = 0 ;
- OwnerIsThread = 0 ;
- }
每個 Java 對象在 JVM 的對等對象的頭中保存鎖狀態,指向 ObjectMonitor。
ObjectMonitor 保存了當前持有鎖的線程引用,EntryList 中保存目前等待獲取鎖的線程,WaitSet 保存 wait 的線程。
還有一個計數器count,每當線程獲得 monitor 鎖,計數器 +1,當線程重入此鎖時,計數器還會 +1。當計數器不為 0 時,其它嘗試獲取 monitor 鎖的線程將會被保存到EntryList中,并被阻塞。
當持有鎖的線程釋放了monitor 鎖后,計數器 -1。當計數器歸位為 0 時,所有 EntryList 中的線程會嘗試去獲取鎖,但只會有一個線程會成功,沒有成功的線程仍舊保存在 EntryList 中。
詳細流程:
- 加鎖時,即遇到Synchronized關鍵字時,線程會先進入monitor的_EntryList隊列阻塞等待。
- 如果monitor的_owner為空,則從隊列中移出并賦值與_owner。
- 如果在程序里調用了wait()方法,則該線程進入_WaitSet隊列。我們都知道wait方法會釋放monitor鎖,即將_owner賦值為null并進入_WaitSet隊列阻塞等待。這時其他在_EntryList中的線程就可以獲取鎖了。
- 當程序里其他線程調用了notify/notifyAll方法時,就會喚醒_WaitSet中的某個線程,這個線程就會再次嘗試獲取monitor鎖。如果成功,則就會成為monitor的owner。
- 當程序里遇到Synchronized關鍵字的作用范圍結束時,就會將monitor的owner設為null,退出。
Java對象如何與Monitor關聯
鎖優化
相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虛擬機對 Synchronized 內置鎖的性能進行了很多優化,包括自適應的自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖等。
自適應的自旋鎖
在 JDK 1.6 中引入了自適應的自旋鎖來解決長時間自旋的問題。
比如,如果最近嘗試自旋獲取某一把鎖成功了,那么下一次可能還會繼續使用自旋,并且允許自旋更長的時間;但是如果最近自旋獲取某一把鎖失敗了,那么可能會省略掉自旋的過程,以便減少無用的自旋,提高效率。
鎖消除
經過逃逸分析之后,如果發現某些對象不可能被其他線程訪問到,那么就可以把它們當成棧上數據,棧上數據由于只有本線程可以訪問,自然是線程安全的,也就無需加鎖,所以會把這樣的鎖給自動去除掉。
鎖粗化
按理來說,同步塊的作用范圍應該盡可能小,僅在共享數據的實際作用域中才進行同步,這樣做的目的是為了使需要同步的操作數量盡可能縮小,縮短阻塞時間,如果存在鎖競爭,那么等待鎖的線程也能盡快拿到鎖。
但是加鎖解鎖也需要消耗資源,如果存在一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗。
鎖粗化就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個范圍更大的鎖,避免頻繁的加鎖解鎖操作。
偏向鎖/輕量級鎖/重量級鎖
JVM 默認會優先使用偏向鎖,如果有必要的話才逐步升級,這大幅提高了鎖的性能。
鎖升級
鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖
隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級
從JDK 1.6中默認是開啟偏向鎖,可以通過-XX:-UseBiasedLocking來禁用偏向鎖
偏向鎖
在只有一個線程使用了鎖的情況下,偏向鎖能夠保證更高的效率。
具體過程是這樣的:當第一個線程第一次訪問同步塊時,會先檢測對象頭 Mark Word 中的標志位 Tag 是否為 01,以此判斷此時對象鎖是否處于無鎖狀態或者偏向鎖狀態。
線程一旦獲取了這把鎖,就會把自己的線程 ID 寫到 MarkWord 中,在其他線程來獲取這把鎖之前,鎖都處于偏向鎖狀態。
當下一個線程參與到偏向鎖競爭時,會先判斷 MarkWord 中保存的線程 ID 是否與這個線程 ID 相等,如果不相等,會立即撤銷偏向鎖,升級為輕量級鎖。
輕量級鎖
當鎖處于輕量級鎖的狀態時,就不能夠再通過簡單地對比標志位 Tag 的值進行判斷,每次對鎖的獲取,都需要通過自旋。
當然,自旋也是面向不存在鎖競爭的場景,比如一個線程運行完了,另外一個線程去獲取這把鎖;但如果自旋失敗達到一定的次數,鎖就會膨脹為重量級鎖。
重量級鎖
重量級鎖,這種情況下,線程會掛起,進入到操作系統內核態,等待操作系統的調度,然后再映射回用戶態。系統調用是昂貴的,所以重量級鎖的名稱由此而來。
如果系統的共享變量競爭非常激烈,鎖會迅速膨脹到重量級鎖,這些優化就名存實亡。
如果并發非常嚴重,可以通過參數-XX:-UseBiasedLocking 禁用偏向鎖,理論上會有一些性能提升,但實際上并不確定。
常見面試題
Synchronized和Lock的區別:
- Synchronized屬于JVM層面,底層通過 monitorenter 和 monitorexit 兩個指令實現,Lock是API層面的東西,JUC提供的具體類
- Synchronized不需要用戶手動釋放鎖,當Synchronized代碼執行完畢之后會自動讓線程釋放持有的鎖,Lock需要一般使用try-finally模式去手動釋放鎖
- Synchronized是不可中斷的,除非拋出異常或者程序正常退出,Lock可中斷,使用lockInterruptibly,調用interrupt方法可中斷
- Synchronized是非公平鎖,Lock默認是非公平鎖,但是可以通過構造函數傳入boolean類型值更改是否為公平鎖
- 鎖是否能綁定多個條件,Synchronized沒有condition的說法,要么喚醒所有線程,要么隨機喚醒一個線程,Lock可以使用condition實現分組喚醒需要喚醒的線程,實現精準喚醒
- Synchronized 鎖只能同時被一個線程擁有,但是 Lock 鎖沒有這個限制,例如在讀寫鎖中的讀鎖,是可以同時被多個線程持有的,可是 Synchronized做不到
- 性能區別:在 Java 5 以及之前Synchronized的性能比較低,但是到了 Java 6 以后 JDK 對 Synchronized 進行了很多優化,比如自適應自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等
本文轉載自微信公眾號「月伴飛魚」,可以通過以下二維碼關注。轉載本文請聯系月伴飛魚公眾號。