硬核學習Synchronized原理(底層結構、鎖優化過程)
Monitor 被翻譯爲監視器或管程,是操作系統層次的數據結構
每個 Java 對象都可以關聯一個 Monitor 對象
如果使用 synchronized 給對象上鎖(重量級)之後,該對象頭的Mark Word 中就被設置指向 Monitor 對象的指針
Monitor 結構如下
現模擬多線程競爭Synchronized鎖對象的流程
-
剛開始 Monitor 中 Owner 爲 null
-
當 Thread-2 執行 synchronized(obj) 就會將 Monitor 的所有者 Owner 置爲 Thread-2,Monitor中只能有一個 Owner
-
在 Thread-2 上鎖的過程中,如果 Thread-3,Thread-4,Thread-5 也來執行 synchronized(obj),就會進入EntryList BLOCKED(俗稱阻塞隊列)
-
Thread-2 執行完同步代碼塊的內容,然後喚醒 EntryList 中等待的線程來競爭鎖,競爭的時是非公平的
-
圖中 WaitSet 中的 Thread-0,Thread-1 是之前獲得過鎖,但條件不滿足進入 WAITING 狀態的線程
注意:
- synchronized 必須是進入同一個對象的 monitor 纔有上述的效果
- 不加 synchronized 的對象不會關聯監視器,不遵從以上規則
Java對象頭
被synchronized的對象,對象頭的MarkWord字段會指向Monitor地址,具體來看下對象頭是什麼結構
以 32 位虛擬機爲例
普通對象:
數組對象:
其中 Mark Word 結構爲:
Mark Word記錄了對象和鎖有關的信息,當這個對象被synchronized關鍵字當成同步鎖時,圍繞這個鎖的一系列操作都和Mark Word有關
Mark Word在32位JVM中的長度是32bit,在64位JVM中長度是64bit
Mark Word在不同的鎖狀態下存儲的內容不同,在32位JVM中是這麼存的:
其中無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態
一個對象創建時:
- 如果開啓了偏向鎖(默認開啓),那麼對象創建後,markword 值爲 0x05 即最後 3 位爲 101,這時它的thread、epoch、age 都爲 0
- 偏向鎖是默認是延遲的,不會在程序啓動時立即生效,如果想避免延遲,可以加 VM 參數
-XX:BiasedLockingStartupDelay=0
來禁用延遲 - VM 參數
-XX:-UseBiasedLocking
禁用偏向鎖
JDK1.6以後的版本在處理同步鎖時存在鎖升級的概念,JVM對於同步鎖的處理是從偏向鎖開始的,隨着競爭越來越激烈,處理方式從偏向鎖升級到輕量級鎖,最終升級到重量級鎖
64 位虛擬機 Mark Word:
Klass Word也佔32個字節,該區域用來表示指向該對象對應類的指針
Array Length爲數組對象獨佔區域,用來指明數組長度的
輕量級鎖
輕量級鎖的使用場景:如果一個對象雖然有多線程要加鎖,但加鎖的時間是錯開的(也就是沒有競爭),那麼可以使用輕量級鎖來優化
輕量級鎖對使用者是透明的,即語法仍然是 synchronized
假設有兩個方法同步塊,利用同一個對象加鎖
class TestSyn{
static final Object obj = new Object() ;
public static void method1(){
synchronized (obj){
//同步塊A
method2();
}
}
public static void method2(){
synchronized (obj){
//同步塊B
}
}
}
-
創建鎖記錄(Lock Record)對象,每個線程的棧幀都會包含一個鎖記錄的結構,內部可以存儲鎖定對象的Mark Word
-
讓鎖記錄中 Object reference 指向鎖對象,並嘗試用 cas (交換並設置)替換 Object 的 Mark Word,將 Mark Word 的值存入鎖記錄
-
如果 cas 替換成功,對象頭中存儲了鎖記錄地址和狀態 00 (代表輕量級鎖),表示由該線程給對象加鎖,這時圖示如下
-
如果 cas 失敗,有兩種情況
-
如果是其它線程已經持有了該 Object 的輕量級鎖,這時表明有競爭,進入鎖膨脹(也稱鎖升級)過程
-
如果是自己執行了 synchronized 鎖重入(與本線程競爭),那麼再添加一條 Lock Record 作爲重入的計數
-
-
當退出 synchronized 代碼塊(解鎖時)如果有取值爲 null 的鎖記錄,表示有重入,這時重置鎖記錄,表示重入計數減一
-
當退出 synchronized 代碼塊(解鎖時)鎖記錄的值不爲 null,這時使用 cas 將 Mark Word 的值恢復給對象頭
成功,則解鎖成功
失敗,說明輕量級鎖進行了鎖膨脹或已經升級爲重量級鎖,進入重量級鎖解鎖流程
鎖膨脹
如果在嘗試加輕量級鎖的過程中,CAS 操作無法成功,這時一種情況就是有其它線程爲此對象加上了輕量級鎖(有競爭),這時需要進行鎖膨脹,將輕量級鎖變爲重量級鎖
如有以下流程:
-
當 Thread-1 進行輕量級加鎖時,Thread-0 已經對該對象加了輕量級鎖
-
這時 Thread-1 加輕量級鎖失敗,進入鎖膨脹流程,爲 Object 對象申請 Monitor 鎖(重量級鎖),讓 Object 指向重量級鎖地址,然後自己進入 Monitor 的 EntryList BLOCKED阻塞隊列
當 Thread-0 退出同步塊解鎖時,使用 cas 將 Mark Word 的值恢復給對象頭,失敗。這時會進入重量級解鎖流程,即按照 Monitor 地址找到 Monitor 對象,設置 Owner 爲 null,喚醒 EntryList 中 BLOCKED 線程
自旋
重量級鎖競爭的時候,還可以使用自旋來進行優化,如果當前線程自旋成功(即這時候持鎖線程已經退出了同步塊,釋放了鎖),這時當前線程就可以避免阻塞
自旋重試成功的情況:
自旋重試失敗的情況:
注意:
- 自旋會佔用 CPU 時間,單核 CPU 自旋就是浪費,多核 CPU 自旋才能發揮優勢
- 在 Java 6 之後自旋鎖是自適應的,比如對象剛剛的一次自旋操作成功過,那麼認爲這次自旋成功的可能性會高,就多自旋幾次;反之,就少自旋甚至不自旋,總之,比較智能
- Java 7 之後不能控制是否開啓自旋功能
偏向鎖
輕量級鎖在沒有競爭時(就自己這個線程),每次重入仍然需要執行 CAS 操作
Java 6 中引入了偏向鎖來做進一步優化:只有第一次使用 CAS 將線程 ID 設置到對象的 Mark Word 頭,之後發現這個線程 ID 是自己的就表示沒有競爭,不用重新 CAS。以後只要不發生競爭,這個對象就歸該線程所有
class TestSyn{
static final Object obj = new Object() ;
public static void method1(){
synchronized (obj){
//同步塊A
method2();
}
}
public static void method2(){
synchronized (obj){
//同步塊B
method3();
}
}
public static void method3(){
synchronized (obj){
//同步塊C
}
}
}
輕量級鎖下,主線程每次申請鎖都需要CAS
偏向鎖下。無需CAS
偏向鎖撤銷的情況
hashCode()導致偏向鎖被動撤銷
調用了對象的 hashCode,但偏向鎖的對象 MarkWord 中存儲的是線程 id,如果調用 hashCode 會導致偏向鎖被撤銷
-
輕量級鎖會在鎖記錄中記錄 hashCode
-
重量級鎖會在 Monitor 中記錄 hashCode
故輕量級重量級鎖調用hashCode是沒有問題的
在調用 hashCode 後使用偏向鎖,記得去掉 -XX:-UseBiasedLocking
線程爭搶導致偏向鎖被動撤銷
當有其它線程使用偏向鎖對象時,會將偏向鎖升級爲輕量級鎖,過程上面已經寫過
調用 wait/notify導致偏向鎖撤銷
鎖對象一旦調用wait/notify會導致該對象的偏向鎖狀態被撤銷
參考:
黑馬程序員《全面深入學習高併發多線程》
wait/notify
說到了wait/notify,那麼繼續扯扯wait/notify,wait/notify是用來實現線程通信協作的
接synchronized,當一個線程暫時不滿足條件,應該進去等待隊列,等着另一個線程執行完畢,線程滿足條件,由另一個線程喚醒之
注意,wait/notify需要在synchroniezd代碼塊中才能使用
API 介紹
- obj.wait() 讓進入 object 監視器的線程到 waitSet 等待
- obj.notify() 在 object 上正在 waitSet 等待的線程中挑一個喚醒
- obj.notifyAll() 讓 object 上正在 waitSet 等待的線程全部喚醒
- Owner 線程發現條件不滿足,調用 wait 方法,即可進入 WaitSet 變爲 WAITING 狀態
- BLOCKED 和 WAITING 的線程都處於阻塞狀態,不佔用 CPU 時間片
- BLOCKED 線程會在 Owner 線程釋放鎖時喚醒
- WAITING 線程會在 Owner 線程調用 notify 或 notifyAll 時喚醒,但喚醒後並不意味者立刻獲得鎖,仍需進入EntryList 重新競爭
Park/UnPark
這套組合與wait/notify是一樣的效果,但這套組合要由於wait/notify,它們是 LockSupport 類中的方法
// 暫停當前線程
LockSupport.park();
// 恢復某個線程的運行
LockSupport.unpark(暫停線程對象)
先 park 再 unpark
與 Object 的 wait & notify 相比
- wait,notify 和 notifyAll 必須配合 Object Monitor(重量級鎖) 一起使用,而 park,unpark 不必
- park & unpark 是以線程爲單位來【阻塞】和【喚醒】線程,而 notify 只能隨機喚醒一個等待線程,notifyAll 是喚醒所有等待線程,就不那麼【精確】
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify
Park/UnPark原理
每個線程都有自己的一個 Parker 對象,由三部分組成 _counter , _cond 和 _mutex
打個比喻
線程就像一個旅人,Parker 就像他隨身攜帶的揹包,條件變量cond就好比揹包中的帳篷。_counter 就好比揹包中的備用乾糧(0 爲耗盡,1 爲充足)
調用 park 就是要看需不需要停下來歇息
如果備用乾糧耗盡,那麼鑽進帳篷歇息
如果備用乾糧充足,那麼不需停留,繼續前進
調用 unpark,就好比令乾糧充足
如果這時線程還在帳篷,就喚醒讓他繼續前進
如果這時線程還在運行,那麼下次他調用 park 時,僅是消耗掉備用乾糧,不需停留繼續前進
因爲揹包空間有限,多次調用 unpark 僅會補充一份備用乾糧
- 當前線程調用 Unsafe.park() 方法
- 檢查 _counter(counter) ,本情況爲 0,這時,獲得 _mutex 互斥鎖,類比monitor的owner
- 線程進入 _cond 條件變量阻塞,類比monitor的waitset
- 設置 _counter = 0
- 調用 Unsafe.unpark(Thread_0) 方法,設置 _counter 爲 1
- 喚醒 _cond 條件變量中的 Thread_0
- Thread_0 恢復運行
- 設置 _counter 爲 0
- 調用 Unsafe.unpark(Thread_0) 方法,設置 _counter 爲 1
- 當前線程調用 Unsafe.park() 方法
- 檢查 _counter ,本情況爲 1,這時線程無需阻塞,繼續運行
- 設置 _counter 爲 0