通過內存佈局帶你掌握鎖升級過程

Synchronized四種鎖狀態

在 Java 語言中,使用 Synchronized 是能夠實現線程同步的,即加鎖。並且實現的是悲觀鎖,在操作同步資源的時候直接先加鎖。
加鎖可以使一段代碼在同一時間只有一個線程可以訪問,在增加安全性的同時,犧牲掉的是程序的執行性能,所以爲了在一定程度上減少獲得鎖和釋放鎖帶來的性能消耗,在 jdk6 之後便引入了“偏向鎖”和“輕量級鎖”,所以總共有4種鎖狀態,級別由低到高依次爲:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態。這幾個狀態會隨着競爭情況逐漸升級。

內存佈局

要想清晰地瞭解鎖升級的過程,首先需要我們掌握內存佈局,很多公司會問到這樣一個問題: Object o = new Object(); 對象o佔多少字節?

這裏我們首先給出對象的內存佈局圖。

在這裏插入圖片描述
可以看出內存佈局有三個部分:對象頭,實例數據,對齊。

對象頭:

HotSpot虛擬機的對象頭主要包括兩部分信息:

第一部分:用於存儲對象自身的運行時數據, 如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等,這部分數據的長度在32位和64位的虛擬機(暫 不考慮開啓壓縮指針的場景)中分別爲32個和64個Bits,官方稱它爲“Mark Word”

第二部分:類型指針,即對象指向它的類的元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

其他部分:如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因爲虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中無法確定數組的大小。

在64位JVM上有一個壓縮指針選項-XX:+UseCompressedOops,默認是開啓的。開啓之後Class Pointer部分就會壓縮爲4字節,此時對象頭大小就會縮小到12字節。

實例數據
實例數據部分是對象真正存儲的有效信息,也是在程序代碼中所定義的各種類型的字段內容。

對齊
這裏的對齊填充專指對象末尾的填充,如果對象填充完實例數據後的大小不滿足"8N",則填充到8N,其實在上面運行結果中就已經有提示了。

內存佈局實驗

爲了查看內存佈局,需要下載第三方依賴JOL(Java Object Layout)

<dependencies>
        <!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
        </dependency>
 </dependencies>

實驗一:

public static void main(String[] args) {
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

輸出:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以直觀的看到
對象頭:

  • Mark Word 8字節
  • 類型指針 4字節

實例數據:0字節
對齊:4字節
因此總共佔16字節
說明:對象頭+實例數據 = 12字節不爲8的倍數,需對齊補4個字節。

實驗二:
People 對象新增兩個int類型的變量(name,age)

@Data
public class People {
    int name;
    int age;
}

People o = new People();   
System.out.println(ClassLayout.parseInstance(o).toPrintable());

輸出:

com.example.demo.People object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
     12     4    int People.name                               0
     16     4    int People.age                                0
     20     4        (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以看出多了兩行

int People.name 
int People.age 

對象頭:

  • Mark Word 8字節
  • 類型指針 4字節

實例數據:8字節(每個int類型各佔4個字節)
對齊:4字節
總共佔24個字節。

修改:如果把其中的一個int類型替換成long(8字節),結果還是佔24個字節。

@Data
public class People {
    int name;
    long age;
}
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
     12     4    int People.name                               0
     16     8   long People.age                                0
Instance size: 24 bytes

因爲此時無需對齊。

鎖狀態與內存佈局

大家需要掌握幾種鎖的概念,我曾在之前一篇文章形象的講解了輕量級鎖(cas),大家可以查看學習。

下圖是這幾種鎖的比較。

鎖狀態 優點 缺點 適用場景
偏向鎖 加鎖解鎖無額外消耗 競爭線程多,會帶來額外的鎖撤銷的消耗 基本無鎖競爭的同步場景
輕量級鎖 競爭線程不會阻塞,提高響應速度 長時間自旋會造成cpu消耗 少量鎖競爭且線程持有鎖時間不長
重量級鎖 不會導致cpu空轉消耗資源 線程阻塞,響應時間長 大量線程競爭鎖且鎖持有時間長

上文說過鎖相關信息都保存在對象頭中的Mark Word中
重點
下圖是Mark Word信息與鎖狀態對照表:
在這裏插入圖片描述
講解:
第一行

  • 低三位:偏向鎖位(iased_lock=0)代表鎖爲非偏向鎖,它和偏向鎖位(lock:01)組合表示鎖狀態爲正常。
  • age:分代年齡
  • identity_hashcode(31位):hashcode

第二行

  • thread:線程信息

鎖升級過程

鎖升級的過程總結爲:
new - 偏向鎖 - 輕量級鎖 (無鎖, 自旋鎖,自適應自旋)- 重量級鎖
大家是不是一頭霧水?沒關係,我們通過下面的實驗帶你徹底掌握這張表以及鎖升級的過程。

實驗要進行以下三步:

  • new一個新對象,打印內存佈局。
  • 進行hashCode操作,打印內存佈局。
  • 上鎖,打印內存佈局。
public static void main(String[] args) {
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
    o.hashCode();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
    synchronized (o){
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

輸出:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 22 7f 63 (00000001 00100010 01111111 01100011) (1669276161)
      4     4        (object header)                           07 00 00 00 (00000111 00000000 00000000 00000000) (7)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           b8 f5 4f 03 (10111000 11110101 01001111 00000011) (55571896)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

上面有三個輸出,我們需要把要分析的對象頭中的Mark Word(前兩行)逆序展開,舉個例子:

(00000001 00000000 00000000 00000000) (1)
(00000000 00000000 00000000 00000000) (0)

展開後爲

00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001

由於Mark Word中最低的三位代表鎖狀態 其中1位是偏向鎖位 兩位是普通鎖位,此處鎖信息爲001

讓我們看看每一步都發生了什麼吧~

1. Object o = new Object()
此時打印的Mark Word值爲

00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001

鎖信息爲0 01
我們查找上圖Mark Word對照表,iased_lock=0,lock=01
查找結果爲無鎖態 。
此時:

unused: 00000000 00000000 00000000 0
hashcode: 0000000 00000000 00000000 00000000
unued: 0
age: 0000
biased_lock: 0
lock: 01

2. o.hashCode()
此時打印的Mark Word值爲

00000000 00000000 00000000 00000111 01100011 01111111 00100010 00000001

鎖信息仍然爲0 01,不過此時有了hashcode

unused: 00000000 00000000 00000000 0
hashcode: 0000111 01100011 01111111 00100010
unued: 0
age: 0000
biased_lock: 0
lock: 01

3. 默認synchronized(o)
此時打印的Mark Word值爲

00000000 00000000 00000000 00000000 00000011 01001111 11110101 10111000

之前我們講的鎖升級順序是先是偏向鎖之後纔會升級爲輕量級鎖
然而此時最低三位爲 000(輕量級鎖)
問號一:爲什麼不是偏向鎖呢?
因爲默認情況下偏向鎖有個時延,默認是4秒,所以最先看到的是輕量級鎖,如果要觀察到偏向鎖,應該設定啓動參數

-XX:BiasedLockingStartupDelay=0

問號二:爲什麼要先爲輕量級鎖?
因爲JVM虛擬機自己有一些默認啓動的線程,裏面有好多sync代碼,這些sync代碼啓動時就知道肯定會有競爭,如果使用偏向鎖,就會造成偏向鎖不斷的進行鎖撤銷和鎖升級的操作,效率較低。
不過有的JDK如JDK11,打開就是偏向鎖,無需進行額外的參數設置。

如果你在Idea設置了啓動參數,還是000(輕量級鎖),是因爲你已經計算過了對象的hashCode,則對象無法進入偏向狀態!**

4. 如果設定上述參數成功
我們再來看下內存佈局

00000000 00000000 00000000 00000000 00000011 01000111 01001000 00000101 

可以看到低3位爲101(偏向鎖)

5. 如果有線程上鎖
上偏向鎖,指的就是,把Mark Word的線程ID改爲自己線程ID的過程
偏向鎖不可重偏向 批量偏向 批量撤銷

6. 一旦出現線程競爭
撤銷偏向鎖,升級輕量級鎖
線程在自己的線程棧生成LockRecord ,用CAS操作將Mark Word設置爲指向自己這個線程的LR的指針,設置成功者得到鎖

7. 如果競爭加劇

有線程超過10次自旋或者自旋線程數超過CPU核數的一半,升級重量級鎖:向操作系統申請資源,執行linux 互斥鎖mutex ,線程掛起,進入等待隊列,等待操作系統的調度,然後再映射回用戶空間。

自旋次數可以在啓動參數中進行調整

 -XX:PreBlockSpin

JDK1.6之後,加入自適應自旋 Adapative Self Spinning , JVM自己控制
自適應自旋鎖意味着自旋的時間(次數)不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

偏向鎖由於有鎖撤銷的過程revoke,會消耗系統資源,所以,在鎖爭用特別激烈的時候,用偏向鎖未必效率高。還不如直接使用輕量級鎖。

其他概念

鎖消除 lock eliminate

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}

我們都知道 StringBuffer 是線程安全的,因爲它的關鍵方法都是被 synchronized 修飾過的,但我們看上面這段代碼,我們會發現,sb 這個引用只會在 add 方法中使用,不可能被其它線程引用(因爲是局部變量,棧私有),因此 sb 是不可能共享的資源,JVM 會自動消除 StringBuffer 對象內部的鎖。

鎖粗化 lock coarsening

public String test(String str){
       
       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
           sb.append(str);
           i++;
       }
       return sb.toString():
}

JVM 會檢測到這樣一連串的操作都對同一個對象加鎖(while 循環內 100 次執行 append,沒有鎖粗化的就要進行 100 次加鎖/解鎖),此時 JVM 就會將加鎖的範圍粗化到這一連串的操作的外部(比如 while 虛幻體外),使得這一連串操作只需要加一次鎖即可。

             **更多技術文章,請掃碼關注微信公衆號“雲計算平臺技術”**

在這裏插入圖片描述

             **如果看完這篇文章後您還沒有升職加薪,也可以掃碼關注公衆號罵我哦~**
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章