淺談JMM

Captain&D發表於2019-07-29

概述

JMM的全稱是Java Memory Model(Java記憶體模型)

JMM的關鍵技術點都是圍繞著多執行緒的原子性、可見性和有序性來建立的,這也是Java解決多執行緒並行機制的環境下,定義出的一種規則,意在保證多個執行緒間可以有效地、正確地協同工作。

三要素

原子性(Atomicity)

原子性是指一個操作是不可中斷的,即使是在多個執行緒一起執行的情況下,一個操作一旦開始執行,就不會受到其他執行緒的干擾。

比如,有兩個執行緒同時對一個靜態全域性變數int num進行賦值,執行緒A給他賦值為1,執行緒B給他賦值為2,那麼不管這兩個執行緒以何種方式何種步調去執行,num的值最終要麼是1要麼是2,執行緒A和執行緒B在賦值操作期間,是不可能受到對方干擾的,這就是原子性的一個特點——不可被中斷

但如果我們不使用int型別而是用long型別的話,可能就會出現差池了,因為對於32位系統來說,long型別資料的寫入不是原子性的(因為long有64位),也就是說,如果兩個執行緒在32位作業系統下同時對一個long型別的資料進行同步操作,那麼執行緒之間的資料操作可能是有干擾的。

可見性(Visibility)

可見性是指在多執行緒情況下,當一個執行緒修改了某一個共享變數的值之後,其他執行緒是否能夠立即知道這個修改。顯然,對於序列執行緒來說,可見性問題是不存在的,因為你在任何一個操作步驟中修改了某個變數的值,那麼在後續步驟中,讀取這個變數的值,一定是修改後的新值。

但是這個問題在並行程式中就不見得了。

如果一個執行緒修改了某一個全域性變數,那麼其他執行緒未必能夠馬上知道這個改動,如下圖便展示了可見性問題的一種可能:

如果在CPU1和CPU2上各執行一個執行緒,它們共享變數v,由於編譯器優化或者硬體優化的緣故,在CPU1上對變數v進行了優化,將這個值拷貝快取到cache或者暫存器中,這種情況下,如果在CPU2上的某個執行緒修改了變數v的實際值,那麼CPU1上的執行緒可能無法感知這個改動,依然會讀取之前拷貝到cache或者暫存器裡的資料進行操作,因此這就產生了可見性問題。外在表現為:變數v的值被修改了,但是CPU1上的執行緒依然會讀到一個修改之前的舊值。可見性問題也是並行程式開發中需要哪個重點關注的問題之一。

可見性問題是一個綜合性問題,除了上述提到的快取優化或者硬體優化(有些記憶體讀寫可能不會立即出發,而是先進入到一個硬體佇列等待)會導致可見性問題外,指令重排以及編譯器優化等,都有可能導致一個執行緒的修改不會立即被其他執行緒所察覺到。

有序性(Ordering)

有序性問題可能是比較難理解的一個問題。對於一個執行緒執行的程式碼而言,我們總是習慣地認為程式碼總是按照書寫順序從先往後依次執行,這在單執行緒環境下,確實如此,但是在多執行緒併發環境下估計就不見得了,程式的執行可能就會出現亂序,給人的感覺就是寫在前面的程式碼可能在後面執行了。其實有序性問題的原因是因為程式在執行時,可能因為編譯器優化的緣故,進行了指令重排的操作,重排後的指令與原指令的順序未必一致

我們上面的敘述都是以不確定的口吻來表達的,我們都說是這種情況下可能存在,因為如果沒有指令重排的現象發生,問題就不存在了,但是指令重排是否發生、如何進行指令重排、何時進行指令重排,我們不得而知也無法預測。因此對於這類問題,我們比較嚴謹的描述就是:執行緒A的指令執行順序線上程B看來是沒有保證的,如果運氣好,執行緒B也許真的可以看到和執行緒A一樣的執行順序。

不過這裡我們還需要強調一點,對於一個執行緒來說,它看到的指令執行順序一定是一致的,也就是說指令重排是有個一基本前提的,就是必須保證序列語義的一致性,不管指令怎麼重排序都不會使序列的語義邏輯發生問題

注意:指令重排可以保證序列語義一致,但是沒有義務保證多執行緒間的語義也一致

那麼為什麼要指令重排呢?

之所以這麼做,完全是基於程式碼執行的效能考慮的。我們知道,一條指令的執行是分多個步驟的,簡單的說,可以分為以下幾步:

  1. 取指 IF
  2. 譯碼和取暫存器運算元 ID
  3. 執行或者有效地址計算 EX
  4. 儲存器訪問 MEM
  5. 寫回 WB

我們的彙編指令也不是一步就執行完成的,在CPU中實際工作時,它還是需要分多個步驟依次執行的。當然,每個步驟所涉及的硬體也可能不同,比如取值時會用到PC暫存器和儲存器,譯碼時會用到指令暫存器組,執行時會使用ALU,寫回時需要暫存器組。

由於每一個步驟都可能使用不同的硬體來完成,因此,聰明的工程師們發明了流水線技術來執行指令,如下圖所示的工作原理:

可以看到,當第二條指令執行時,第一條指令其實並未執行完,確切地說是第一條指令還沒有開始執行,只是完成了取指的操作而已。這樣的好處就非常明顯了,假如這裡每一個步驟都需要花費1毫秒,那麼指令2等待指令1完全執行後再執行,則需要等待5毫秒的時間,而是用這種流水線模式後,指令2就只需要等待1毫秒的時間就可以開始執行了,這樣以來就帶來了很大的效能提升,在商業環境中這種流水線級別甚至更高,效能提升就愈加的明顯了。

有了流水線這種模式,我們的CPU才能真正更高效的執行,但是,流水線總是害怕被迫中斷。流水線滿載時效能是很高的,但是一旦中斷,所有的硬體裝置就會進入到停頓器,等到再次滿載執行就又要等到幾個週期,因此效能損失會很大,所以我們必須想辦法不讓流水線中斷。

那麼答案就來了,之所以需要做指令重排,就是為了儘量減少指令流水線執行時的中斷。當然了,指令重排只是減少中斷的一種技術,實際上在CPU涉及中,還有更多的軟硬體技術來防止中斷,這裡就不做更多敘述了。

為了加深對指令重排序的認識,理解指令重排序對效能提升的意義,我們通過一些簡單的例子來增加感性的認識。

下圖展示了A=B+C這個操作的執行過程,寫在左邊的是彙編指令,其中LW表示load載入,LW R1,B就是表示將B的值載入到R1暫存器當中,ADD是加法,LW R3,R1,R2就是表示將R1R2的值相加並存放到R3中,SW表示儲存,SW A,R3就是表示將R3暫存器的值儲存到變數A中。

(A=B+C的執行過程,圖示仿自書籍)

右邊就是流水線的情況,其中在ADD指令上就有一個大X,這就表示一箇中斷,為什麼這裡會有中斷(停頓)呢?原因很簡單,R2中的資料還沒有準備好,必須要等到它寫回到儲存器上才能繼續使用,所以ADD操作在這裡必須等待一次。由於ADD的延遲,導致其後面所有的指令都要慢一步。

我們可以再來看一個稍微更復雜一點的例子:

a = b + c

d = e - f

上述程式碼的執行應該會是這樣的,如下圖所示:

從上圖我們可以看出,由於ADD和SUB操作都需要等待上一條指令的結果,所以插入了不少的停頓,那麼對於這段程式碼,我們是否可以消除這些停頓呢,顯然是可行的。我們只需要將LW Re,e和LW Rf,f的操作移動到前面去執行即可,思路很簡單,就是先載入e和f對程式執行是沒有影響的,因為既然ADD的時候要停頓一下,那麼不如將停頓的時間去用來做點別的操作。

針對上面的指令流程,我們將第5條指令挪到第2條指令的後面執行,將第6條指令挪到上圖的第3條指令後面去執行,於是我們重新畫一下指令重排後的執行流程圖,如下所示:

上面這塊程式碼的運算流程,在指令重排後減少了2次停頓,對於提高CPU處理效能效果明顯,由此可見,指令重排對於提高CPU處理器效能還是十分必要的,雖然確實帶來了亂序的問題,但是這點犧牲完全是值得的。

Happen-Before規則

上面介紹了指令重排,雖然Java虛擬機器和執行系統會對指令進行一定的重排,但是指令重排是有原則的,併發所有的指令都可以隨便更改執行位置,下面羅列了一些基本原則,這些原則是指令重排不可以違背的:

  • 程式順序原則:一個執行緒內保證語義的序列性
  • volatile規則:volatile變數的寫操作,先發生於讀操作,這保證了volatile變數的可見性
  • 鎖規則:解鎖(unlock)必然發生在隨後的加鎖(lock)前
  • 傳遞性:A先於B,B先於C,那麼A必然先於C
  • 執行緒的start()方法先於它的每一個動作
  • 執行緒的所有操作先於執行緒的終結(Thread.join())
  • 執行緒的中斷(interrupt)先於被中斷執行緒的程式碼
  • 物件的建構函式執行、結束先於finalize()方法

以程式順序原則為例,重排後的指令絕對不能改變原有的序列語義,比如:

a = 1

b = a + 1

由於第二條語句依賴第一條語句執行的結果,如果冒然交換兩條程式碼的執行順序,那麼程式的語義就會被修改,因此這種情況是絕對不允許發生的,這也是指令重排必須遵循的第一條基本原則。

此外,鎖規則強調,unlock操作必然發生在後續對同一把鎖的lock之前。也就是說,如果對一個鎖的解鎖後再加鎖,那麼加鎖的執行動作絕對不可能重排到解鎖的動作之前,很顯然如果這麼做,加鎖就沒有意義了。

其他幾條原則也類似,都是為了保證指令重排不會破壞原有的語義結構。

參考資料

1、實戰Java高併發程式設計 / 葛一鳴,郭超編著. —北京:電子工業出版社,2015.11