05.深入理解JMM和Happens-Before

王有志發表於2023-01-03

大家好,我是王有志。

JMM都問啥?

最近沉迷P5R,所以寫作的進度很不理想,但不得不說高卷杏YYDS。話不多說,開始今天的主題,JMM和Happens-Before

關於它們的問題並不多,基本上只有兩個:

  • JMM是什麼?詳細描述下JMM。
  • 說說你對JMM的理解,為什麼要這樣設計?

Tips:本文以JMM理論為主。

JMM是什麼?

JMM即Java Memory Model,Java記憶體模型JSR-133 FAQ中對記憶體模型的解釋是:

At the processor level, a memory model defines necessary and sufficient conditions for knowing that writes to memory by other processors are visible to the current processor, and writes by the current processor are visible to other processors.

處理器級別上,記憶體模型定義了處理器核心間對彼此寫記憶體操作可見性的充要條件。以及:

Moreover, writes to memory can be moved earlier in a program; in this case, other threads might see a write before it actually "occurs" in the program.  All of this flexibility is by design -- by giving the compiler, runtime, or hardware the flexibility to execute operations in the optimal order, within the bounds of the memory model, we can achieve higher performance.

在記憶體模型允許的範圍內,允許編譯器、執行時或硬體以最佳順序執行指令,以提高效能。最佳順序是透過指令重排序得到的指令執行順序。

我們對處理器級別的記憶體模型做個總結:

  • 定義了核心間的寫操作的可見性
  • 約束了指令重排序

接著看對JMM的描述:

The Java Memory Model describes what behaviors are legal in multithreaded code, and how threads may interact through memory.It describes the relationship between variables in a program and the low-level details of storing and retrieving them to and from memory or registers in a real computer system.It does this in a way that can be implemented correctly using a wide variety of hardware and a wide variety of compiler optimizations.

提取這段話的關鍵資訊:

  • JMM描述了多執行緒中行為的合法性,以及執行緒間如何透過記憶體進行互動
  • 遮蔽了硬體和編譯器的實現差異,以達到一致的記憶體訪問效果

我們結合記憶體模型來看,JMM到底是什麼?

  • JVM的角度看,JMM遮蔽了不同硬體/平臺底層差異,達到一致的記憶體訪問效果
  • Java開發人員的角度看,JMM定義了執行緒間寫操作的可見性,約束了指令重排序

那麼為什麼要有記憶體模型呢?

“詭異”的併發問題

關於執行緒你必須知道的8個問題(上)中給出了併發程式設計的3要素,以及無法正確實現帶來的問題,接下來我們探究下底層原因。

Tips:補充一點Linux中執行緒排程相關內容。

Linux執行緒排程是基於時間片的搶佔式排程,簡單理解為,執行緒尚未執行結束,但時間片耗盡,執行緒掛起,Linux在等待佇列中選取優先順序最高的執行緒分配時間片,因此優先順序高的執行緒總會被執行

上下文切換帶來的原子性問題

我們以常見的自增操作count++為例。

直覺上我們認為自增操作是一氣呵成,沒有任何停頓。但實際上會產生3條指令:

  • 指令1:將count讀入快取;
  • 指令2:執行自增操作;
  • 指令3:將自增後的count寫入記憶體。

那麼問題來了,如果兩個執行緒t1,t2同時對count執行自增操作,且t1執行完指令1後發生了執行緒切換,此時會發生什麼?

我們期望的結果是2,但實際上得到1。這便是執行緒切換帶來的原子性問題。那麼禁止執行緒切換不就解決了原子性問題嗎?

雖然是這樣,但禁止執行緒切換的代價太大了。我們知道,CPU運算速度“賊快”,而I/O操作“賊慢”。試想一下,如果你正在用steam下載P5R,但是電腦卡住了,只能等到下載後才能愉快的寫BUG,你氣不氣?

因此,作業系統中執行緒執行I/O操作時會放棄CPU時間片,讓給其它執行緒,提高CPU的利用率

P5R天下第一!!!

快取帶來的可見性問題

你可能會想上面例子中,執行緒t1,t2操作的不是同一個count嗎?

看起來是同一個count,但其實是記憶體中count在不同快取中的副本。因為,不僅是I/O和CPU有著巨大的速度差異,記憶體與CPU的差異也不小,為了彌補差異而在記憶體和CPU間新增了CPU快取

CPU核心操作記憶體資料時,先複製資料到快取中,然後各自操作快取中的資料副本。

我們先忽略MESI帶來的影響,可以得到執行緒對快取中變數的修改對其它執行緒來說並不是立即可見的

Tips:擴充中補充MESI協議基礎內容。

指令重排序帶來的有序性問題

除了以上提升執行速度的方式外,還有其它“么蛾子”--指令重排序。我們把關於執行緒你必須知道的8個問題(上)中的例子改一下。

public static class Singleton {
	private Singleton instance;
	public Singleton getInstance() {
	    if (instance == null) {
		    synchronized(this) {
			    if (instance == null) {
				    instance = new Singleton();
			    }
		    }
	    }
	    return instance;
	}
  
	private Singleton() {
	}
}

Java中new Singleton()需要經歷3步:

  1. 分配記憶體;
  2. 初始化Singleton物件;
  3. instance指向這塊記憶體。

分析下這3步間的依賴性,分配記憶體必須最先執行,否則2和3無法進行,至於2和3無論誰先執行,都不會影響單執行緒下語義的正確性,它們之間不存在依賴性。

但是到了多執行緒場景下,情況就變得複雜了:

此時執行緒t2拿到的instance是尚未經過初始化的例項物件,重排序導致的有序性問題就產生了

Tips:擴充中補充指令重排序

JMM都做了什麼?

正式描述JMM前,JSR-133中提到了另外兩種記憶體模型:

  • 順序一致性記憶體模型
  • Happens-Before記憶體模型

順序一致性記憶體模型禁止了編譯器和處理器最佳化,提供了極強的記憶體可見性保證。它要求:

  • 執行過程中,所有讀/寫操作存在全序關係;
  • 執行緒中的操作必須按照程式的順序來執行;
  • 操作必須原子執行且立即對所有執行緒可見。

順序一致性模型的約束力太強了,顯然不適合作為支援併發的程式語言的記憶體模型。

Happens-Before

Happens-Before描述兩個操作結果間的關係,操作A happens-before 操作B(記作$A \xrightarrow{hb} B$),即便經過重排序,也應該有操作A的結果對操作B是可見的

Tips:Happens-Before是因果關係,$A \xrightarrow{hb} B$是“因”,A的結果對B可見是“果”,執行過程不關我的事。

Happens-Before的規則,我們引用《Java併發程式設計的藝術》中的翻譯:

程式順序規則:執行緒中的每個操作happens-before該執行緒中的任意後續操作。
監視器鎖規則:鎖的解鎖happens-before隨後這個鎖的加鎖。
volatile變數規則:volatile變數的寫happens-before後續任意對這個volatile變數的讀。
傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。
join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

以上內容出現在JSR-133第5章Happens-Before and Synchronizes-With Edges中,原文較為難讀。

這些看似是廢話,但是別忘了,我們面對的是多執行緒環境編譯器,硬體的重排序

再次強調,以監視器鎖規則為例,雖然只說瞭解鎖發生在加鎖前,但實際是解鎖後的結果(成功/失敗)發生在加鎖前。

Tips:Happens-Before可以翻譯為發生在...之前,Synchronizes-With可以翻譯為與...同步

另外JSR-133還還提及了非volatile變數的規則:

The values that can be seen by a non-volatile read are determined by a rule known as happens-before consistency.

非volatile變數的讀操作的可見性又happens-before一致性決定

Happens-Before一致性:存在對變數V的寫入操作W和讀取操作R,如果滿足$W \xrightarrow{hb} R$,則操作W的結果對操作R可見(JSR 133上的定義詮釋了科學家的嚴謹)。

JMM雖然不是照單全收Happens-Before的規則(進行了增強),不過還是可以認為:$Happens-Before規則 \approx JMM規則$。

那麼為什麼選擇Happens-Before呢?實際就是易程式設計約束性執行效率三者權衡後的結果。

圖中只選了今天或多或少提到過的記憶體模型,其中X86/ARM指的是硬體架構體系。

雖然Happens-Before是JMM的核心,但是除此之外,JMM還遮蔽了硬體間的差異;併為Java開發人員提供了3個併發原語,synchronizedvolatilefinal

擴充內容

關於記憶體模型和JMM的理論內容已經結束了,這裡為文章中出現的概念做個補充,大部分都是硬體層面的內容,不感興趣的話可以直接跳過了。

快取一致性協議

快取一致性協議(Cache Coherence Protocol),一致性用的並不是常見的Consistency。

Coherence和Consistency經常出現在併發程式設計,編譯最佳化和分散式系統設計中,如果僅僅從中文翻譯上理解你很容易誤解,實際上兩者的區別還是很大的,我們看維基百科中對一致性模型的解釋:

Consistency is different from coherence, which occurs in systems that are cached or cache-less, and is consistency of data with respect to all processors. Coherence deals with maintaining a global order in which writes to a single location or single variable are seen by all processors. Consistency deals with the ordering of operations to multiple locations with respect to all processors.

很明顯的,如果是Coherence,針對的是單個變數,而Consistency針對的是多個綿連。

MESI協議

MESI協議是基於失效的最常用的快取一致性協議。MESI代表了快取的4種狀態:

  • M(Modified,已修改),快取中資料已經被修改,且與主記憶體資料不同。
  • E(Exclusive,獨佔),資料只存在於當前核心的快取中,且與主記憶體資料相同。
  • S(Shared,共享),資料存在與多個核心中,且與主記憶體資料相同。
  • I(Invalid,無效),快取中資料是無效的。

Tips:除了MESI協議外還有MSI協議,MOSI協議,MOESI協議等,首字母都是描述狀態的,O代表的是Owned。

MESI是硬體層面做出的保證,它保證一個變數在多個核心上的讀寫順序

不同的CPU架構對MESI有不同的實現,如:X86引入了store buffer,ARM中又引入load buffer和invalid queue,讀/寫緩衝區和無效化佇列提高了速度但是帶來了另一個問題。

指令重排序

重排序可以分為3類:

  • 指令並行重排序:沒有資料依賴的情況下,處理器可以自行最佳化指令的執行順序;
  • 編譯器最佳化重排序:不改變單執行緒語義的前提下,編譯器可以重新安排語句的執行順序;
  • 記憶體系統重排序:引入store/load buffer,並且非同步執行,看起來指令是“亂序”執行的。

前兩種重排序很好理解,但是記憶體系統重排序要怎麼理解呢?

引入store buffer,load buffer和invalid queue,將原本同步互動的過程修改為了非同步互動,雖然減少了同步阻塞,但也帶來了“亂序”的可能性。

當然重排序也不是“百無禁忌”,它有兩個底線:

資料依賴

兩個操作依賴同一個資料,且其中包含寫操作,此時兩個操作之間就存在資料依賴。如果兩個操作存在資料依賴性,那麼在編譯器或處理器重排序時,就不能修改這兩個操作的順序

as-if-serial語義

as-if-serial語義並不是說像單執行緒場景一樣執行,而是無論如何重排序,單執行緒場景下的語義不能被改變(或者說執行結果不變)

推薦閱讀

關於記憶體模型和JMM的閱讀資料

雖然《Time, Clocks, and the Ordering of Events in a Distributed System》是討論分散式領域問題的,但在併發程式設計領域也有著巨大的影響。

最後說個有意思的事情,大佬們的部落格都異常“樸素”。

Doug Lea的部落格首頁:

Lamport的部落格首頁:

結語

最近沉迷P5R,一直在偷懶~~

JMM的內容刪刪減減的寫得很糾結,因為涉及到併發原理時,從來不是程式語言自己在戰鬥,從CPU到程式語言每個環節都有參與,所以很難把控每部分內容的詳略。

不過好在也是把JMM的本質和由來說明白了,希望這篇對你有所幫助,歡迎各位大佬留言指正。


好了,今天就到這裡了,Bye~~

相關文章