由DCL引發的一次思考

上次羣裏面試的小夥伴在看完對逃逸分析的說明後,下功夫好好學了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僅僅是要求前一個操作結果對後一個操作可見,如果兩個操作沒有數據依賴,則不一定會按順序執行。這是因爲爲了提高性能,即時編譯器和處理器都會對操作進行重排序。重排序包括以下三種:

  1. 即時編譯器優化重排序。
  2. 處理器指令級並行重排序。
  3. 內存系統重排序。

我們簡單描述一下會產生重排序的場景。

即時編譯器重排序,在即時編譯時,如果即時編譯發現操作順序可以優化,那可能會產生重排序,如下代碼:

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 of volatile 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做了兩個關鍵的事情:

  1. volatile在即時編譯時,禁止做指令的重排序
  2. 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併發編程實戰》

 

 

 

 

 

 

 

 

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