本文主要是學習Java內存模型的筆記以及加上本身的一些案例分享,若有錯誤之處請指出。html
在併發編程中,須要瞭解並會處理這兩個關鍵問題:java
通訊是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通訊機制有兩種:共享內存和消息傳遞。程序員
a) 在共享內存的併發模型裏,線程之間共享程序的公共狀態,經過寫-讀內存中的公共狀態進行隱式通訊。(重點)編程
b) 在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須經過發送消息來顯式進行通訊。數組
同步是指程序中用於控制不一樣線程間操做發生相對順序的機制。緩存
在共享內存的併發模型裏,同步是顯示進行的。由於程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。多線程
在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。併發
知道並瞭解上面兩個問題後,對java內存模型的瞭解,就打下了基礎。由於Java的併發模型採用的是共享內存模型,java線程之間的通訊老是隱式進行,整個通訊過程對程序員徹底透明。app
在Java中,全部實例域、靜態域和數組元素都存儲在堆內存中, 堆內存在線程之間是共享的(詳細能夠參考JVM運行時數據區域的劃分及其做用)。而虛擬機棧(其中包括局部變量、方法參數定義等..)是線程私有的,不會在線程之間共享,因此它們不會有內存可見性的問題,也不受內存模型的影響。ide
Java線程之間的通訊由Java內存模型(簡稱JMM)控制。JMM決定一個線程對共享變量的寫入什麼時候對另外一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存中,每一個線程都有一個私有的本地內存,本地內存存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的抽象概念,並不真實存在。Java內存模型的抽象示意圖:
從上圖來看,若是線程A和線程B之間要通訊的話,必需要經歷下面兩個步驟:
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2)線程B到主內存中去讀取線程A以前已更新過的共享變量。
舉個例子:線程A與線程B進行通訊,以下圖:
假設初始時,這三個內存中x的值都爲0,線程A在執行時,把更新後的x值臨時放在本地內存。當線程A與線程B須要通訊時,
步驟1:線程A首先會把本身本地內存中修改後的x值刷新到主內存中,此時主內存中的x值變爲了1。
步驟2:線程B到主內存中讀取線程A更新後的X值,此時線程B的本地內存x的值也變爲了1。
從總體(不考慮重排序,按順序執行)來看,這兩個步驟實質上是線程A在向線程B發送消息,並且這個通訊過程必需要通過主內存。JMM經過控制主內存與每一個線程的本地內存之間的交互,提供內存可見性的保證。
在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序。重排序分3種:
1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。(編譯器重排序)
2)指令級並行的重排序。現代處理採用了指令級並行技術來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應及其指令的執行順序。(處理器重排序)
3)內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。(處理器重排序)
這些重排序可能會致使多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(並非全部的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理重排序規則會要求Java編譯器在生成指令序列時,經過內存屏障(後面會解釋)指令來禁止特定類型的處理重排序。
如今的處理器使用寫緩衝區臨時保存向內存寫入的數據。寫緩衝區能夠保證指令流水線持續運行,它能夠避免因爲處理器停頓下來等待向內存寫入數據而產生的延遲。同時,經過以批處理的方式刷新寫緩衝區,以及合併寫緩衝區中對同一內存地址的屢次寫,減小對內存總線的佔用。雖然寫緩衝區有這麼多好處,但每一個處理器的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對內存操做的執行順序產生重要的影響:處理器對內存的讀/寫操做的執行順序,不必定與內存實際發生的讀/寫操做順序一致!下面請看下案例:
class Pointer { int a = 0; int b = 0; int x = 0; int y = 0; public void set1() { a = 1; x = b; } public void set2() { b = 1; y = a; } } /** * 重排序測試 */ public class ReorderTest { public static void main(String[] args) throws InterruptedException { int i = 0; while (true) { final Pointer counter = new Pointer(); Thread t1 = new Thread(new Runnable() { @Override public void run() { counter.set1(); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { counter.set2(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("i="+(++i)+",x=" + counter.x + ", y=" + counter.y); if (counter.x == 0 && counter.y == 0) { break; } } } }
運行結果:
i=1,x=0, y=1
i=2,x=1, y=0 . . . i=5040,x=0, y=0
表格示例圖:
假設處理器A和處理B按程序的順序並行執行內存訪問,最終可能獲得x=y=0的結果。具體緣由以下圖:
解釋下爲何會出現這樣的結果:這裏處理A和處理B能夠同時把共享變量寫入本身的寫緩衝區(A1,B1),而後從內存中讀取另外一個共享變量(B1,B2),最後才把本身寫入緩存區中保存的髒數據刷新到內存中(A3,B3)。當以這種時序執行時,程序就能夠獲得x=y=0的結果。
問題分析:從內存操做實際發生的順序來看,雖然處理A執行內存操做順序爲:A1->A2,但內存操做實際發生的順序確實A2->A1。此時,處理器A的內存操做順序被重排序了(處理器B也是同樣)。因此因爲寫緩衝區僅對本身的處理器可見,它會致使處理器執行內存操做的順序可能會與內存實際的操做執行順序不一致。因爲現代的處理器都會使用寫緩衝區,所以如今的處理器都會容許對寫 - 讀操做進行重排序。重排序的具體內容後續會說明,下圖表是常見處理器容許的重排序狀況(N不容許重排序,Y表示容許重排序):
爲了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分爲4類,以下表
從上表能夠看出StoreLoad Barriers是一個「全能型」的屏障,它同時具備其餘3個屏障的效果。現代的多處理大多支持該屏障(其餘類型屏障不必定支持)。執行該屏障開銷會很昂貴,由於當前處理一般要把寫緩衝區的數據所有刷新到內存中。
後續會詳細介紹,這裏只是提出點,聲明這是JMM中存在的概念。
重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行從新排序的一種手段。並非全部都會進行重排序,除了上面提到Java編譯器會在適當的時候插入內存屏障來禁止重排外,還得遵循如下幾個特性:
前面提到過編譯器和處理器可能會操做作重排序。可是編譯器和處理器在重排序序時,會遵照數據依賴性,不會改變存在數據依賴關係的兩個操做的執行順序。
數據依賴分爲下列三種類型:
這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不編譯器和處理器考慮。
as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。爲了遵照as-if-serial語義,編譯器和處理不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。例如:
double pi = 3.14; // A double r = 1.0; // B double area = pi * r; // C
從代碼中能夠看出, A和C之間存在數據依賴關係,同時B和C之間也存在數據依賴關係。所以在最終執行的指令序列中,C不能被重排序到A和B的前面。可是A和B沒有數據依賴關係,編譯器和處理器能夠重排序A和B之間的執行順序。如下就是程序可能執行的兩種順序。
在單線程程序中,對存在控制依賴的操做重排序,不會改變執行結果。可是在多線程程序中,對存在控制依賴的操做重排序,可能會改變程序執行的結果(上面已說明:不會保證對多線程的數據依賴禁止重排),上面有個例子也提到過,下面再寫個案例加深印象:
package com.yuanfy.gradle.concurrent.volatiles;
/**
* 重排序測試
*/
public class ReorderExample { int sum = 0; int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } public void reader() { if (flag) { // 3 sum = a * a;// 4 } } public static void main(String[] args) throws InterruptedException { int i = 0; while (true) { final ReorderExample example = new ReorderExample(); Thread t1 = new Thread(new Runnable() { @Override public void run() { example.writer(); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { example.reader(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("i="+(++i)+",sum=" + example.sum); if (example.sum == 0) { break; } } } }
簡單描述下上面代碼:flag變量是個標記,用來標誌變量a是否已被寫入。線程1先執行writer()方法,隨後線程2接着執行reader()方法。當線程2在執行操做4時,可否看到線程1在操做1對共享變量a的寫入呢。答案是:不必定。先看下運行結果:
i=1,sum=1
i=2,sum=1 i=3,sum=0
問題分析:經過前面對重排序的瞭解,線程1中一、2步驟沒有數據依賴,那麼編譯器和處理器就有可能將其進行重排序,若是排序結果成下圖,那麼線程2就看不到線程1對共享變量a的操做了。
當程序爲正確同步時,就可能存在數據競爭。Java內存模型規範對數據競爭的定義以下:
在一個線程中寫一個變量,
在另外一個線程讀同一個變量,
並且寫和讀沒有經過同步來排序。
當代碼中包含數據競爭時,程序的執行每每產生違反直覺的結果(譬如重排序案例中的ReorderExample )。若是一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。JMM對正確同步的多線程程序的內存一致性作了以下保證:
若是程序是正確同步的,程序的執行將具備順序一致性-----即程序的執行結果與該程序在順序一致性內存模型中的執行的結果相同。
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它爲程序員提供了極強的內存可見性保證。它有兩大特性:
1)一個線程中的全部操做必須按照程序的順序來執行。
2)(無論程序是否同步)全部線程都只能看到一個單一的操做執行順序。在順序一致性內存模型中,每一個操做都必須原子執行且馬上對全部線程可見。
下面參考下順序一致性內存模型的視圖:
上面也說順序一致性是基於程序是正確同步的,對於未正確同步的多線程程序,JMM不保證未同步程序的執行結果與該程序在順序一致性模型中的執行結果一致。由於若是想要保證執行結果一致,JMM須要禁止大量的處理器和編譯器的優化,這對程序的執行性能會產生很大的影響。
未同步程序在兩個模型中的執行特性差別以下:
1) 順序一致性模型保證單線程內的操做會按照程序的順序執行(特性1),而JMM不保證單線程內的操做會按程序的順序執行(可能會發生重排序)。
2)順序一致性模型保證全部線程只能看到一致的操做執行順序(特性2),而JMM不保證全部線程能看到一致的操做執行順序(一樣是重排序)。
3)JMM不保證對64位的long類型和double類型變量的寫操做具備原子性,而順序一致性模型保證對全部的內存讀/寫操做都具備原子性(特性2)。
主要分析下第三點:
在一些32位的處理器上,若是要求對64位數據的寫操做具備原子性,會有比較大的開銷。爲了照顧這些處理器,Java語言規範鼓勵但不強求JVM對64位的long類型變量和double類型變量的寫操做具備原子性。當JVM在這種處理器上運行時,可能會把一個64位long/double類型變量的寫操做拆分爲兩個32位的寫操做來執行。這兩個32位的寫操做可能會被分配到不一樣的總線事務中執行,此時對這個64位變量的寫操做不具備原子性。
當單個內存操做不具備原子性時,可能會產生意想不到的後果。
如上圖所示,假設處理器A寫一個long型變量,同時初期B要讀取這個long型變量。處理器A中64位的寫操做被拆分兩個32位的寫操做,且這兩個32位的寫操做分配到不一樣的事務中執行。同時處理器B中64位的讀操做被分配到單個的讀事務中執行。當處理器A和B按上圖來執行時,處理器B將看到僅僅被處理器A「寫了一半」的無效值。
注意:在JSR-133規範以前的舊內存模型中,一個64位long/double型變量的讀/寫操做能夠被拆分兩個32位的讀/寫操做來執行。從JSR-133內存模型開始(即從JDK5開始),僅僅只容許一個64位long和double型變量的寫操做拆分爲兩個32位的寫操做來執行,任意的讀操做在JSR-133中都必須具備原子性(即任意讀操做必需要在單個事務中執行)。
happens-before是JMM最核心的概念,因此理解happens-before是理解JMM的關鍵。下面咱們從三方面去理解。
1.1 設計考慮的因素:
a) 須要考慮程序員對內存模型的使用。程序員但願內存模型易於理解、易於編程,但願基於一個強內存模型來編寫代碼。
b)須要考慮編譯器和處理器對內存模型的實現。編譯器和處理器但願內存模型對它們的束縛越少越好,這樣它們就能夠作儘量多的優化來提升性能。編譯器和處理器但願實現一個弱內存模型。
1.2 設計目標:因爲這兩個因素相互矛盾,這兩個點也就成了設計JMM的核心目標:一方面,要爲程序員提供足夠強的內存可見性保證;另外一方面,對編譯器和處理器的限制要儘量地放鬆。
1.3 設計結果
JMM把happens-before要求禁止的重排序分爲下面兩類,並採起了不一樣的策略:
a) 會改變程序執行結果的重排序,對於這種JMM要求編譯器和處理器必須禁止這種重排序(在重排序應該有體現)。
b) 不會改變程序執行結果的重排序,對於這種JMM對編譯器和處理器不做要求(JMM容許這種重排序)。
設計示意圖以下:
從上圖能夠看出兩點:
JSR-133對happens-before關係的定義以下:
1) 若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。
2) 兩個操做之間存在happens-before關係,並不意味者Java平臺的具體實現必需要按照happens-before關係執行的順序來執行。若是重排序以後的執行結果與按happens-before關係來執行的結果一致,那麼這種重排序並不非法,也就是說JMM容許這種重排序。
上面的第一點是JMM對程序員的承諾。從程序員的角度來講,能夠這樣理解happens-before關係:若是A happens-before B, 那麼Java內存模型將向程序員保證--A操做的結果將對B可見,且A的執行順序排在B以前。注意這是Java內存模型作出的保證(若是沒有禁止編譯器和處理器對其重排序且重排序不非法那麼就不必定是這個執行循序)。
上面的第二點是JMM對編譯器和處理器重排序的約束規則。JMM這麼作的緣由是:程序員對於這兩個操做是否真的被重排序並不關心,關心的是程序執行時的語義不能被改變即執行結果不能被改變。所以,happens-before關係本質上和前面說的as-if-serial語義是一回事。
a) as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關係保證正確同步的多線程程序的執行結果不被改變。
b) as-if-serial語義給編寫單線程程序的創造了一個幻境:單線程程序時按程序的順序來執行的。happens-before關係給編寫正確同步的多線程程序創造了一個幻境:正確同步的多線程程序是按happens-before指定的順序來執行的。
這二者都是爲了在不改變執行結果的前提下,儘量地提供程序執行的並行度。
JSR-133定義了以下happens-before規則:
1) 程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。(通俗的說:單線程中前面的動做發生在後面的動做以前)
2) 監視器鎖規則:對於一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。(通俗的說:解鎖操做發生在加鎖操做以後)
3) volatile變量規則:對於一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。(通俗的說:對volatile變量的寫發生在讀以前)
4) 傳遞性規則:若是A happens-before B,且B happens-before C,那麼A happens-before C.
5) start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做返回成功。
6) join()規則:若是線程A執行操做Thread.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join操做成功返回。
下面經過程序流程圖分析下:
上圖說明程序順序規則、volatile變量規則和傳遞性規則,下圖說明start()規則。
下圖說明join規則:
《Java併發編程的藝術》