概述
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一樣的執行順序。
不過這里我們還需要強調一點,對於一個線程來說,它看到的指令執行順序一定是一致的,也就是說指令重排是有個一基本前提的,就是必須保證串行語義的一致性,不管指令怎么重排序都不會使串行的語義邏輯發生問題。
注意:指令重排可以保證串行語義一致,但是沒有義務保證多線程間的語義也一致。
那么為什么要指令重排呢?
之所以這么做,完全是基於代碼執行的性能考慮的。我們知道,一條指令的執行是分多個步驟的,簡單的說,可以分為以下幾步:
- 取指 IF
- 譯碼和取寄存器操作數 ID
- 執行或者有效地址計算 EX
- 存儲器訪問 MEM
- 寫回 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