上次羣裏面試的小夥伴在看完對逃逸分析的說明後,下功夫好好學了HotSpot即時編譯的相關知識,信心十足的又去面試了,結果又讓回家等消息。
看了看他分享的面試題,又在各種高大上的題目裏發現了一道有意思的題:請看一個DCL單例模式,並簡單說明一下是否正確。
這題目過於簡單,不像是這種級別的面試題,所以這道題大有深意,很有意思。
先上代碼:
/**
* @author liuyan
* @date 19:44 2020/4/4
* @description
*/
public class DCLDemo {
private int num;
private static DCLDemo demo;
private DCLDemo() {
this.num = 10; //(1)
}
public static DCLDemo getInstance() {
if (demo == null) { //(2)
synchronized (DCLDemo.class) { //(3)
if (demo == null) { //(4)
demo = new DCLDemo(); //(5)
}
}
}
return demo; //(6)
}
public int getNum() {
return num; //(7)
}
}
很標準的錯誤示範,上學認真聽講的可能能指出來:
private static DCLDemo demo;
需要改成:
private volatile static DCLDemo demo;
我們簡單分析一下,這就要從JVM內存模型說起:
在JVM中每個線程都會有一份本地內存,包括寫緩衝、寄存器等等,其中保存了共享變量的副本。這就涉及到了內存可見性,線程A對共享變量的修改,線程B不一定會立即看到。爲了描述內存可見性,Java內存模型引入了happens-before語義:如果一個操作的結果對另一個操作可見,那麼兩個操作之間一定要滿足happens-before關係。這裏很值得注意:滿足happens-before的規則,前一個操作的代碼一定會在後一個操作之前執行嗎?當然不是,因爲happens-before僅僅是要求前一個操作結果對後一個操作可見,如果兩個操作沒有數據依賴,則不一定會按順序執行。這是因爲爲了提高性能,即時編譯器和處理器都會對操作進行重排序。重排序包括以下三種:
- 即時編譯器優化重排序。
- 處理器指令級並行重排序。
- 內存系統重排序。
我們簡單描述一下會產生重排序的場景。
即時編譯器重排序,在即時編譯時,如果即時編譯發現操作順序可以優化,那可能會產生重排序,如下代碼:
private void test() {
int i = 0;
int a, b;
while (i++ < 100) {
a = 100;
b = i + 1;
}
}
在循環中,a的賦值與循環無關,所以可以把a的賦值移除到循環外,這就會產生即時編譯重排序。
處理器指令集並行重排序,也就是我們計算機系統結構裏說的:當指令之間不存在相關時,它們在流水線中是可以重疊起來並行執行的。如下代碼:
private void test() {
int[] array = new int[10];
for (int i = 0; i < 10; i++) {
array[i] = i;
}
}
我們可以對其進行循環展開,變成不相關的10個賦值語句,那麼就可以在流水線中並行執行。其實在即時編譯中,HotSpot對循環做了相當多的優化,比如循環無關外提、循環展開、分支預測等。(其實學這部內容時,一直在感嘆計算機的知識點真的是一張網,從計算機系統結構的設計到上層高級語言的設計,環環相扣,讓人心中敬畏。)
話說回來,我們看下JUC包中描述的常見的happends-before規則:
- Each action in a thread happens-before every action in that thread that comes later in the program's order.
- An unlock (
synchronized
block or method exit) of a monitor happens-before every subsequent lock (synchronized
block or method entry) of that same monitor. And because the happens-before relation is transitive, all actions of a thread prior to unlocking happen-before all actions subsequent to any thread locking that monitor.- A write to a
volatile
field happens-before every subsequent read of that same field. Writes and reads ofvolatile
fields have similar memory consistency effects as entering and exiting monitors, but do not entail mutual exclusion locking.- A call to
start
on a thread happens-before any action in the started thread.- All actions in a thread happen-before any other thread successfully returns from a
join
on that thread.
那麼基於以上的規則,我們分析一下錯誤示範代碼中的DCL單例爲什麼錯誤。也就是在多線程中,代碼(1)是否會happens-before代碼(7)。
當兩個線程都執行到了語句2時,此時有兩種場景,即第一種場景線程1看到了線程2對實例的初始化,也就是不爲null。第二種場景線程1此時看到的instance==null。
我們先看線程1看到instance==null的場景,也就是線程1與2都會執行同步代碼塊(3),又因爲上述happends-before規則第二條描述的,synchronized的unlock操作一定會happens-before於synchronized的lock操作。也就是說線程2的(6)happends-before線程1的(7),又因爲線程2的(1)happens-before線程2的(6),因爲happens-before的傳遞性,那麼線程2的(1)happend-before線程1的(7),所以此時DCL正確。
再看線程1看到instance!=null的場景,線程1會執行(6)、(7),這兩個操作與線程2的所有操作沒有滿足上述happends-before的任意一條,所以我們說線程2的(1)不具備happends-before線程1的(7),所以此時DCL是錯誤的。
那麼爲什麼加上關鍵字volatile就可以保證DCL的正確呢?
我們看happens-before規則的第三條,任何對volatile的寫操作都會happends-before其後的對於volatile的讀操作。因此,加上volatile關鍵字之後,線程2對instance的寫就會happends-before線程1對instance的讀,所以DCL可以保證正確。
那麼volatile關鍵字到底做了什麼來保證內存可見性?
volatile做了兩個關鍵的事情:
- volatile在即時編譯時,禁止做指令的重排序
- volatile通過增加讀寫屏障,保證了內存可見性
第一個很好解釋,對於volatile的變量禁止進行指令重排序,保證了指令執行順序與代碼時序相同。
第二個,volatile如何保證內存可見性。前文說過Java的內存模型,每個線程都有本地內存,其中就包括了寫緩衝。一般處理器爲了提高性能,對於變量的寫,會先更新到寫緩衝區,批量合併更新到內存。那就導致了,某個線程對共享變量的修改,其他線程不一定會立即看到。volatile做的事情就是,在對volatile的變量寫時,會直接刷新到內存,對volatile的變量讀時,會直接從內存中讀取。這又涉及到了CPU的緩存一致性協議MESI。
MESI代表了緩存行的四種狀態:
- Modified修改狀態:此時緩存行有效,與內存不一致,並且該緩存行只存在於本cache中。
- Exclusive獨佔狀態:此時緩存行有效,與內存一致,並且被該cache獨佔。
- Shared共享狀態:此時緩存行有效,與內存一致,被很多cache共享。
- Invalid無效狀態:此時緩存行無效。
緩存行狀態變更是一個非常複雜的過程,我們簡單介紹幾種:
當CPU-A讀取某數據時,該緩存行在CPU-A的cache爲E狀態,其他CPU爲I狀態。
當CPU-B也要讀取該數據時,會先告知CPU-A修改爲S狀態,CPU-B此時也爲S狀態。
當CPU-A修改數據時,設置爲M狀態,並告知CPU-B設置爲I狀態。
當CPU-B再讀取數據時,CPU-A會先同步數據到內存,然後設置爲E狀態,再同步CPU-B數據,CPU-A與CPU-B設置爲S狀態。
我們可以看到,這麼一個簡單的多核讀取數據、修改數據、再讀取數據就如此的複雜,如果CPU真這麼設計,那執行會得多慢。所以前文所說的寫緩衝就是在這裏用到的,CPU對數據的讀/寫都是會先讀/寫到寫緩衝,再批量刷新。並且還提供了寫屏障與讀屏障,來保證內存可見性。
讀屏障:在執行讀屏障之後的指令之前,要先執行所有的失效緩存行操作。
寫屏障:在執行完寫屏障之前的所有指令後,要先執行所有的刷新內存操作。
那麼實際上,volatile只不過是在寫之後加了一個寫讀屏障,也就是volatile寫之後,先執行所有的刷新內存操作,就保證了內存中volatile數據的正確性,接着執行所有失效緩存行操作,保證了其他cache中關於該volatile數據全部失效。這樣在其他cpu讀取該volatile數據時,會發現本地cache失效,從內存中讀取。
如此就保證了,volatile的內存可見性。
以上,就分析完畢DCL的正確性。
深有感觸,計算機的知識真的是環環相扣,終於也明白前輩們諄諄教誨我們基礎的重要性。沒有基礎,很難把這一環環的知識編織成網。
感謝前輩們的總結:
https://www.cnblogs.com/z00377750/p/9180644.html
《深入理解Java內存模型》
《深入拆解Java虛擬機》
《java併發編程實戰》