多任務和高並發的內存交互
多任務和高並發是衡量一台計算機處理器的能力重要指標之一。一般衡量一個服務器性能的高低好壞,使用每秒事務處理數(Transactions Per Second,TPS)這個指標比較能說明問題,它代表着一秒內服務器平均能響應的請求數,而TPS值與程序的並發能力有着非常密切的關系。物理機的並發問題與虛擬機中的情況有很多相似之處,物理機對並發的處理方案對於虛擬機的實現也有相當大的參考意義。
由於計算機的存儲設備與處理器的運算能力之間有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中,這樣處理器就無需等待緩慢的內存讀寫了。
基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而他們又共享同一主存,如下圖所示:多個處理器運算任務都涉及同一塊主存,需要一種協議可以保障數據的一致性,這類協議有MSI、MESI、MOSI及Dragon Protocol等。
除此之外,為了使得處理器內部的運算單元能盡可能被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之后將對亂序執行的代碼進行結果重組,保證結果准確性。與處理器的亂序執行優化類似,Java虛擬機的即時編譯器中也有類似的指令重排序(Instruction Recorder)優化。
Java內存模型
內存模型可以理解為在特定的操作協議下,對特定的內存或者高速緩存進行讀寫訪問的過程抽象,不同架構下的物理機擁有不一樣的內存模型,Java虛擬機也有自己的內存模型,即Java內存模型(Java Memory Model, JMM)。在C/C++語言中直接使用物理硬件和操作系統內存模型,導致不同平台下並發訪問出錯。而JMM的出現,能夠屏蔽掉各種硬件和操作系統的內存訪問差異,實現平台一致性,是的Java程序能夠“一次編寫,到處運行”。
主內存和工作內存
Java內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣底層細節。此處的變量與Java編程時所說的變量不一樣,指包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,后者是線程私有的,不會被共享。
Java內存模型中規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存(可以與前面講的處理器的高速緩存類比),線程的工作內存中保存了該線程使用到的變量到主內存副本拷貝,線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要在主內存來完成,線程、主內存和工作內存的交互關系如下圖所示,和上圖很類似。
注意:這里的主內存、工作內存與Java內存區域的Java堆、棧、方法區不是同一層次內存划分,這兩者基本上沒有關系。
內存交互操作
由上面的交互關系可知,關於主內存與工作內存之間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作來完成:
- lock(鎖定):作用於主內存的變量,把一個變量標識為一條線程獨占狀態。
- unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
- read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用
- load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
- use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
- assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
- store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨后的write的操作。
- write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。
如果要把一個變量從主內存中復制到工作內存,就需要按順尋地執行read和load操作,如果把變量從工作內存中同步回主內存中,就要按順序地執行store和write操作。Java內存模型只要求上述兩個操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load之間,store和write之間是可以插入其他指令的,如對主內存中的變量a、b進行訪問時,可能的順序是read a,read b,load b, load a。Java內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:
- 不允許read和load、store和write操作之一單獨出現
- 不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之后必須同步到主內存中。
- 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中。
- 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。
- 一個變量在同一時刻只允許一條線程對其進行lock操作,lock和unlock必須成對出現
- 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值
- 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。
- 對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。
這8種內存訪問操作很繁瑣,后文會使用一個等效判斷原則,即先行發生(happens-before)原則來確定一個內存訪問在並發環境下是否安全。
volatile變量規則
關鍵字volatile是JVM中最輕量的同步機制。volatile變量具有2種特性:
- 保證變量的可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入,這個新值對於其他線程來說是立即可見的。
- 屏蔽指令重排序:指令重排序是編譯器和處理器為了高效對程序進行優化的手段,下文有詳細的分析。
volatile語義並不能保證變量的原子性。對任意單個volatile變量的讀/寫具有原子性,但類似於i++、i–這種復合操作不具有原子性,因為自增運算包括讀取i的值、i值增加1、重新賦值3步操作,並不具備原子性。
由於volatile只能保證變量的可見性和屏蔽指令重排序,只有滿足下面2條規則時,才能使用volatile來保證並發安全,否則就需要加鎖(使用synchronized、lock或者java.util.concurrent中的Atomic原子類)來保證並發中的原子性。
- 運算結果不存在數據依賴(重排序的數據依賴性),或者只有單一的線程修改變量的值(重排序的as-if-serial語義)
- 變量不需要與其他的狀態變量共同參與不變約束
因為需要在本地代碼中插入許多內存屏蔽指令在屏蔽特定條件下的重排序,volatile變量的寫操作與讀操作相比慢一些,但是其性能開銷比鎖低很多。
long/double非原子協定
JMM要求lock、unlock、read、load、assign、use、store、write這8個操作都必須具有原子性,但對於64為的數據類型(long和double,具有非原子協定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作划分為2次32位操作進行。(與此類似的是,在棧幀結構的局部變量表中,long和double類型的局部變量可以使用2個能存儲32位變量的變量槽(Variable Slot)來存儲的,關於這一部分的詳細分析,詳見詳見周志明著《深入理解Java虛擬機》8.2.1節)
如果多個線程共享一個沒有聲明為volatile的long或double變量,並且同時讀取和修改,某些線程可能會讀取到一個既非原值,也不是其他線程修改值的代表了“半個變量”的數值。不過這種情況十分罕見。因為非原子協議換句話說,同樣允許long和double的讀寫操作實現為原子操作,並且目前絕大多數的虛擬機都是這樣做的。
原子性、可見性、有序性
原子性
JMM保證的原子性變量操作包括read、load、assign、use、store、write,而long、double非原子協定導致的非原子性操作基本可以忽略。如果需要對更大范圍的代碼實行原子性操作,則需要JMM提供的lock、unlock、synchronized等來保證。
可見性
前面分析volatile語義時已經提到,可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。JMM在變量修改后將新值同步回主內存,依賴主內存作為媒介,在變量被線程讀取前從內存刷新變量新值,保證變量的可見性。普通變量和volatile變量都是如此,只不過volatile的特殊規則保證了這種可見性是立即得知的,而普通變量並不具備這種嚴格的可見性。除了volatile外,synchronized和final也能保證可見性。
有序性
JMM的有序性表現為:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句指“線程內表現為串行的語義”(as-if-serial),后半句值“指令重排序”和普通變量的”工作內存與主內存同步延遲“的現象。
重排序
在執行程序時為了提高性能,編譯器和處理器經常會對指令進行重排序。從硬件架構上來說,指令重排序是指CPU采用了允許將多條指令不按照程序規定的順序,分開發送給各個相應電路單元處理,而不是指令任意重排。重排序分成三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義放入前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現代處理器采用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 內存系統的重排序。由於處理器使用緩存和讀寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
JMM的重排序屏障
從Java源代碼到最終實際執行的指令序列,會經過三種重排序。但是,為了保證內存的可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。對於編譯器的重排序,JMM會根據重排序規則禁止特定類型的編譯器重排序;對於處理器重排序,JMM會插入特定類型的內存屏障,通過內存的屏障指令禁止特定類型的處理器重排序。這里討論JMM對處理器的重排序,為了更深理解JMM對處理器重排序的處理,先來認識一下常見處理器的重排序規則:
其中的N標識處理器不允許兩個操作進行重排序,Y表示允許。其中Load-Load表示讀-讀操作、Load-Store表示讀-寫操作、Store-Store表示寫-寫操作、Store-Load表示寫-讀操作。可以看出:常見處理器對寫-讀操作都是允許重排序的,並且常見的處理器都不允許對存在數據依賴的操作進行重排序(對應上面數據轉換那一列,都是N,所以處理器不允許這種重排序)。
那么這個結論對我們有什么作用呢?比如第一點:處理器允許寫-讀操作兩者之間的重排序,那么在並發編程中讀線程讀到可能是一個未被初始化或者是一個NULL等,出現不可預知的錯誤,基於這點,JMM會在適當的位置插入內存屏障指令來禁止特定類型的處理器的重排序。內存屏障指令一共有4類:
- LoadLoad Barriers:確保Load1數據的裝載先於Load2以及所有后續裝載指令
- StoreStore Barriers:確保Store1的數據對其他處理器可見(會使緩存行無效,並刷新到內存中)先於Store2及所有后續存儲指令的裝載
- LoadStore Barriers:確保Load1數據裝載先於Store2及所有后續存儲指令刷新到內存
- StoreLoad Barriers:確保Store1數據對其他處理器可見(刷新到內存,並且其他處理器的緩存行無效)先於Load2及所有后續裝載指令的裝載。該指令會使得該屏障之前的所有內存訪問指令完成之后,才能執行該屏障之后的內存訪問指令。
數據依賴性
根據上面的表格,處理器不會對存在數據依賴的操作進行重排序。這里數據依賴的准確定義是:如果兩個操作同時訪問一個變量,其中一個操作是寫操作,此時這兩個操作就構成了數據依賴。常見的具有這個特性的如i++、i—。如果改變了具有數據依賴的兩個操作的執行順序,那么最后的執行結果就會被改變。這也是不能進行重排序的原因。例如:
- 寫后讀:
a = 1; b = a;
- 寫后寫:
a = 1; a = 2;
- 讀后寫:
a = b; b = 1;
重排序遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。但是這里所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。
as-if-serial語義
as-if-serial語義的意思指:管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。
as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單線程程序的程序員創建了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。
重排序對多線程的影響
如果代碼中存在控制依賴的時候,會影響指令序列執行的並行度(因為高效)。也是為此,編譯器和處理器會采用猜測(Speculation)執行來克服控制的相關性。所以重排序破壞了程序順序規則(該規則是說指令執行順序與實際代碼的執行順序是一致的,但是處理器和編譯器會進行重排序,只要最后的結果不會改變,該重排序就是合理的)。
在單線程程序中,由於as-ifserial語義的存在,對存在控制依賴的操作重排序,不會改變執行結果;但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。
先行發生原則(happens-before)
前面所述的內存交互操作必須要滿足一定的規則,而happens-before就是定義這些規則的一個等效判斷原則。happens-before是JMM定義的2個操作之間的偏序關系:如果操作A線性發生於操作B,則A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。如果兩個操作滿足happens-before原則,那么不需要進行同步操作,JVM能夠保證操作具有順序性,此時不能夠隨意的重排序。否則,無法保證順序性,就能進行指令的重排序。
happens-before原則主要包括:
- 程序次序規則(Program Order Rule):在同一個線程中,按照程序代碼順序,書寫在前面的操作先行發生於書寫在后面的操縱。准確的說是程序的控制流順序,考慮分支和循環等。
- 管理鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於后面(時間上的順序)對同一個鎖的lock操作。
- volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於后面(時間上的順序)對該變量的讀操作。
- 線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。
- 線程終止規則(Thread Termination Rule):線程的所有操作都先行發生於對此線程的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
- 線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷時事件的發生。Thread.interrupted()可以檢測是否有中斷發生。
- 對象終結規則(Finilizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()的開始。
- 傳遞性(Transitivity):如果操作A 先行發生於操作B,操作B 先行發生於操作C,那么可以得出A 先行發生於操作C。
注意:不同操作時間先后順序與先行發生原則之間沒有關系,二者不能相互推斷,衡量並發安全問題不能受到時間順序的干擾,一切都要以happens-before原則為准
示例代碼1:
private int value = 0; public void setValue(int value) { this.value = value; } public int getValue() { return this.value; }
對於上面的代碼,假設線程A在時間上先調用setValue(1),然后線程B調用getValue()方法,那么線程B收到的返回值一定是1嗎?
按照happens-before原則,兩個操作不在同一個線程、沒有通道鎖同步、線程的相關啟動、終止和中斷以及對象終結和傳遞性等規則都與此處沒有關系,因此這兩個操作是不符合happens-before原則的,這里的並發操作是不安全的,返回值並不一定是1。
對於該問題的修復,可以使用lock或者synchronized套用“管程鎖定規則”實現先行發生關系;或者將value定義為volatile變量(兩個方法的調用都不存在數據依賴性),套用“volatile變量規則”實現先行發生關系。如此一來,就能保證並發安全性。
示例代碼2
// 以下操作在同一個線程中 int i = 1; int j = 2;
上面的代碼符合“程序次序規則”,滿足先行發生關系,但是第2條語句完全可能由於重排序而被處理器先執行,時間上先於第1條語句。
1、轉自:http://blog.csdn.net/u011080472/article/details/51337422