25 張圖 - 深入淺出「偏向鎖」

背景

在 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 關鍵字相比於其他同步機制性能不好

鎖的演變

來到 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());
  }
 }

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

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

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

偏向撤銷

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

  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 方法是互斥量(重量級鎖)獨有的,一旦調用該方法,就會升級成重量級鎖(這個是面試可以說出的亮點內容哦)

最後再繼續豐富一下鎖對象變化圖:

告別偏向鎖

看到這個標題你應該是有些慌,爲啥要告別偏向鎖,因爲維護成本有些高了,來看 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/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816

  2. 偏向撤銷入口:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/interpreterRuntime.cpp#l608

  3. 偏向鎖釋放入口:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1923

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

靈魂追問

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

參考資料

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

  1. https://www.oracle.com/technetwork/java/javase/tech/biasedlocking-oopsla2006-preso-150106.pdf

  2. https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

  3. https://wiki.openjdk.java.net/display/HotSpot/Synchronization#Synchronization-Russel06

  4. https://github.com/farmerjohngit/myblog/issues/12

  5. https://zhuanlan.zhihu.com/p/440994983

  6. https://mp.weixin.qq.com/s/G4z08HfiqJ4qm3th0KtovA

  7. https://www.jianshu.com/p/884eb51266e4

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tSxOeJxJIcjoc-l1uxPFmg