難搞的偏向鎖終於被 Java 移除了

日拱一兵發表於2021-12-29

背景

在 JDK1.5 之前,面對 Java 併發問題, synchronized 是一招鮮的解決方案:

  1. 普通同步方法,鎖上當前例項物件
  2. 靜態同步方法,鎖上當前類 Class 物件
  3. 同步塊,鎖上括號裡面配置的物件

拿同步塊來舉例:

public void test(){
  synchronized (object) {
    i++;
  }
}

經過 javap -v 編譯後的指令如下:

monitorenter 指令是在編譯後插入到同步程式碼塊的開始位置;monitorexit是插入到方法結束和異常的位置(實際隱藏了try-finally),每個物件都有一個 monitor 與之關聯,當一個執行緒執行到 monitorenter 指令時,就會獲得物件所對應的 monitor 的所有權,也就獲得到了物件的鎖

當另外一個執行緒執行到同步塊的時候,由於它沒有對應 monitor 的所有權,就會被阻塞,此時控制權只能交給作業系統,也就會從 user mode 切換到 kernel mode, 由作業系統來負責執行緒間的排程和執行緒的狀態變更, 需要頻繁的在這兩個模式下切換(上下文轉換)。這種有點競爭就找核心的行為很不好,會引起很大的開銷,所以大家都叫它重量級鎖,自然效率也很低,這也就給很多童鞋留下了一個根深蒂固的印象 —— synchronized關鍵字相比於其他同步機制效能不好

免費的 Java 併發程式設計小冊在此

鎖的演變

來到 JDK1.6,要怎樣優化才能讓鎖變的輕量級一些? 答案就是:

輕量級鎖:CPU CAS

如果 CPU 通過簡單的 CAS 能處理加鎖/釋放鎖,這樣就不會有上下文的切換,較重量級鎖而言自然就輕了很多。但是當競爭很激烈,CAS 嘗試再多也是浪費 CPU,權衡一下,不如升級成重量級鎖,阻塞執行緒排隊競爭,也就有了輕量級鎖升級成重量級鎖的過程

程式設計師在追求極致的道路上是永無止境的,HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一個執行緒多次獲得,同一個執行緒反覆獲取鎖,如果還按照輕量級鎖的方式獲取鎖(CAS),也是有一定代價的,如何讓這個代價更小一些呢?

偏向鎖

偏向鎖實際就是鎖物件潛意識「偏心」同一個執行緒來訪問,讓鎖物件記住執行緒 ID,當執行緒再次獲取鎖時,亮出身份,如果同一個 ID 直接就獲取鎖就好了,是一種 load-and-test 的過程,相較 CAS 自然又輕量級了一些

可是多執行緒環境,也不可能只是同一個執行緒一直獲取這個鎖,其他執行緒也是要幹活的,如果出現多個執行緒競爭的情況,也就有了偏向鎖升級的過程

這裡可以先思考一下:偏向鎖可以繞過輕量級鎖,直接升級到重量級鎖嗎?

都是同一個鎖物件,卻有多種鎖狀態,其目的顯而易見:

佔用的資源越少,程式執行的速度越快

偏向鎖,輕量鎖,它倆都不會呼叫系統互斥量(Mutex Lock),只是為了提升效能,多出的兩種鎖的狀態,這樣可以在不同場景下采取最合適的策略,所以可以總結性的說:

  • 偏向鎖:無競爭的情況下,只有一個執行緒進入臨界區,採用偏向鎖
  • 輕量級鎖:多個執行緒可以交替進入臨界區,採用輕量級鎖
  • 重量級鎖:多執行緒同時進入臨界區,交給作業系統互斥量來處理

到這裡,大家應該理解了全域性大框,但仍然會有很多疑問:

  1. 鎖物件是在哪儲存執行緒 ID 才可以識別同一個執行緒的?
  2. 整個升級過程是如何過渡的?

想理解這些問題,需要先知道 Java 物件頭的結構

認識 Java 物件頭

按照常規理解,識別執行緒 ID 需要一組 mapping 對映關係來搞定,如果單獨維護這個 mapping 關係又要考慮執行緒安全的問題。奧卡姆剃刀原理,Java 萬物皆是物件,物件皆可用作鎖,與其單獨維護一個 mapping 關係,不如中心化將鎖的資訊維護在 Java 物件本身上

Java 物件頭最多由三部分構成:

  1. MarkWord
  2. ClassMetadata Address
  3. Array Length (如果物件是陣列才會有這部分

其中 Markword 是儲存鎖狀態的關鍵,物件鎖狀態可以從偏向鎖升級到輕量級鎖,再升級到重量級鎖,加上初始的無鎖狀態,可以理解為有 4 種狀態。想在一個物件中表示這麼多資訊自然就要用儲存,在 64 位作業系統中,是這樣儲存的(注意顏色標記),想看具體註釋的可以看 hotspot(1.8) 原始碼檔案 path/hotspot/src/share/vm/oops/markOop.hpp 第 30 行

有了這些基本資訊,接下來我們就只需要弄清楚,MarkWord 中的鎖資訊是怎麼變化的

認識偏向鎖

單純的看上圖,還是顯得十分抽象,作為程式設計師的我們最喜歡用程式碼說話,貼心的 openjdk 官網提供了可以檢視物件記憶體佈局的工具 JOL (java object layout)

Maven Package

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.14</version>
</dependency>

Gradle Package

implementation 'org.openjdk.jol:jol-core:0.14'

接下來我們就通過程式碼來深入瞭解一下偏向鎖吧

注意:

上圖(從左到右) 代表 高位 -> 低位

JOL 輸出結果(從左到右)代表 低位 -> 高位

來看測試程式碼

場景1

    public static void main(String[] args) {
        Object o = new Object();
        log.info("未進入同步塊,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

來看輸出結果:

上面我們用到的 JOL 版本為 0.14, 帶領大家快速瞭解一下位具體值,接下來我們就要用 0.16 版本檢視輸出結果,因為這個版本給了我們更友好的說明,同樣的程式碼,來看輸出結果:

看到這個結果,你應該是有疑問的,JDK 1.6 之後預設是開啟偏向鎖的,為什麼初始化的程式碼是無鎖狀態,進入同步塊產生競爭就繞過偏向鎖直接變成輕量級鎖了呢?

雖然預設開啟了偏向鎖,但是開啟有延遲,大概 4s。原因是 JVM 內部的程式碼有很多地方用到了synchronized,如果直接開啟偏向,產生競爭就要有鎖升級,會帶來額外的效能損耗,所以就有了延遲策略

我們可以通過引數 -XX:BiasedLockingStartupDelay=0 將延遲改為0,但是不建議這麼做。我們可以通過一張圖來理解一下目前的情況:

場景2

那我們就程式碼延遲 5 秒來建立物件,來看看偏向是否生效

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未進入同步塊,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

重新檢視執行結果:

這樣的結果是符合我們預期的,但是結果中的 biasable 狀態,在 MarkWord 表格中並不存在,其實這是一種匿名偏向狀態,是物件初始化中,JVM 幫我們做的

這樣當有執行緒進入同步塊:

  1. 可偏向狀態:直接就 CAS 替換 ThreadID,如果成功,就可以獲取偏向鎖了
  2. 不可偏向狀態:就會變成輕量級鎖

那問題又來了,現在鎖物件有具體偏向的執行緒,如果新的執行緒過來執行同步塊會偏向新的執行緒嗎?

場景3

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未進入同步塊,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }

        Thread t2 = new Thread(() -> {
            synchronized (o) {
                log.info("新執行緒獲取鎖,MarkWord為:");
                log.info(ClassLayout.parseInstance(o).toPrintable());
            }
        });

        t2.start();
        t2.join();
        log.info("主執行緒再次檢視鎖物件,MarkWord為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){
            log.info(("主執行緒再次進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

來看執行結果,奇怪的事情發生了:

  • 標記1: 初始可偏向狀態
  • 標記2:偏向主執行緒後,主執行緒退出同步程式碼塊
  • 標記3: 新執行緒進入同步程式碼塊,升級成了輕量級鎖
  • 標記4: 新執行緒輕量級鎖退出同步程式碼塊,主執行緒檢視,變為不可偏向狀態
  • 標記5: 由於物件不可偏向,同場景1主執行緒再次進入同步塊,自然就會用輕量級鎖

至此,場景一二三可以總結為一張圖:

從這樣的執行結果上來看,偏向鎖像是“一錘子買賣”,只要偏向了某個執行緒,後續其他執行緒嘗試獲取鎖,都會變為輕量級鎖,這樣的偏向非常有侷限性。事實上並不是這樣,如果你仔細看標記2(已偏向狀態),還有個 epoch 我們沒有提及,這個值就是打破這種侷限性的關鍵,在瞭解 epoch 之前,我們還要了解一個概念——偏向撤銷

免費的 Java 併發程式設計小冊在此

偏向撤銷

在真正講解偏向撤銷之前,需要和大家明確一個概念——偏向鎖撤銷和偏向鎖釋放是兩碼事

  1. 撤銷:籠統的說就是多個執行緒競爭導致不能再使用偏向模式的時候,主要是告知這個鎖物件不能再用偏向模式
  2. 釋放:和你的常規理解一樣,對應的就是 synchronized 方法的退出或 synchronized 塊的結束

何為偏向撤銷?

從偏向狀態撤回原有的狀態,也就是將 MarkWord 的第 3 位(是否偏向撤銷)的值,從 1 變回 0

如果只是一個執行緒獲取鎖,再加上「偏心」的機制,是沒有理由撤銷偏向的,所以偏向的撤銷只能發生在有競爭的情況下

想要撤銷偏向鎖,還不能對持有偏向鎖的執行緒有影響,所以就要等待持有偏向鎖的執行緒到達一個 safepoint 安全點 (這裡的安全點是 JVM 為了保證在垃圾回收的過程中引用關係不會發生變化設定的一種安全狀態,在這個狀態上會暫停所有執行緒工作), 在這個安全點會掛起獲得偏向鎖的執行緒

在這個安全點,執行緒可能還是處在不同狀態的,先說結論(因為原始碼就是這麼寫的,可能有疑惑的地方會在後面解釋)

  1. 執行緒不存活或者活著的執行緒但退出了同步塊,很簡單,直接撤銷偏向就好了
  2. 活著的執行緒但仍在同步塊之內,那就要升級成輕量級鎖

這個和 epoch 貌似還是沒啥關係,因為這還不是全部場景。偏向鎖是特定場景下提升程式效率的方案,可並不代表程式設計師寫的程式都滿足這些特定場景,比如這些場景(在開啟偏向鎖的前提下):

  1. 一個執行緒建立了大量物件並執行了初始的同步操作,之後在另一個執行緒中將這些物件作為鎖進行之後的操作。這種case下,會導致大量的偏向鎖撤銷操作
  2. 明知有多執行緒競爭(生產者/消費者佇列),還要使用偏向鎖,也會導致各種撤銷

很顯然,這兩種場景肯定會導致偏向撤銷的,一個偏向撤銷的成本無所謂,大量偏向撤銷的成本是不能忽視的。那怎麼辦?既不想禁用偏向鎖,還不想忍受大量撤銷偏向增加的成本,這種方案就是設計一個有階梯的底線

批量重偏向(bulk rebias)

這是第一種場景的快速解決方案,以 class 為單位,為每個 class 維護一個偏向鎖撤銷計數器,每一次該class的物件發生偏向撤銷操作時,該計數器 +1,當這個值達到重偏向閾值(預設20)時:

BiasedLockingBulkRebiasThreshold = 20

JVM 就認為該class的偏向鎖有問題,因此會進行批量重偏向, 它的實現方式就用到了我們上面說的 epoch

Epoch,如其含義「紀元」一樣,就是一個時間戳。每個 class 物件會有一個對應的epoch欄位,每個處於偏向鎖狀態物件mark word 中也有該欄位,其初始值為建立該物件時 class 中的epoch的值(此時二者是相等的)。每次發生批量重偏向時,就將該值加1,同時遍歷JVM中所有執行緒的棧

  1. 找到該 class 所有正處於加鎖狀態的偏向鎖物件,將其epoch欄位改為新值
  2. class 中不處於加鎖狀態的偏向鎖物件(沒被任何執行緒持有,但之前是被執行緒持有過的,這種鎖物件的 markword 肯定也是有偏向的),保持 epoch 欄位值不變

這樣下次獲得鎖時,發現當前物件的epoch值和class的epoch,本著今朝不問前朝事 的原則(上一個紀元),那就算當前已經偏向了其他執行緒,也不會執行撤銷操作,而是直接通過 CAS 操作將其mark word的執行緒 ID 改成當前執行緒 ID,這也算是一定程度的優化,畢竟沒升級鎖;

如果 epoch 都一樣,說明沒有發生過批量重偏向, 如果 markword 有執行緒ID,還有其他鎖來競爭,那鎖自然是要升級的(如同前面舉的例子 epoch=0)

批量重偏向是第一階梯底線,還有第二階梯底線

批量撤銷(bulk revoke)

當達到重偏向閾值後,假設該 class 計數器繼續增長,當其達到批量撤銷的閾值後(預設40)時,

BiasedLockingBulkRevokeThreshold = 40

JVM就認為該 class 的使用場景存在多執行緒競爭,會標記該 class 為不可偏向。之後對於該 class 的鎖,直接走輕量級鎖的邏輯

這就是第二階梯底線,但是在第一階梯到第二階梯的過渡過程中,也就是在徹底禁用偏向鎖之前,還給一次改過自新的機會,那就是另外一個計時器:

BiasedLockingDecayTime = 25000
  1. 如果在距離上次批量重偏向發生的 25 秒之內,並且累計撤銷計數達到40,就會發生批量撤銷(偏向鎖徹底 game over)
  2. 如果在距離上次批量重偏向發生超過 25 秒之外,那麼就會重置在 [20, 40) 內的計數, 再給次機會

大家有興趣可以寫程式碼測試一下臨界點,觀察鎖物件 markword 的變化

至此,整個偏向鎖的工作流程可以用一張圖表示:

到此,你應該對偏向鎖有個基本的認識了,但是我心中的好多疑問還沒有解除,我們們繼續看:

HashCode 哪去了

上面場景一,無鎖狀態,物件頭中沒有 hashcode;偏向鎖狀態,物件頭還是沒有 hashcode,那我們的 hashcode 哪去了?

首先要知道,hashcode 不是建立物件就幫我們寫到物件頭中的,而是要經過第一次呼叫 Object::hashCode() 或者System::identityHashCode(Object) 才會儲存在物件頭中的。第一次生成的 hashcode後,該值應該是一直保持不變的,但偏向鎖又是來回更改鎖物件的 markword,必定會對 hashcode 的生成有影響,那怎麼辦呢?,我們來用程式碼驗證:

場景一

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        o.hashCode();
        log.info("已生成 hashcode,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

來看執行結果

結論就是:即便初始化為可偏向狀態的物件,一旦呼叫 Object::hashCode() 或者System::identityHashCode(Object) ,進入同步塊就會直接使用輕量級鎖

場景二

假如已偏向某一個執行緒,然後生成 hashcode,然後同一個執行緒又進入同步塊,會發生什麼呢?來看程式碼:

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }

        o.hashCode();
        log.info("生成 hashcode");
        synchronized (o){
            log.info(("同一執行緒再次進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

檢視執行結果:

結論就是:同場景一,會直接使用輕量級鎖

場景三

那假如物件處於已偏向狀態,在同步塊中呼叫了那兩個方法會發生什麼呢?繼續程式碼驗證:

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o){
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
            o.hashCode();
            log.info("已偏向狀態下,生成 hashcode,MarkWord 為:");
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

來看執行結果:

結論就是:如果物件處在已偏向狀態,生成 hashcode 後,就會直接升級成重量級鎖

最後用書中的一段話來描述 鎖和hashcode 之前的關係

呼叫 Object.wait() 方法會發生什麼?

Object 除了提供了上述 hashcode 方法,還有 wait() 方法,這也是我們在同步塊中常用的,那這會對鎖產生哪些影響呢?來看程式碼:

    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);

        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 為:");
        log.info(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o) {
            log.info(("進入同步塊,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());

            log.info("wait 2s");
            o.wait(2000);

            log.info(("呼叫 wait 後,MarkWord 為:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }

檢視執行結果:

結論就是,wait 方法是互斥量(重量級鎖)獨有的,一旦呼叫該方法,就會升級成重量級鎖(這個是面試可以說出的亮點內容哦)

最後再繼續豐富一下鎖物件變化圖:


免費的 Java 併發程式設計小冊在此

告別偏向鎖

看到這個標題你應該是有些慌,為啥要告別偏向鎖,因為維護成本有些高了,來看 Open JDK 官方宣告,JEP 374: Deprecate and Disable Biased Locking,相信你看上面的文字說明也深有體會

這個說明的更新時間距離現在很近,在 JDK15 版本就已經開始了

一句話解釋就是維護成本太高

最終就是,JDK 15 之前,偏向鎖預設是 enabled,從 15 開始,預設就是 disabled,除非顯示的通過 UseBiasedLocking 開啟

其中在 quarkus 上的一篇文章說明的更加直接

偏向鎖給 JVM 增加了巨大的複雜性,只有少數非常有經驗的程式設計師才能理解整個過程,維護成本很高,大大阻礙了開發新特性的程式(換個角度理解,你掌握了,是不是就是那少數有經驗的程式設計師了呢?哈哈)

總結

偏向鎖可能就這樣的走完了它的一生,有些同學可能直接發問,都被 deprecated 了,JDK都 17 了,還講這麼多幹什麼?

  1. java 任它發,我用 Java8,這是很多主流的狀態,至少你用的版本沒有被 deprecated
  2. 面試還是會被經常問到
  3. 萬一哪天有更好的設計方案,“偏向鎖”又以新的形式回來了呢,瞭解變化才能更好理解背後設計
  4. 奧卡姆剃刀原理,我們現實中的優化也一樣,如果沒有必要不要增加實體,如果增加的內容帶來很大的成本,不如大膽的廢除掉,接受一點落差

之前對於偏向鎖我也只是單純的理論認知,但是為了寫這篇文章,我翻閱了很多資料,包括也重新檢視 Hotspot 原始碼,說的這些內容也並不能完全說明偏向鎖的整個流程細節,還需要大傢俱體實踐追蹤檢視,這裡給出原始碼的幾個關鍵入口,方便大家追蹤:

  1. 偏向鎖入口: http://hg.openjdk.java.net/jd...
  2. 偏向撤銷入口:http://hg.openjdk.java.net/jd...
  3. 偏向鎖釋放入口:http://hg.openjdk.java.net/jd...

文中有疑問的地方歡迎留言討論,有錯誤的地方還請大家幫忙指正

靈魂追問

  1. 輕量級和重量級鎖,hashcode 存在了什麼位置?

參考資料

感謝各路前輩的精華總結,可以讓我參考理解:

  1. https://www.oracle.com/techne...
  2. https://www.oracle.com/techne...
  3. https://wiki.openjdk.java.net...
  4. https://github.com/farmerjohn...
  5. https://zhuanlan.zhihu.com/p/...
  6. https://mp.weixin.qq.com/s/G4...
  7. https://www.jianshu.com/p/884...

日拱一兵 | 原創

相關文章