《Java并發編程的藝術》中說到「如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖」,并且下文所配的流程圖中明確表示自旋失敗后才會升級為重量級鎖,但《深入理解Java虛擬機》又說「如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖」,到底會不會呢?其實相信synchronized源碼很少有人愿意去扒去看,本文會盡量用簡潔易懂的方式說清synchronized的原理。
只對實現原理感興趣可以直接跳過到「synchronized實現原理」
synchronized基本使用
一般有三種方式:
修飾普通方法:鎖this
// 1. synchronized用在普通方法上,默認的鎖就是this,當前實例public synchronized void method() {}
修飾靜態方法:鎖this.class
// 2. synchronized用在靜態方法上,默認的鎖就是當前所在的Class類// 所以無論是哪個線程訪問它,需要的鎖都只有一把public static synchronized void method() {}
同步代碼塊:自定義鎖對象
自定義鎖對象可以是實例,也可以是Class對象
synchronized (this) {}
synchronized(SynchronizedObjectLock.class){}
拋出異常會釋放鎖
無論正常退出還是拋出異常,synchronized都保證能夠釋放鎖。
鎖與happens-before規則
我們知道,解鎖操作 happens-before 加鎖,因此:
首先有個變量a,沒有用volatile修飾
int a = 0;
線程A先執行:
public synchronized void writer() { // 1a++; // 2
} // 3
線程B后執行:
public synchronized void reader() { // 4int i = a; // 5
} // 6
由h-b規則,3 h-b 4,再由as if serial和傳遞性原則,因此2 h-b 5,而h-b從開發人員的角度來說,你就可以理解為2在5之前執行,并且2的結果對5可見,因此5處讀到的a,一定為1。
synchronized的內存語義
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
當線程獲取鎖時,JMM會把該線程對應的本地內存置為無效
可以看到:
鎖釋放與volatile寫有相同的內存語義;
鎖獲取與volatile讀有相同的內存語義。
synchronized實現原理
下面我用盡量清晰簡潔,繞過虛擬機源碼的方式來講一下:
會跳過一些源碼細節的實現,不會影響整體流程和理解
要了解實現原理,第一步我會先看一下字節碼指令:
透過字節碼看異常如何釋放鎖
synchronized修飾的方法會被加上 ACC_SYNCHRONIZED的flag。
而同步代碼塊的字節碼是這樣的:
monitorenter
...
monitorexit
goto xxx
monitorexit
athrow
returnException table:
from to target type4 14 17 any17 20 17 any
可以看到,monitorenter和monitorexit指令分別對應synchronized同步塊的進入和退出。
有兩個monitorexit,因為javac為同步代碼塊添加了一個隱式的try-finally,在finally中會調用monitorexit命令釋放鎖。如果不知道字節碼的Exception table是什么可以參考:異常處理實現原理
盡管字節碼通常都能幫助我們更好地理解語義,但關于synchronized的語義也就到此為止了,接下來就要深入虛擬機源碼看看monitorenter(獲取鎖)和monitorexit(釋放鎖)到底都干了些什么,不過在此之前:
因為synchronized有四種鎖狀態,而鎖狀態的實現依賴于Java對象的mark word,這是實現synchronized的基礎,我們先來看mark word如何表達鎖狀態的。
Java中的每一個對象都可以作為一個鎖,包括Class對象。
四種鎖狀態
Java對象頭的mark word
注意輕/重鎖的mark word內是持有一個指向鎖記錄的指針的。
因此,一個對象其實有四種鎖狀態,級別由低到高:
無鎖狀態
偏向鎖狀態
輕量級鎖狀態
重量級鎖狀態
1、無鎖
釋放輕量級鎖,沒有線程在嘗試獲取鎖,也沒有線程持有鎖(正在執行同步代碼塊),就是無鎖。
2、偏向鎖(JDK15被廢棄)
偏向鎖在JDK1.6引入,在JDK15被廢棄,了解即可。如果一定要用,需要手動打開:
-XX:+UseBiasedLocking
人們發現大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,于是有了偏向鎖。
偏向鎖顧名思義,偏向于第一個訪問鎖的線程。偏向鎖在資源無競爭情況下消除了同步語句,連CAS操作都不做了,提高了程序的運行性能。
當開啟偏向鎖功能時,創建的新對象是可偏向狀態,此時mark word中的thread id為0,也叫做匿名偏向。當該對象第一次被CAS成功時,成為「偏向鎖」。
在該線程又一次嘗試獲取該對象鎖時,發現thread id就是自己,就可以不做CAS直接認為已經拿到了鎖并執行同步代碼塊中的代碼。
注意上述的所有,都只出現了一個線程
當第二個線程出現并嘗試獲取鎖,無論如何都會升級成「輕量級鎖」。
如果第一個線程正在執行同步代碼塊,鎖偏向的線程繼續擁有鎖,當前線程升級該鎖為「輕量級鎖」。
如果第一個線程不在執行同步代碼塊,先將對象頭的mark word改為無鎖狀態,再升級為「輕量級鎖」。
也就是是要有兩個線程嘗試獲取鎖,不論是否出現資源競爭,升級為「輕量級鎖」。
3、輕量級鎖
升級到「輕量級鎖」的條件是:存在多個線程嘗試CAS獲取同一把鎖,盡管彼此之間互不影響。而「輕量級鎖」繼續膨脹為「重量級鎖」的條件是:只要CAS失敗,就升級,即發生了:一個線程正在執行同步代碼塊的同時,另一個線程嘗試獲取鎖。
輕量級鎖會自旋嗎
自旋:不斷嘗試去獲取鎖,一般用循環來實現。
這是不對的,是網上最常見的錯誤之一,你問chatGPT他也是這個答案,但這就是個錯誤的答案。因為前面說的很清楚了,只要發生哪怕一次CAS失敗,就不是「輕量級鎖」了,何來自旋呢?
自旋的說法從何而來
《Java并發編程的藝術》(2015)原文是:
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
《深入淺出Java多線程1.0.0》原文是:
然后線程嘗試用CAS將鎖的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示Mark Word已經被替換成了其他線程的鎖記錄,說明在與其它線程競爭鎖,當前線程就嘗試使用自旋來獲取鎖。
總之,以上兩位作者認為:發生競爭,自旋,并沒有指出自旋前會發生鎖膨脹。
《深入理解Java虛擬機》(2019)原文是:
如果這個更新操作失敗了,那就意味著至少存在一條線程與當前線程競爭獲取該對象的鎖。虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個鎖對象已經被其他線程搶占了。如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖,鎖標志的狀態值變為“10”,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也必須進入阻塞狀態。
周志明大大的意思是:出現兩條以上的線程爭用同一個鎖的情況,就要升級為重量級鎖,沒有指出升級為重量級鎖前要自旋。
顯然,這兩種觀點是有沖突的,核心問題在于:
輕量級鎖狀態下,發生資源競爭,到底是自旋,還是立刻鎖膨脹?
如何考證說法的正確性
那么我們也只能自己去看源碼來驗證說法的正確性了(但很少有人愿意看吧)
下文我會盡量清楚地用文字表達出源碼傳達的意思
輕量級鎖實現原理
獲取鎖
發現是無鎖狀態,線程會把鎖的Mark Word復制到自己的Displaced Mark Word(棧幀中的一塊空間) ,然后通過CAS嘗試將鎖的Mark Word修改為一根指針,指向自己的Displaced Mark Word(Displaced Mark Word與原mark word的內容一模一樣,保存了HashCode,GC年齡等信息)
發現處于輕量級鎖狀態
如果輕量級鎖的markword指向自己的Displaced Mark Word,代表重入鎖,那么獲取鎖成功(如果是重入,會將markword改為null,空指針,即0)
如果輕量級鎖的markword不是指向自己,鎖膨脹,升級為「重量級鎖」
CAS失敗直接膨脹
釋放鎖
首先,遍歷線程棧,拿到所有需要做解鎖操作的鎖對象:
如果是null,代表可重入的鎖,直接解鎖成功
如果不是重入的鎖:
還原成功,輕量級鎖解鎖成功
還原失敗,仍然是「嘗試解鎖重量級鎖」
如果markword被修改,說明發生了競爭,已經成為「重量級鎖」了,「嘗試解鎖重量級鎖」
如果markword沒被修改,嘗試CAS還原對象的markword
補充說明:線程A正在執行同步代碼塊時,此時有線程CAS失敗,雖然升級為「重量級鎖」,但仍然由線程A持有鎖,「如何膨脹為重量級鎖」后文馬上分析
4、重量級鎖
為了實現鎖膨脹,避免并發膨脹鎖,定義了四種膨脹鎖狀態:
膨脹完畢
膨脹中
無鎖
輕量級鎖
下面依次對這些情況的膨脹進行分析:
重量級鎖的生成/鎖膨脹
若膨脹完畢,直接返回monitor
若膨脹中,線程等待一會,直到別的線程膨脹完畢,然后拿到別人生成的monitor
從輕量級鎖開始膨脹:
創建monitor對象
CAS將鎖狀態修改為「膨脹中」
將markword保存至monitor
設置持有monitor的線程
將monitor地址設置為mark word
返回monitor對象
失敗,說明別人在膨脹了,等待,然后返回別人生成的monitor
成功:
從無鎖開始膨脹,差不多:
創建monitor對象
將markword保存至monitor
CAS將鎖狀態修改為「膨脹中」
失敗,說明別人在膨脹了,等待,然后返回別人生成的monitor
成功,返回monitor對象
重量級鎖實現原理
生成了重量級鎖,mark word會指向堆中實際生成的monitor對象,我們先來看看monitor對象的結構:
Contention List(cxq):所有請求鎖的線程將被首先放置到該競爭隊列,是先進后出的棧結構
Entry List:Contention List中那些有資格成為候選人的線程被移到Entry List
Wait Set:那些調用wait方法被阻塞的線程被放置到Wait Set
OnDeck:任何時刻最多只能有一個線程正在競爭鎖,該線程稱為OnDeck
Owner:獲得鎖的線程稱為Owner
!Owner:釋放鎖的線程
獲取鎖
對于重量級鎖,嘗試獲取鎖具體是指:嘗試用CAS將monitor對象的Owner從nullptr改變為自己
當一個線程嘗試獲得重量級鎖時
首先嘗試「自旋」,調用trySpin方法獲取鎖,如果第一次失敗,再進行一次trySpin方法(最壞情況拿不到鎖會調用兩次trySpin),然后『用CAS的方式進入cxq』
進入cxq后,陷入「死循環」,死循環中,可能會從cxq轉移到EntryList,可能阻塞,也可能調用trySpin方法自旋。后文再詳細分析「死循環」
可以看到「死循環」的實現也依賴trySpin自旋,因此我們先來看看「自旋」的實現邏輯:
1、自旋鎖
自旋:不斷嘗試去獲取鎖,一般用循環來實現。
如果是單核CPU,自旋是無意義的,所以只有多處理器才會開啟自旋功能
自旋的出現,是為了避免切換到內核態,因為線程的阻塞和喚醒依賴內核,我們希望能夠一定程度上避免這種內核態與用戶態的切換,因此有了「自旋鎖」。那么自旋多少次更合適呢?
在鎖很快被釋放時,自旋既不會帶來CPU資源的浪費,還能提高運行效率。此時自旋次數過少,可能會導致沒能順利拿到鎖,即使結束自旋后不久鎖就被釋放了。
在鎖很久才被釋放時,自旋空轉占用CPU資源卻遲遲拿不到鎖,造成過多的CPU資源浪費。此時自旋次數過多,反而會得不償失。
因此,JDK發明了自適應自旋,來適應各種情況的鎖。
自適應自旋
自適應自旋為了權衡自旋次數過多和過少帶來的弊端,它的基本思想是:
自旋成功拿到鎖了,說明你下次成功的概率也很大,下次自旋的次數會更多
自旋失敗,說明你下次也大概率拿不到,下次自旋的次數會更少
自適應自旋參數如下:
自旋邏輯:trySpin
首選預自旋11次(避免預自旋次數設置為0,源碼后面對這個參數加了1),如果沒拿到鎖:
開始自旋5000次(假設是第一次開始自旋,上限就為5000)
成功,下次+100,下次可以最多自旋5100次
失敗,下次- 200,下次可以最多自旋4800次,不會少于1000次
2、死循環
死循環主要是在「阻塞」和「自旋」之間切換
park阻塞,注意不會移動到WaitSet中
unpark喚醒,再次調用trySpin方法自旋獲取鎖,如果失敗,陷入阻塞
只有釋放鎖時,才會調用unpark喚醒,進入自旋狀態,此時并不是一定能拿到鎖的。
喚醒的時機
釋放鎖時才會喚醒,且只會喚醒一個,喚醒邏輯取決于Policy參數。
cxq和EntryList內線程的行為
這兩個區域內的線程幾乎是全阻塞的,這兩個區域內的線程,保證最多只有一個線程去競爭鎖資源,這個被『釋放鎖時喚醒的唯一的線程』叫「假定繼承人」,即Monitor結構中的「OnDeck」。
注意:只保證所有阻塞的線程,只有一個去競爭鎖資源,仍然可能被外來的線程在進入cxq之前就搶到了鎖,所以說synchronized是不公平的。
EntryList內的線程全部來自cxq,在釋放鎖與調用notify方法時,可能進入EntryList
釋放鎖
通過CAS的方式將Monitor結構的Owner修改為nullptr
根據QMode參數的不同,執行不同的邏輯
因為QMode默認值為0,我們來看一下默認的邏輯:
如果EntryList和cxq均為空:什么也不做
如果EntryList非空:就取EntryList首元素喚醒
如果EntryList為空,cxq非空:將cxq的所有線程放到EntryList,再喚醒EntryList首元素;
鎖被持有時,EntryList和cxq的所有線程都阻塞,有且只有鎖釋放這唯一一個行為能夠喚醒其中的一個線程。
為什么要區分cxq和EntryList
是為了解決CAS的ABA問題,也能分散請求,提高性能。
cxq和EntryList都是為了存儲所有阻塞的線程,但是:
釋放鎖并喚醒時,只會喚醒EntryList的線程,這是刪除操作
線程自旋次數過多需要被阻塞時,只會插入cxq隊列,這是添加操作
把這兩種操作分離開來有什么好處呢?
提高性能
由于鎖只有一把,因此做刪除操作的線程只有一個,不存在線程安全問題,不需要做CAS,如果和添加操作混在一起,就不得不考慮線程安全問題了。這樣只需要在cxq內考慮CAS即可。
解決ABA問題
因為多個線程同時add,不會有某個線程出現在cxq里兩次,因此只add不會有ABA問題。而一旦存在刪除操作,那么ABA問題就是有可能的。
可感知的鎖控制權
現在知道了加解鎖的原理,那其實我們已經有能力知道,釋放鎖時會喚醒哪個線程。(暫時不考慮wait/notify)
結論:先阻塞的線程,最晚獲得鎖。
有三個線程,t1,t2,t3。這三個線程都自旋失敗,插入cxq,由于是個棧,越晚進入cxq的,反而越早進入EntryList,順序為t3,t2,t1。而喚醒時是按照EntryList的順序去喚醒的,因此「并不是所謂的隨機喚醒」。當然,如果此時有別的線程t4自旋未進入cxq,是有可能拿到鎖的,但我們保證:t3先于t2被喚醒,t2先于t1被喚醒
階段性小結(一)
到這里,應該對鎖機制非常熟悉了,你應該清楚:
Monitor鎖結構
自旋的原理和應用,自旋不會出現在輕量級鎖
重量級鎖加解鎖的邏輯
我們趁熱打鐵來學習一下wait/notify的底層原理,至今仍未露面的WaitSet終于要登場了,學完wait/notify整個synchronized也就 “證據鏈閉環” 了。
從趁熱打鐵的角度,趁你還對加解鎖和Monitor結構足夠熟悉,我非常推薦直接跳到「wait/notify底層原理」看,當然,在此之前請確保你對wait/notify的基礎知識足夠了解
等待通知機制:wait/notify
wait/notify必備的基礎知識
wait/notify只能用在synchronized代碼塊內部,且必須是重量級鎖。
只有持有鎖的線程能夠調用wait/notify方法
調用wait會使當前線程釋放鎖并陷入阻塞狀態
從wait()方法返回的前提是獲得了調用對象的鎖
可以喚醒一個(notify)或多個(notifyAll)
調用notify無法保證被喚醒的線程一定拿到鎖
當調用一個鎖對象的wait或notify方法時,如當前鎖的狀態是偏向鎖或輕量級鎖則會先膨脹成重量級鎖。
wait/notify基本使用
等待通知基本模型
等待者:
synchronized(對象) {while(條件不滿足) {對象.wait();}對應的處理邏輯
}
通知者:
synchronized(對象) {改變條件對象.notifyAll();
}
等待超時模型
這樣一個熟悉的場景:調用一個方法時等待一段時間(一般來說是給定一個時間段),如果該方法能夠在給定的時間段之內得到結果,那么將結果立刻返回,反之,超時返回默認結果。
public synchronized Object get(long mills) throws InterruptedException {long future = System.currentTimeMillis() + mills;long remaining = mills;// 當超時大于0并且result返回值不滿足要求while ((result == null) && remaining > 0) {wait(remaining);remaining = future - System.currentTimeMillis();}return result;
}
wait/notify底層原理
wait方法
將當前線程包裝成ObjectWaiter對象,放入WaitSet中,并調用park掛起
執行「釋放鎖」的邏輯。
只有notify方法有可能將線程從WaitSet拯救出來,處于WaitSet的線程永遠是阻塞狀態,不可能參與鎖競爭
notify方法
從WaitSet中取出第一個線程,根據Policy的不同,將這個線程放入EntryList或者cxq隊列中的起始或末尾位置
默認Policy為2,即:
EntryList隊列為空,將線程放入EntryList
EntryList隊列非空,將線程放入cxq隊列的頭部位置(棧頂);
強調一下:notify方法只是將線程從WaitSet移動到EntryList或者cxq,不是直接讓它開始自旋CAS。
wait/notify理解實戰
看下面這段代碼,在不修改 HotSpot VM源碼的情況下,考慮幾個問題:
輸出唯一確定嗎?
如果確定,會輸出什么?
public class NotifyDemo {
private static void log(String desc){System.out.println(Thread.currentThread().getName() + " : " + desc);
}Object lock = new Object();public void startThreadA(){new Thread(() -> {synchronized (lock){log("get lock");startThreadB();log("start wait");try {lock.wait();}catch(InterruptedException e){e.printStackTrace();}log("get lock after wait");log("release lock");}}, "thread-A").start();
}public void startThreadB(){new Thread(()->{synchronized (lock){log("get lock");startThreadC();sleep(100);log("start notify");lock.notify();log("release lock");}},"thread-B").start();
}public void startThreadC(){new Thread(() -> {synchronized (lock){log("get lock");log("release lock");}}, "thread-C").start();
}public static void main(String[] args){new NotifyDemo().startThreadA();}
}
輸出唯一確定,為:
thread-A : get lock
thread-A : start wait
thread-B : get lock
thread-B : start notify
thread-B : release lock
thread-A : get lock after wait
thread-A : release lock
thread-C : get lock
thread-C : release lock
為什么最后四行A一定先于C發生?
線程C獲取鎖失敗,直接放入cxq首部;線程A被notify,會被放入EntryList。之后B釋放鎖,發現EntryList內有線程A,就直接把A喚醒。
自定義搶鎖邏輯:修改JVM參數
有兩個參數會影響synchronized的行為邏輯:
Policy參數:喚醒線程
Policy參數決定如何喚醒線程
Policy == 0:放入EntryList隊列的排頭位置;
Policy == 1:放入EntryList隊列的末尾位置;
Policy == 2:EntryList隊列為空就放入EntryList,否則放入cxq隊列的排頭位置;
Policy == 3:放入cxq隊列中,末尾位置
QMode參數:釋放鎖
QMode參數決定如何釋放鎖
QMode = 2,并且cxq非空:取cxq隊列排頭位置的ObjectWaiter對象,喚醒該線程,結束
QMode = 3,把cxq隊列的全部元素放入EntryList尾部,然后執行步驟四;
QMode = 4,把cxq隊列的全部元素放入EntryList頭部,然后執行步驟四;
QMode = 0,不做什么,執行步驟4;(默認為0)
如果EntryList非空,就取首元素喚醒,否則整個cxq放到EntryList,再喚醒EntryList首元素;
通過修改這兩個參數,就可以自定義notify和釋放鎖的邏輯。還是上面那個例子,只需要修改QMode為4,就可以確保最后四行C先于A執行。
階段性小結(二)
其實wait/notify原理并不難懂,甚至可以說是非常好理解,就不再重復了。
到此為止,與synchronized的原理基本就講解完畢了,接下來我們重新審視一下一些比較籠統而泛泛的問題,不僅能幫助你更好地理解synchronized的原理,也能對synchronized有一個更全面的認知。算是一些補充說明吧。
synchronized的特點
非公平鎖
非公平鎖完全可以從前文的原理體現出來:
新來的線程不斷自旋不會阻塞,因此比起阻塞中的線程,更容易搶占鎖
cxq先入后出,先陷入阻塞的線程反而更晚執行
notify喚醒的線程,如果EntrySet為空直接放入EntrySet,先于cxq被執行
可重入性
synchronized是可重入的
monitor有個計數器recursions,起初為0,Monitorenter + 1,Monitorexit - 1,減為0會釋放鎖。
樂觀 or 悲觀
什么是悲觀鎖,什么是樂觀鎖?
看似簡單的概念,很多人第一次學習時都會顧名思義,但現在網絡上主流的觀點有兩種:
樂觀鎖只是一種思想,認為不會競爭鎖,僅此而已
樂觀鎖是線程先執行鎖區域的內容,執行過程中檢查是否出現競爭
核心的矛盾點在于,樂觀鎖到底是純思想,還是對實現做了一些行為規范的定義(比如必須:什么都不操作直接執行同步代碼塊的內容)?
如果讀者有關于「樂觀鎖」較為官方的定義,請在評論區告訴我,感激不盡
但如果「樂觀鎖」僅僅是一種思想,那可以說:synchronized的所有線程,只要沒有被阻塞,那就是樂觀的,只有重量級鎖中那些在cxq和EntryList的阻塞的線程是悲觀的(WaitSet是自愿阻塞不算在內)。因為如果足夠悲觀,早就阻塞等待去了,為啥還要自旋CAS呢?
編譯器對synchronized的優化
鎖消除
如果編譯器發現不會發生線程安全問題,就會無視了你的鎖。
鎖粗化
比如執行插入數據商品時,是對店鋪加鎖。那么批量執行的時候,只需要加一次鎖。而不是每插入一次就加/釋放一次鎖。
StringBuffer sb = new StringBuffer();sb.append(s1);sb.append(s2);sb.append(s3);// 線程安全的buffer類,append會加鎖,但顯然這是可以鎖粗話的,會優化成只獲得/釋放一次鎖synchronized與包裝類的坑
Integer并不適合當作鎖對象。
因為有緩存機制,-128~127有緩存。容易導致鎖失效。
volatile static Integer ticket = 10
比如兩個線程搶票,不能鎖住 ticket。搶完票以后ticket–,一個線程A鎖的是ticket = 10的對象,另一個線程B執行完ticket = 10的臨界區代碼,ticket–,再走臨界區,他的鎖變成了9,與A競爭的都不是一把鎖,因此兩者都會搶到鎖。
因此:
鎖住的對象盡量是靜態的不變的,比如class類
不能是各種有緩存的包裝類
在idea中 沒有聲明final的對象加synchronized會提示不安全