一、JVM運作時資料區

二、Java記憶體模型
- JCP定義了一種Java記憶體模型,以前是在JVM規範中,後來獨立出來成為JSR-133(Java記憶體模型和線程規範修訂)。
- 記憶體模型:在特定的操作協定下,對特定的記憶體或高速緩存進行讀寫通路的過程抽象。
- Java記憶體模型主要關注JVM中把變量值存儲到記憶體和從記憶體中取出變量值這樣的底層細節。
- Java記憶體模型是共享記憶體的并發模型,線程之間主要通過讀-寫共享變量(堆記憶體中的執行個體域,靜态域和數組元素)來完成隐式通信。
- Java 記憶體模型(JMM)控制 Java 線程之間的通信,決定一個線程對共享變量的寫入何時對另一個線程可見。
計算機高速緩存和緩存一緻性
計算機在高速的 CPU 和相對低速的儲存設備之間使用高速緩存,作為記憶體和處理器之間的緩沖。将運算需要使用到的資料複制到緩存中,讓運算能快速運作,當運算結束後再從緩存同步回記憶體之中。
在多處理器的系統中(或者單處理器多核的系統),每個處理器核心都有自己的高速緩存,它們有共享同一主記憶體(Main Memory)。
當多個處理器的運算任務都涉及同一塊主記憶體區域時,将可能導緻各自的緩存資料不一緻。
是以,需要各個處理器通路緩存時都遵循一些協定,在讀寫時要根據協定進行操作,來維護緩存的一緻性。
JVM主記憶體與工作記憶體
- Java 記憶體模型的主要目标是定義程式中各個變量的通路規則,即在虛拟機中将變量(線程共享的變量)存儲到記憶體和從記憶體中取出變量這樣底層細節。
- Java記憶體模型中規定了所有的變量(共享的)都存儲在主記憶體中,每條線程還有自己的工作記憶體,工作記憶體裡面儲存該線程使用到的變量的主記憶體副本拷貝。
JMM的兩條規定
- 1、線程對共享變量的所有操作都必須在自己的工作記憶體中進行,不能直接從主記憶體中讀寫。
- 2、不同的線程之間無法直接通路其他線程工作記憶體中的變量,線程變量值的傳遞需要通過主記憶體來完成。
記憶體互動操作
由上面的互動關系可知,關于主記憶體與工作記憶體之間的具體互動協定,即一個變量如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實作細節,虛拟機實作必須保證每一個操作都是原子的,不可在分的(對于double和long類型的變量來說,load、store、read和write操作在某些平台上允許例外),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。
JMM對這八種指令的使用,制定了如下規則:
- 不允許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)原則來确定一個記憶體通路在并發環境下是否安全。
三、并發程式設計問題
多線程并發程式設計會涉及到以下的問題:
- 1、原子性:指在一個操作中就是cpu不可以在中途暫停然後再排程,既不被中斷操作,要不執行完成,要不就不執行。
- 2、可見性:指當多個線程通路同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
- 3、有序性:程式執行的順序按照代碼的先後順序執行,多線程中為了提高性能,編譯器和處理器的常常會對指令做重排(編譯器優化重排、指令并行重排、記憶體系統重排)。
四、解決并發程式設計
- 1、原子性:Java提供了兩個進階位元組碼指令monitorenter和monitorexit,對應的是關鍵字synchronized,使用該關鍵字保證方法和代碼塊内的操作的原子性。
- 2、可見性:Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變量在被修改後可以立即同步到主記憶體,被其修飾的變量在每次是用之前都從主記憶體重新整理。是以,可以使用volatile來保證多線程操作時變量的可見性。
除了volatile,Java中的synchronized和final兩個關鍵字也可以實作可見性,隻不過實作方式不同。
- 3、有序性:用volatile關鍵字禁止指令重排,用synchronized關鍵字加鎖。
- 4、互斥同步:synchronized、java.util.concurrent.ReentrantLock。目前兩個方法性能已經差不多了,建議優先選擇synchronized。
ReentrantLock增加了如下特性:
- 等待可中斷:當持有鎖的線程長時間不釋放鎖,正在等待的線程可以選擇放棄等待。
- 公平鎖:多個線程等待同一個鎖時,必須嚴格按照申請鎖的時間順序來獲得鎖。
- 鎖綁定多個條件:一個ReentrantLock對象可以綁定多個Condition對象,而synchronized是針對一個條件的,如果要多個,就得有多個鎖。
- 5、非阻塞同步:是一種基于沖突檢查的樂觀鎖定政策,通常是先操作,如果沒有沖突,操作就成功了,有沖突在采取其他方式進行補償操作。
- 6、無同步方案:其實就是在多線程中,方法并不涉及共享資料,自然也就無需同步了。
五、volatile
關鍵字volatile是JVM中最輕量的同步機制。volatile變量具有2種特性:
- 1、保證變量的可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入,這個新值對于其他線程來說是立即可見的。
- 2、屏蔽指令重排序:指令重排序是編譯器和處理器為了高效對程式進行優化的手段。
volatile語義并不能保證變量的原子性。對任意單個volatile變量的讀/寫具有原子性,但類似于i++、i–這種複合操作不具有原子性,因為自增運算包括讀取i的值、i值增加1、重新指派3步操作,并不具備原子性。
由于volatile隻能保證變量的可見性和屏蔽指令重排序,隻有滿足下面2條規則時,才能使用volatile來保證并發安全,否則就需要加鎖(使用synchronized、lock或者java.util.concurrent中的Atomic原子類)來保證并發中的原子性。
- 1、運算結果不存在資料依賴(重排序的資料依賴性),或者隻有單一的線程修改變量的值(重排序的as-if-serial語義)。
- 2、變量不需要與其他的狀态變量共同參與不變限制。
因為需要在本地代碼中插入許多記憶體屏蔽指令在屏蔽特定條件下的重排序,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的讀寫操作實作為原子操作,并且目前絕大多數的虛拟機都是這樣做的。
指令重排
在執行程式時為了提高性能,編譯器和處理器經常會對指令進行重排序。從硬體架構上來說,指令重排序是指CPU采用了允許将多條指令不按照程式規定的順序,分開發送給各個相應電路單元處理,而不是指令任意重排。重排序分成三種類型:
- 1、編譯器優化的重排序。編譯器在不改變單線程程式語義放入前提下,可以重新安排語句的執行順序。
- 2、指令級并行的重排序。現代處理器采用了指令級并行技術來将多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
- 3、記憶體系統的重排序。由于處理器使用緩存和讀寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
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()可以檢測是否有中斷發生。