淺談JMM


概述

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM