synchronized的實現原理——物件頭解密

夜勿語發表於2020-08-30

前言

併發程式設計式Java基礎,同時也是Java最難的一部分,因為與底層作業系統和硬體息息相關,並且程式難以除錯。本系列就從synchronized原理開始,逐步深入,領會併發程式設計之美。

正文

基礎稍微好點的同學應該都知道,Java中獲取鎖有兩種方式,一種是使用synchronized關鍵字,另外一種就是使用Lock介面的實現類。前者就是Java原生的方式,但在優化以前(JDK1.6)效能都不如Lock,因為在優化之前一旦使用synchronized就會發生系統呼叫進入核心態,所以效能很差,也因此大神Doug Lea自己寫了一套併發類,也就是JUC,並在JDK1.5版本引入進了Java類庫。那麼作為Java的親兒子synchronized自然也不能示弱啊,所以sun公司對其做了大量的優化,引入了偏向鎖輕量級鎖重量鎖鎖消除鎖粗化,才使得synchronized效能大大提升。

執行緒模型

Java的執行緒本質是什麼?
首先我們需要了解執行緒的模型,實現執行緒有以下三種方式:

  • 使用核心執行緒,即一對一模型
  • 使用使用者執行緒,即一對多模型(一個核心執行緒對應多個使用者執行緒,如現在比較火的Golang)
  • 混合實現,即多對多模型,這種比較複雜,不用太過深入。

而Java現在就是採用的一對一模型(JDK1.2以前是使用的使用者執行緒實現),即當呼叫start方法時都是真實地建立一個核心執行緒(KLT),但程式一般不會直接使用核心執行緒,而是使用核心執行緒的一種高階介面——輕量級程式(LWP)。輕量級程式和核心執行緒也是一對一的關係,因此使用它可以保證每個執行緒都是一個獨立的排程單元,即當前執行緒阻塞了也不會影響整個程式工作,但帶來的問題就是線上程建立、銷燬、同步、切換等場景都會涉及系統呼叫,效能比較低;另外每個輕量級程式都要佔據一定的系統資源,因此,能夠建立的執行緒數量是有限的。

鎖優化

因為大部分情況下不會出現執行緒競爭,所以為了避免執行緒每次遇到synchronized都直接進入核心態,sun公司使用大量的優化手段:

  • 偏向鎖:當一個執行緒第一次獲得鎖後再次申請獲取就可以直接拿到鎖,相當於無鎖,這種情況下效率最高。
  • 輕量級鎖:在沒有多執行緒競爭,但有多個執行緒交替執行情況下,避免呼叫系統函式mutex(特指linux系統)產生的效能消耗。
  • 重量級鎖:發生了多執行緒競爭,就會呼叫mutex函式使得未獲取到鎖的執行緒進入睡眠狀態。
  • 鎖消除:程式碼經過逃逸分析後,判斷沒有資料會逃逸出執行緒,就不會給這段這段程式碼加鎖。
  • 鎖粗化:如果虛擬機器檢測到有一系列零碎的操作都對同一物件加鎖,就會將整個同步操作擴大到這些操作的外部,這樣就只需要加鎖一次即可。

本篇主要討論鎖膨脹的過程對物件的影響,所以總結為一句話就是:當一個執行緒第一次獲取鎖後再去拿鎖就是偏向鎖,如果有別的執行緒和當前執行緒交替執行就膨脹為輕量級鎖,如果發生競爭就會膨脹為重量級鎖。這個就是synchronized鎖膨脹的原理,但並不完全正確,其中還有很多細節,下面就一步步來說明。

物件的記憶體佈局

理論

物件在記憶體中是如何分配的呢?學過JVM的人應該都知道,如下圖:
在這裡插入圖片描述
但上圖只是說明了一個物件在記憶體中由哪幾部分組成,但具體每一部分多大,整個物件又有多大呢?比如下面這個類的物件在記憶體中佔用多少個位元組:

public class A{}

32位和64位虛擬機器表現不同,這裡以主流的64位進行說明。一個物件在記憶體中儲存必須是8位元組的整數倍,其中物件頭佔了12位元組,這裡A物件沒有例項資料,所以還需要4位元組的對其填充,所以佔用16位元組(如果該物件中有一個boolean物件的成員變數,這個物件又佔用多少位元組呢)。另外物件頭中也分為了兩部分,一部分是指向方法區後設資料的型別指標(klass point),固定佔用4位元組32位;另一部分則是則是用於儲存物件hashcode、分代年齡、鎖標識(偏向、輕量、重量)、執行緒id等資訊的mark word,佔用8位元組64位。由於型別指標是固定的,下面主要討論mark word部分的記憶體佈局。
我們可以看到在mark word中儲存了很多資訊,這麼多資訊64位肯定是不夠儲存的,那怎麼辦呢?虛擬機器將mark word設計成為了一個非固定的動態資料結構,意思是它會根據當前的物件狀態儲存不同的資訊,達到空間複用的目的,下圖就是一個物件的mark word在不同的狀態下儲存的資訊:
在這裡插入圖片描述
從上圖我們可以發現無鎖、偏向鎖、輕量鎖、重量鎖分別的狀態是:01、01、00、10,偏向鎖同時還需要額外的以為表示是否可偏向。因為當一個物件持有偏向鎖時,需要在物件頭中儲存執行緒id和偏向時間戳,佔用56bit,而物件的hashcode需要佔用31bit,空間就不夠了,所以一旦物件呼叫了未重寫的hashcode方法就無法獲取偏向鎖。
另外我們可以看到當鎖膨脹為輕量鎖或重量鎖時,物件頭中62bit都用來儲存鎖記錄(Lock record)的地址了,那他們的分代年齡、hashcode這些資訊去哪了呢?其實就存在於鎖記錄空間中,而鎖記錄是存在於當前執行緒的棧幀中的。虛擬機器會使用CAS操作嘗試把mark word指向當前的Lock record,如果修改成功,則當前執行緒獲取到該鎖,並標記為00輕量鎖,如果修改失敗,虛擬機器會檢查物件的mark word是否指向當前執行緒的棧幀,如果是,則直接獲取鎖執行即可,否則則說明有其它執行緒和當前執行緒在競爭鎖資源,直接膨脹為重量級鎖,等待的執行緒則進入阻塞狀態。

證明

偏向鎖

上面說的都是理論,怎麼證明呢?先引入下面這個依賴:

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

然後針對之前建立的A類,執行下面的方法:

public class TestJol {

    static A l = new A();

    public static void main(String[] args) throws InterruptedException {
        log.debug("執行緒還未啟動----無鎖");
        log.debug(ClassLayout.parseInstance(l).toPrintable());
    }
}

控制檯就會列印如下資訊:
在這裡插入圖片描述
我們主要看到二進位制部分內容前兩行內容(第三行是型別指標),按照之前所說,當前這個物件應該是無鎖可偏向狀態,那麼前25個bit應該是未被使用的,後三個bit應該是101,中間部分也應該都是0,但是圖中顯示的和我們理論不符啊。別急,這其實是由於我們現在的家用電腦基本上採用的都是小端儲存導致的,那什麼又是小端儲存呢?小端儲存就是高地址存高位元組,低地址存低位元組
在這裡插入圖片描述
所以小端地址輸出的格式是反著的從右到左(反之大端儲存輸出格式就是符合我們人類閱讀習慣的格式),這裡只是幫助理解,不深入探究大小端儲存問題。
因此之前輸出的資訊是符合我們上面所說的理論的,接著我們在輸出物件頭之前獲取下hashcode,看看會發生什麼,main方法中增加下面這行程式碼。

System.out.println(Integer.toHexString(l.hashCode()));

在這裡插入圖片描述
可以看到物件頭中儲存的hashcode和我們輸出的hashcode是一致的,同時狀態變為了無鎖不可偏向(001)
再來看看加鎖之後會有什麼變化:

    public static void testLock() {
        //偏向鎖  首選判斷是否可偏向  判斷是否偏向了 拿到當前的id 通過cas 設定到物件頭
        synchronized (l) {//t1 locked  t2 ctlock
            log.debug("name:" + Thread.currentThread().getName());
            //有鎖  是一把偏向鎖
            log.debug(ClassLayout.parseInstance(l).toPrintable());
        }

    }

去掉hashcode方法的呼叫並呼叫這個方法,另外還需要關閉偏向延遲-XX:BiasedLockingStartupDelay=0,否則也會直接膨脹為輕量鎖。輸出結果如下:
在這裡插入圖片描述
可以看到在獲取偏向鎖後將執行緒id存入到了物件頭中。

輕量鎖

接下來我們看看膨脹為輕量鎖的過程,導致膨脹輕量鎖的原因主要有以下幾點:

  • 呼叫了未重寫的hashcode方法
  • 開啟了偏向延遲(因為我們是短時間執行程式,預設延遲時間是4s中)
  • 多執行緒交替執行

前兩點讀者可自行列印輸出看看,這裡主要來看最後一點,使用如下程式:

public class TestJol {

    static A l = new A();

    static Thread t1;
    static Thread t2;
    public static void main(String[] args) throws InterruptedException {
        t1 = new Thread() {
            @SneakyThrows
            @Override
            public void run() {
                testLock();
                Thread.sleep(1000);
                testLock();
            }
        };

        t2 = new Thread() {
            @SneakyThrows
            @Override
            public void run() {
                testLock();
                Thread.sleep(2000);
                testLock();
            }
        };

        t1.setName("t1");
        t1.start();
        t2.setName("t2");
        t2.start();

    }

   public static void testLock() {
        //偏向鎖  首選判斷是否可偏向  判斷是否偏向了 拿到當前的id 通過cas 設定到物件頭
        synchronized (l) {//t1 locked  t2 ctlock
            log.debug("name:" + Thread.currentThread().getName());
            //有鎖  是一把偏向鎖
            log.debug(ClassLayout.parseInstance(l).toPrintable());
        }

    }
}

這裡建立了兩個執行緒t1、t2,各自先呼叫一次testLock方法,然後使用sleep睡眠讓出cpu後再呼叫一次,形成交替執行testLock方法,最終列印如下:
在這裡插入圖片描述
注意t1和t2首次都是獲取到的偏向鎖,並且執行緒id是相同的,但是按理說執行緒id應該會變才對,這裡筆者猜測為JVM優化,使得執行緒可以重用,但暫時還無法驗證。接著看後兩條記錄是睡眠之後列印的,這時t1和t2獲取到的鎖都是輕量級鎖了,物件頭中儲存的Lock record的地址,和我們猜測相符合。

重量鎖

最後去掉上面程式碼中的兩個sleep,這樣兩個執行緒就會發生競爭膨脹為重量鎖:
在這裡插入圖片描述
可以看到和我們的理論也是相符合的。

總結

本篇是併發系列的第一篇,也是synchronized原理的第一篇,主要分析了鎖物件在記憶體中的佈局情況以及鎖膨脹的過程,並通過程式碼驗證了所學理論,但synchronized的實現原理是非常複雜的,尤其是優化過後。更深入的內容將在後面的文章中逐步展開,另外讀者們可以思考一個問題,synchronized有沒有使用自旋鎖來優化?

相關文章