synchronized的實現原理——對象頭解密

前言

並發編程式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有沒有使用自旋鎖來優化?