硬核學習Synchronized原理(底層結構、鎖優化過程)

硬核學習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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章