JAVA鎖的集大成應用者--synchronized的鎖優化

前言

之前博客轉載過美團的鎖介紹文章 【基本功】不可不說的Java“鎖”事–轉自美團技術博客,寫的非常好,但是在鎖的落地中,有哪個可以囊括大部分鎖的落地應用,我覺得synchronized可以是一個。下面就講講synchronized的鎖優化。

對象頭

我們用過synchronized的都知道,它的使用語法是用一個對象來當“鑰匙”的。哪個線程有這把鑰匙,哪個線程就可以自由出入synchronized的作用範圍。反之,則會拒之門外。有沒有想過,我們爲什麼要用對象來當鎖,這裏面就有對象頭的概念。

synchronized用的鎖是存在Java對象頭裏的,那麼什麼是Java對象頭呢?Hotspot虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。其中Klass Point是是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,Mark Word用於存儲對象自身的運行時數據,它是實現輕量級鎖和偏向鎖的關鍵,下圖就是不同鎖狀態下Mark Word記錄數據的區別,其中比較重要的列就是 鎖狀態,鎖標誌位,線程ID,下面synchronized的原理就跟這些息息相關。

在這裏插入圖片描述

synchronized的原理(鎖膨脹)

synchronized 鎖升級過程

前面介紹了對象頭可能不明所以,不知道怎麼用的,沒有關係,前面是介紹基本概念,這一章正式講解synchronized的內部。synchronized以前是一個純重量級的鎖,使用操作系統互斥量來實現,給人的印象是效率低下。但是後來優化後,我個人認爲,synchronized的原理就是鎖膨脹的過程,因爲synchronized的鎖會經歷 無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖的一個過程.先從無鎖講起。

無鎖

無鎖非常好理解,當沒有一個線程訪問或者只有一個線程訪問時,就是無鎖狀態,無鎖狀態下 對象頭的 鎖標誌位 就是 '01' . 你可以對照上面的Mark Word圖查 01 對應的行,但是你會發現有一個叫 "偏向鎖"的也是 01,兩者有啥區別?下面就會解釋

偏向鎖

當只有一個線程訪問的時候,除了 鎖標誌位 設爲 '01',還會做一件重要的事情

  • 在Mark Word中記錄 當前訪問線程的線程id

你也可以在上圖中看到 偏向鎖無鎖 狀態下 偏向鎖就是多了一個 線程id ,圖中epoch指的是偏向鎖的時間戳,這裏不講解這個。

何謂偏向鎖 – 當只有一個線程訪問時,虛擬機認爲這段代碼是無需加鎖的,也沒有必要加鎖,這就提高了代碼的運行性能。但是需要記錄下 當前的線程ID,一旦有其它線程進入 發現對象頭的中線程ID不是 自己的id,說明這個鎖開始存在競爭。 那就進入了synchronized鎖的下一階段。

匿名偏向

  • -XX:+UseBiasedLocking 啓動偏向鎖
  • -XX:BiasedLockingStartupDelay 偏向鎖啓動的延時時間 ,默認4s

這裏介紹兩個關於偏向鎖的jvm參數,一個是是否啓動偏向鎖,一個是 jvm啓動多久之後開啓偏向鎖, 這兩個參數就決定了 synchronized 鎖升級的走向

  • 如果偏向鎖壓根沒有開啓,那麼synchronized鎖升級就會跳過偏向鎖,直接升級爲輕量級鎖。
  • 如果偏向鎖開啓,但是延遲了0秒(沒有延遲),這時候創建的對象就是一個匿名偏向對象,這個對象 鎖標誌位 設爲 '01',但是這個對象還沒有偏向任何線程。
  • 如果偏向鎖開啓,但是延遲了30秒,那麼new 出來的對象就是一個普通對象,如果這個對象30秒之後被當作synchronized的鎖,那麼就會升級爲偏向鎖。

上面就是在解釋上圖中 匿名偏向的含義, 有人問爲什麼要有 XX:BiasedLockingStartupDelay 這個配置參數,因爲jvm一開始啓動的時候肯定有大量的多線程操作,在高併發的環境下,偏向鎖不是一個好的選擇,還不如一開始就使用重量級鎖排隊等待。等一段時間過去,線程競爭沒有那麼激烈的時候,再開啓偏向鎖。

輕量級鎖

從這裏開始就要分別介紹兩個角色的宿命, 一個是本身通過偏向獲得鎖的線程A,另一個是 其它想要,但還未獲得訪問通行權的線程B。

對於線程A來說: 沒有競爭的時候,它的日子過得很瀟灑,每次只需要簡單的檢查一下線程ID 就可以暢通無阻。 然而幸福總是短暫的,線程B的到來宣告 線程A開始執行 偏向撤銷

  • 線程A已執行完畢的話。

偏向撤銷會恢復到 ‘未鎖定’ 的狀態(線程 ID 爲空,標誌位爲01),讓線程B重新偏向

  • 線程A仍在執行的話。

虛擬機就會在線程A的棧幀中建立一個名爲鎖記錄的空間(Lock Record),用於存儲對象頭的Mark Word拷貝,官方稱呼這個拷貝爲 Displaced Mark Word . 注意這個Displaced Mark Word 是未升級 輕量鎖 前,存儲對象頭Mark Word的拷貝。

然後線程A 嘗試獲取輕量級鎖-- 通過CAS嘗試把Mark Word的stack pointer更新爲指向本地棧幀Lock Record的指針, 更新成功,就獲得了 輕量級鎖,並且鎖標誌爲變成 00,這時的狀態可以參考下圖:
在這裏插入圖片描述
對於線程B來說:它對於線程A來說是一個外來者,是它導致了 線程A的 偏向撤銷( 偏向撤銷不會主動發生),它也會建立自己的Lock Record,並且嘗試通過CAS把Mark Word指向自己的Lock Record,如果失敗的話,它會開始自旋等待(循環重試)。什麼是輕量鎖,輕量就在於沒有重量級的系統切換,通過CAS+自旋來避免大的性能開銷。當然,自旋的次數也是有限度的,持有輕量鎖的線程A可能不會那麼快釋放鎖,這時候 重量級鎖就登場了。

重量級鎖

上面說到 對於線程B來說,輕量鎖 都沒辦法解決的問題 就說明競爭情況不容樂觀,雖然輕量鎖不涉及系統層面的切換開銷,但是 自旋循環等操作都是消耗cpu的操作,什麼東西都有利有弊, 線程B接下來就會選擇將 輕量級鎖繼續膨脹,升級爲 重量級鎖,鎖的標誌位設爲 ‘10’,並且自我掛起等待喚醒(重量級鎖的重要特徵)。

對於線程A來說,它獲得了輕量級鎖,當它執行完邏輯內容,它需要釋放鎖,這裏釋放鎖是通過 CAS 將 Displaced Mark Word 替換當前對象頭來實現的。這裏細講下CAS的預期值,新值,內存值 是啥,新值都知道,就是Displaced Mark Word,預期值是啥?肯定希望對象頭現在存的是輕量級的鎖標誌爲 00 ,然而內存值是啥,這就要打個問號了,前文已經說了,線程B在自旋後是可以把鎖升級爲 重量級鎖,這時候線程A釋放鎖的CAS操作就會失敗,線程A依舊會退出,但是會喚醒被掛起的線程B。

至此,我們大致分析了synchronized整個鎖膨脹的流程。這一套涉及了 偏向鎖,自旋,輕量級鎖,重量級鎖,然而,synchronized除了前文主體流程之外,還涉及到其它的鎖優化技術,這也是爲什麼我認爲synchronized是JAVA鎖的集大成應用者。

JAVA的其它鎖優化

自適應鎖

我們知道了自旋鎖,還有一種自適應的自旋鎖。 自適應意味着自旋的時間不再是固定的, 而是由前一次在同一個鎖上的自旋時間以及鎖擁有者的狀態來決定。如果在同一個鎖對象上, 自旋等待剛好成功獲得鎖, 並且在持有鎖的線程在運行中, 那麼虛擬機就會認爲這次自旋也是很有可能獲得鎖, 進而它將允許自旋等待相對更長的時間。

鎖消除

給一段代碼,這是一個字符串拼接的方法,StringBuffer 是線程安全的,因爲它的每個方法都用synchronized修飾。但是這一個方法裏我們看不到共享的變量,換句話說,sb變量的作用域只在這個方法體內,變量並沒有逃逸,也就沒有多線程問題。

沒有多線程問題,用synchronized鎖是不是有點浪費?虛擬機會幫我們做逃逸分析和鎖消除,程序在運行的時候並沒有鎖的存在,儘可能幫我們提升程序性能。可惜的是我查了資料,java的逃逸分析沒在編譯期運行,我們不能通過字節碼看到直觀的優化效果。

    public void test(){
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 10; i++) {
            sb.append(i);
        }
    }

鎖粗化

還是上面的例子,append方法是個synchronized修飾的方法,在循環重一遍遍調用的話 就會一直經歷加鎖解鎖的過程,這也很消耗性能,鎖粗化就是把這種代碼的鎖擴大到更大的範圍,比如放到for循環以外,只做一次加鎖解鎖操作,這也是jvm幫我們做的優化。

資料

文章修訂

  • 2020.04.12 拓展匿名偏向的知識
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章