全網最權威:再次打破你對synchronized的認知!!!

這篇文章,我會直接把對象頭的信息打印出來給你看!!!

其實 synchronized 有關的博客我之前也寫過,描述的也還算比較清晰,比較深入。
比如這篇:99%的人答不對的併發題
還有這篇:當面試官懟你 synchronized 性能差時,你拿這篇文章吊打他(ReentrantLock 與 synchronized 的前世今生)

這兩篇對我來說算是比較古老的文章了,都快有兩個月了。
而且實際上,個人認爲,寫得不算很出色,雖然讀者給的反饋還不錯

不過,對於我們要精通 Java 的人來說,我覺得還不夠。
首先,裏面的很多知識,網上面有一部分博客也寫到了,對於這些很多大家都知道的知識,寫出來畢竟意義也不是特別大。

而對於很多 synchronized 的優化點,大部分人是不清楚的。
實際上,在 synchronized 被優化之後,有很多很多的優化點,除了鎖粗化、鎖消除、偏向鎖這些耳熟能詳的之外,實際上還有一些其它的優化。

在這裏,我的畫圖演示不會很多,因爲我之前的博客已經描述過了;
這裏更多的是一個證明,和知識的補充

所以,這裏重點關注的,是我代碼運行之後的結果!!!
你可以直接看到我程序中打印出來的對象頭的信息,從而弄清,synchronized 的鎖到底是怎麼一回事。

如果你要看一些基礎的流程等等,那就去看我之前的博客即可。

對象頭

首先,基本上只要你去看 synchronized 的文章,你大部分情況會看到這麼一張圖:
可能很多人都是看了別人的博客是這麼寫的,然後自己也總結了一份,也是這樣子,然後慢慢的,幾乎網上的都是這個樣子。
在這裏插入圖片描述
但是,我在這裏不是說這個圖是錯的!

但是我也不是說它就是對的!

什麼意思,就是這個圖很可能對你來說就是錯的。
因爲,那篇文章是別人寫的,他研究的可能就是 32 位的機器,而你用的,則是 64 位的 Java 虛擬機。
比如我的就是 64 位:
在這裏插入圖片描述
現在 2020 年了,好歹你得看一個 64 位的吧。

而對於這個對象頭,Hotspot 源碼裏面有這麼一段註釋:
在這裏插入圖片描述
整理一下知識點可以有如下的表格:

|------------------------------------------------------------------------------|--------------------|
|                                  Mark Word (64 bits)                         |       State        |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |       Normal       |
|------------------------------------------------------------------------------|--------------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |       Biased       |
|------------------------------------------------------------------------------|--------------------|
|                       ptr_to_lock_record:62                         | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
|                     ptr_to_heavyweight_monitor:62                   | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
|                                                                     | lock:2 |    Marked for GC   |
|------------------------------------------------------------------------------|--------------------|

如果你不太明白的話,還是最好去先看一下我之前寫的那篇文章。

對於對象的分析,openjdk 提供了 jol 工具,可以用來查看對象的信息。

所以我們先來看一下一個普通對象是什麼樣子的:

public static void main(String[] args) {
    // 直接new一個Object查看分析
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

在這裏插入圖片描述
我們能看到,這個對象的大小一共是 16 bytes;
其中對象頭有 12 bytes;
多餘的 4 bytes 用於對齊。

如果你不明白對齊是什麼意思,那我給你一個示例:

public class ObjectSize {
    static class MyObject {
        byte b; // 一個類中有一個1byte的成員變量
    }
    public static void main(String[] args) {
        MyObject o = new MyObject();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

在這裏插入圖片描述
可以看到,這個時候,對象頭 12 bytes;
加上一個成員變量 1 byte,一共 13 bytes;
這時候,對齊,到了 16 bytes。

其實就是對象的大小,必須是 8 bytes的倍數。
其它的各種大小我就不演示了,你可以自己去測試,就能證明,對象的對齊是什麼意思。

當然,還有一點提及一下,如果是數組對象,還會有一個額外的空間,4 bytes, 來表示數組的長度。

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

在這裏插入圖片描述
很明顯,對象頭變成 4 行了,多了 4 bytes 的數據;
然後,由於數據大小是 26 bytes,對齊之後變成了 32 bytes。

不過,有一個小點要提一下,就是對象頭裏的執行類的指針按道理是 64 位,也就是 8 字節對不對?
但是,我們看到的,第三行類指針只有 4 bytes 對吧?
這是因爲,虛擬機默認開啓了指針壓縮,如果我把指針壓縮關了,就會變成 8 bytes 了。

我再拿之前的 MyObject 來舉例:
在這裏插入圖片描述
可以發現,確實整個對象頭比之前大了 4 bytes;
然後這時對齊之後就會變成 24 bytes 了。

這個知識點就算過了,因爲主要是要講 synchronized 的,所以,通過這些演示,你能看懂這些是什麼意思即可。

MarkWord

下面要開始研究 synchronized 鎖機制:
首先我們看一下,一個剛 new 出來的新對象,是什麼樣子的。
我們現在既然要探究 synchronized,那麼自然着重研究那 64 位的 MarkWord,所以我截圖就只截關鍵的那部分。

public static void main(String[] args) throws Exception {
    final Object o = new Object();
    // 打印新對象
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

在這裏插入圖片描述
然後,我們就可以對照那張表格進行分析:

首先,是沒有加鎖的情況。
在這裏插入圖片描述
首先,如果我什麼都不說,你們直接對着這張表,去按照順序對照裏面的每一個位,你會發現是對不上的!
因爲,計算機裏面的存儲方式,並沒有你想象的那麼簡單,有大小端存儲的概念。

我這裏幫你們把概念百度出來。
其實看不看無所謂,因爲我會幫你們標出來,哪些位分別對應哪些。

大端模式,是指數據的高字節保存在內存的低地址中,而數據的低字節保存在內存的高地址中,這樣的存儲模式有點兒類似於把數據當作字符串順序處理:地址由小向大增加,而數據從高位往低位放;這和我們的閱讀習慣一致。
小端模式,是指數據的高字節保存在內存的高地址中,而數據的低字節保存在內存的低地址中,這種存儲模式將地址的高低和數據位權有效地結合起來,高地址部分權值高,低地址部分權值低。

00000000(56-63) 00000000(48-55) 00000000(40-47) 00000000(32-39)
00000000(24-31) 00000000(16-23) 00000000(08-15) 00000000(0-7)

從每一個大段來看,是倒着排列的;
從每一個小段來看,是正着排列的。

這樣,你就可以對應起來,哪一些位對應表示什麼信息。

00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000

0-24:unused
所以加粗的這些 bit 全部是 0。
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000

25-55:identity_hashcode
加粗的這些 bit 就表示 hashcode。
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000

61 位:偏向鎖標誌:biased_lock
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000

62、63:lock
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000

這時,我們再看新 new 出來的 Object,它的 MarkWord 除了最後三位 101,其他全部是 0。
這時,由於虛擬機默認是開啓偏向鎖的,所以我們和偏向鎖的 MarkWord 做一個對比:
在這裏插入圖片描述
在這裏插入圖片描述

  • 偏向鎖標誌位:1,代表可以偏向。
  • 鎖標誌位:01,結合偏向鎖標誌位,代表這是一個偏向鎖。
  • 其他全是 0,說明此時還沒有偏向任何一個線程。

偏向鎖

然後,我們看,當一個線程獲取鎖之後,打印的結果:

public static void main(String[] args) throws Exception {
    final Object o = new Object();
    synchronized (o) {
        // 獲取鎖時的效果
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
    // 鎖釋放後的效果
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

在這裏插入圖片描述
也就是 0-53 全部用來標記了線程,然後 54、55 用來記錄 epoch 值。

我們這時也可以看到鎖的標誌爲 101,代表着這是一把偏向鎖。

不過,有意思的是,釋放鎖之後,打印出的信息,和釋放鎖之前的信息完全一樣,也就是,實際上,線程執行結束同步代碼塊,並沒有釋放鎖!!!

所以,這很好的證明了,一但一把鎖偏向了一個線程之後,它就會把這個線程信息記錄起來,不去釋放鎖;
然後以後同樣的線程再來加鎖,就可以省去很多加鎖操作。

不過,有一個點大家可能不知道,或者聽說過,但是也不知道是不是真的:
就是,對象計算過 identity_hashcode 之後不能夠再偏向!

所以,我們現在就可以進行證明,這個說法,到底是不是真的?

public static void main(String[] args) throws Exception {
    final Object o = new Object();
    o.hashCode(); // 計算identity_hashcode
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

在這裏插入圖片描述
答案很明確,計算過 identity_hashcode 之後,就無法再偏向。

原因也很好理解:

  • 因爲偏向鎖要存放偏向的線程信息;
  • 而計算了 identity_hashcode 之後,就要存放 identity_hashcode 的值;
  • 所以此時沒有空位存放線程信息了,就無法再進行偏向。

性能對比

現在,看完了這麼多情況的 MarkWord,想必你對偏向鎖就有一定的瞭解了。
不過,其實很多人認爲,synchronized 優化的比較雞肋,既然已經有輕量級鎖這種東西了,還需要偏向鎖幹什麼?
它可能會快那麼一點,但是又能快到哪裏去?

所以,爲了防止你有這樣的誤會,我給你做一個測試:
讓在偏向鎖、輕量級鎖的情況下,分別加鎖 1 億次,來測試時間。

public static void main(String[] args) throws InterruptedException {
    final Object o = new Object(); // 鎖
    long start,end; // 記錄時間
    // 偏向鎖測試
    start = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        synchronized (o) {}
    }
    end = System.currentTimeMillis();
    System.out.println("偏向鎖" + (end - start) + "ms");
    // 用另一個線程使鎖升級爲輕量級鎖
    Thread t2 = new Thread() {
        public void run() {
            synchronized (o) {}
        }
    };
    t2.start();
    t2.join();
    // 測試輕量級鎖
    start = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        synchronized (o) {}
    }
    end = System.currentTimeMillis();
    System.out.println("輕量級鎖" + (end - start) + "ms");
}

在這裏插入圖片描述
有了數據,我就沒有必要去多做解釋了。

鎖是否會退化

然後,有些人還有疑問:
這裏鎖升級了,成爲了輕量級鎖,但是,會不會出現鎖降級的情況?
你看,這裏 main 線程,又循環加鎖了 1 億次,會不會虛擬機看不下去,重新改回了偏向鎖?

這種問題也只要做一個測試:
我在最後添加了一行代碼,打印一下,對象的當前信息。
在這裏插入圖片描述
你可以很清晰的發現,即使循環了 1 億次,也沒有重新偏向。

批量重偏向

那麼,很多人就會認爲,偏向鎖不能重偏向。
還有和很多人認爲能,但是也不知道怎麼才能。

這裏,我就要提到一個批量重偏向的概念:

public static void main(String[] args) throws InterruptedException {
    // 創建100個object
    final Object[] objects = new Object[100];
    for(int i = 0; i < objects.length; i++) {
        objects[i] = new Object();
    }
    // t1全部上一遍鎖,此時便全部是偏向t1的偏向鎖
    Thread t1 = new Thread() {
        public void run() {
            for(int i = 0; i < objects.length; i++) {
                synchronized (objects[i]) {}
            }
        }
    };
    t1.start();
    t1.join();
    // t2,全部上一次鎖
    for(int i = 0; i < objects.length; i++) {
        synchronized (objects[i]) {}
    }
    // 打印18、19號對象
    System.out.println(ClassLayout.parseInstance(objects[18]).toPrintable());
    System.out.println(ClassLayout.parseInstance(objects[19]).toPrintable());
}

在這裏插入圖片描述
我們可以發現,在第 19 個對象(也就是 18 號)第二次加鎖的時候,還會升級爲輕量級鎖;
但是,從第 20 個對象(19 號)第二次加鎖的時候,就不會升級爲輕量級鎖,而是重新偏向,偏向主線程。

延遲偏向鎖

其實,偏向鎖還有一個延遲的概念。
有些人如果瞭解偏向鎖額延遲,那麼在看我之前的代碼的時候,可能就會認爲,我手動調整了參數,讓虛擬機啓動的時候就立刻開啓偏向鎖。
實際上不是的,應該每個版本,它的機制會有變化,我的虛擬機默認在啓動的時候就開啓偏向鎖,並沒有手動設置。

當然,下面爲了解釋延遲這個概念,我手動設置一下,給我的虛擬機增加一個偏向鎖延遲。
在這裏插入圖片描述
這時,啓動時 new 一個新對象,然後查看信息:

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

在這裏插入圖片描述
你會發現,這個對象不是一個偏向鎖,而是一個輕量級鎖。

如果,我在開頭,增加一行代碼:

public static void main(String[] args) throws InterruptedException {
    Thread.sleep(1000); // 睡眠一會,比你設置的延遲時間長即可
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

你就會發現,這個對象又變回了偏向鎖:
在這裏插入圖片描述

也就是說,虛擬機並不是一啓動就立刻有了偏向鎖,而是在短暫延遲過後,纔會開啓偏向鎖

其實因爲虛擬機啓動的時候,會做很多很多的事,這裏面用到了很多 synchronized 關鍵字來同步,而啓動的時候,是有很多線程競爭的,所以鎖一定不會保持偏向的狀態。
於是,虛擬機便將偏向鎖關閉了,直到啓動完成之後,纔會將偏向鎖又重新設置打開。
所以,就會有一定的延遲,而這個延遲時間,則可以由參數配置。

不同線程獲取相同偏向鎖

不過,除了這些,還有一個神奇的現象:

public static void main(String[] args) throws InterruptedException {
    final Object o = new Object();
    // 第1個線程加鎖
    Thread t1 = new Thread() {
        public void run() {
            synchronized (o) {
                System.out.println(ClassLayout.parseInstance(o).toPrintable());
            }
        }
    };
    t1.start();
    t1.join();
    // 第2個線程加鎖
    Thread t2 = new Thread() {
        public void run() {
            synchronized (o) {
                System.out.println(ClassLayout.parseInstance(o).toPrintable());
            }
        }
    };
    t2.start();
    t2.join();
    // 第3個線程加鎖
    Thread t3 = new Thread() {
        public void run() {
            synchronized (o) {
                System.out.println(ClassLayout.parseInstance(o).toPrintable());
            }
        }
    };
    t3.start();
}

我們看起來是 new 了幾個不同的線程去依次執行,但是,可能會出現這種結果:
(這個結果是可能出現,可能運行了會出現,也可能不會)
在這裏插入圖片描述
我們發現,雖然不同的線程來執行,但是,我們發現,每次不一樣的線程來執行的時候,卻發現,底層虛擬機記錄的線程是同一個線程,所以就能獲取同一個偏向鎖,沒有使得鎖升級。

但是,由於每次運行完一個線程之後,線程死亡,然後創建新線程運行;
而對象中記錄的線程的信息,是 JVM 映射的操作系統中的線程;

那麼我們現在就暫時無法區分:

  • 到底是操作系統分配線程的時候,在前一個線程死亡後,就又分配了同樣的線程 id;
  • 還是操作系統並沒有分配同樣的線程 id,而是 JVM 爲了利用偏向鎖,而把這個不一樣的線程,在虛擬機中分配了前一個線程的相同的線程 id,所以在虛擬機看來就是同一個線程;

爲了證明,我給每個線程加了點 sleep,然後我好在外部去打印線程信息。
在這裏插入圖片描述
然後點擊運行,開始操作。
請添加圖片描述
請添加圖片描述
請添加圖片描述
這時,我們便知道答案了。

操作系統分配的線程是不同的;
但是 JVM 給這三個線程分配的線程 id 都是一樣的。

所以,很明顯,是 JVM 爲了優化 synchronized 的偏向鎖,故意分配了相同的線程 id,來提高性能。

wait

還有一點要提的就是,在調用 wait 的時候,也會使對象膨脹成重量級鎖。
畢竟,wait 就是對象監視器來管理的,並且,要阻塞自己。
所以,肯定需要升級成爲重量級鎖。

public static void main(String[] args) throws InterruptedException {
    final Object o = new Object();
    new Thread() {
        public void run() {
            synchronized (o) {
                try {
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }.start();
    // 沒有再次上鎖,直接打印信息
    Thread.sleep(1000);
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

在這裏插入圖片描述

寫在最後

實際上,對於 synchronized 的解釋,筆者不能給出過於權威的答案,因爲畢竟沒有從虛擬機源碼的角度來給出解答。
所以,筆者在這裏,僅僅只是提及了這麼一些知識點,通過官方的文檔給出的說明,一一作了驗證,來給出各位答案,避免走入誤區。

當然,synchronized 的知識點,想來還會有更多,比如:
Object monitor,wait set,entry list;
鎖消除、所粗化等等;

這些筆者嘗試過驗證出結果給各位,但是,在 Java 層面,無法獲取到這些虛擬機底層的代碼信息,所以最終沒能驗證出;
所以,對於這些,筆者無法給出答案。

想要繼續深入這些知識點的話,那就需要閱讀虛擬機源碼了。
筆者能力有限,再加上時間精力的有限,暫時只能給出這些知識點,大家可以根據自己的情況,來繼續學習。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章