JAVA內存模型由來
我們知道不同的計算機硬件和操作系統的,所遵循的規范以及計算機內存模型是有區別的,也就意味著我們開發的程序放在某個計算機硬件和操作系統上運行是正常的,而在另一個計算機硬件和操作系統上運行就存在安全問題。
《Java 虛擬機規范》中曾試圖定義一種“Java 內存模型”來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果,但是定義 Java 內存模型并非一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓 Java 的并發內存訪 問操作不會產生歧義;但是也必須定義得足夠寬松,使得虛擬機的實現能有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存和指令集中某些特有的指令)來獲取更好的執行速度。為了獲得更好的執行效能,Java 內存模型并沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器是否要進行調整代碼執行順序這類優化措施。經過長時間的驗證和修補,直至 JDK 5(實現了 JSR-133)發布后,Java 內存模型才終于成熟、完善起來了。
講到這里java內存模型的概念是什么呢?
Java 內存模型(Java Memory Model 簡稱 JMM)是一種抽象的概念,并不真實存在,它主要目的是圍繞原子性、可見性和有序性這幾種并發問題定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。此處的變量與 Java 編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,因為后者是線程私有的,不會被共享,自然就不會存在競爭問題。JVM運行程序的實體是線程,而每個線程創建時JVM都會為其創建一個工作內存(有些地方稱為棧空間),用于存儲線程私有的數據,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝的自己的工作內存空間,然后對變量進行操作,操作完成后再將變量寫回主內存,不能直接操作主內存中的變量,工作內存中存儲著主內存中的變量副本拷貝,前面說過,工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成。
主內存主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類信息、常量、靜態變量。由于是共享數據區域,多條線程對同一個變量進行訪問可能會發生線程安全問題。
工作內存主要存儲當前方法的所有本地變量信息(工作內存中存儲著主內存中的變量副本拷貝),每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作內存中創建屬于當前線程的本地變量,當然也包括了字節碼行號指示器、相關Native方法的信息。注意由于工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,因此存儲在工作內存的數據不存在線程安全問題。
根據JVM虛擬機規范主內存與工作內存的數據存儲類型以及操作方式,對于一個實例對象中的成員方法而言,如果方法中包含本地變量是基本數據類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工作內存的幀棧結構中,但倘若本地變量是引用類型,那么該變量的引用會存儲在功能內存的幀棧中,而對象實例將存儲在主內存(共享數據區域,堆)中。但對于實例對象的成員變量,不管它是基本數據類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區。至于static變量以及類本身相關信息將會存儲在主內存中。需要注意的是,在主內存中的實例對象可以被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那么兩條線程會將要操作的數據拷貝一份到自己的工作內存中,執行完成操作后才刷新到主內存
java內存模型中的問題
我們java開發中三個并發問題原子性問題,可見性問題,指令重排問題
原子性問題
原子性就是指該操作是不可再分的。不論是多核還是單核,具有原子性的量,同一時刻只能有一個線程來對它進行操作。簡而言之,在整個操作過程中不會被線程調度器中斷的操作,都可認為是原子性。
可見性問題
可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。
指令重排問題
指令重排序:java語言規范規定JVM線程內部維持順序化語義。即只要程序的最終結果與它順序化情況的結果相等,那么指令的執行順序可以與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什么:JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能,但是指令重排會遵循as-if-serial語義。as-if-serial語義的意思是:不管怎么重排序,程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義,為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關系,這些操作就可能被編譯器和處理器重排序。何為依賴關系:譬如指令1把地址A中的值加10,指令2 把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排——(A+10)2與A2+10顯然不相等,但指令3可以重排到指令1、2之前或者中間,只要保證 處理器執行后面依賴到A、B值的操作時能獲取正確的A和B值即可。
Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為hAppens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。happens-before原則后面再介紹。
下圖為從源碼到最終執行的指令序列示意圖:
java內存模型如何解決問題
為了解決這種線程安全問題,針對一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java 內存模型定義了以下八種操作來完成。
數據同步八大原子操作
(1)lock(鎖定):作用于主內存的變量,把一個變量標記為一條線程獨占狀態
(2)unlock(解鎖):作用于主內存的變量,把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定
(3)read(讀取):作用于主內存的變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨后的 load 動作使用
(4)load(載入):作用于工作內存的變量,它把 read 操作從主內存中得到的變量值放入工作內存的變量副本中
(5)use(使用):作用于工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎
(6)assign(賦值):作用于工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量
(7)store(存儲):作用于工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨后的 write 的操作
(8)write(寫入):作用于工作內存的變量,它把 store 操作從工作內存中的一個變量的值傳送到主內存的變量中
如果要把一個變量從主內存拷貝到工作內存,那就要按順序執行read和load操作,如果要把變量從 工作內存同步回主內存,就要按順序執行store和write操作。注意,Java內存模型只要求上述兩個操作 必須按順序執行,但不要求是連續執行。也就是說read與load之間、store與write之間是可插入其他指令 的,如對主內存中的變量a、b進行訪問時,一種可能出現的順序是read a、read b、load b、load a。
除此 之外,Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:
1.不允許 read 和 load、store和 write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者工作內存發起回寫了但主內存不接受的情況出現。
2.不允許一個線程丟棄它最近的assign 操作,即變量在工作內存中改變了之后必須把該變化同步回主內存。
3.不允許一個線程無原因地(沒有發生過任何 assign 操作)把數據從線程的工作內存同步回主內存中。
4.一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load 或 assign)的變量,換句話說就是對一個變量實施 use、store 操作之前,必須先執行 assign 和 load 操作。
5.一個變量在同一個時刻只允許一條線程對其進行 lock 操作,但lock操作可以被同一條線程重復執行多次,多次執行 lock 后,只有執行相同次數的 unlock 操作,變量才會被解鎖。
6.如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作以初始化變量的值。
7.如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。
8.對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。
8大原子操作long和double類型
Java內存模型要求lock、unlock、read、load、assign、use、store、write這八種操作都具有原子性, 但是對于64位的數據類型(long和double),在模型中特別定義了一條寬松的規定:允許虛擬機將沒有 被volatile修飾的64位數據的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機實現自行選擇是否 要保證64位數據類型的load、store、read和write這四個操作的原子性,這就是所謂的“long和double的非原子性協定”。
如果有多個線程共享一個并未聲明為volatile的long或double類型的變量,并且同時對它們進行讀取和修改操作,那么某些線程可能會讀取到一個既不是原值,也不是其他線程修改值的代表了“半個變量”的數值。不過這種讀取到“半個變量”的情況是非常罕見的,經過實際測試,在目前主流平臺下商 用的64位Java虛擬機中并不會出現非原子性訪問行為,但是對于32位的Java虛擬機,譬如比較常用的32位x86平臺下的HotSpot虛擬機,對long類型的數據確實存在非原子性訪問的風險。
從JDK9起,HotSpot增加了一個實驗性的參數-XX:+AlwaysAtomicAccesses來約束虛擬機對所有數據類型進行原子性的訪問。而針對double類型,由于現代中央處理器中一般都包含專門用于處理浮點數據的浮點運算器,用來專門處理單、雙精度的浮點數據,所以哪怕是32位虛擬機中通常也不會出現非原子性訪問的問題,實際測試也證實了這一點。筆者的看法是,在實際開發中,除非該數據有明確可知的線程競爭,否則我們在編寫代碼時一般不需要因為這個原因刻意把用到的long和double變量專門聲明為volatile。
volatile關鍵字
除了8大原子操作Java內存模型還對volatile關鍵字定義了特殊規則:
假定 T 表示 一個線程,V 和 W 分別表示兩個 volatile 型變量,那么在進行 read、load、use、assign、store 和 write 操作 時需要滿足如下規則:
1.只有當線程 T 對變量 V 執行的前一個動作是 load 的時候,線程 T 才能對變量 V 執行 use 動作;并且, 只有當線程 T 對變量 V 執行的后一個動作是 use 的時候,線程 T 才能對變量 V 執行 load 動作。線程 T 對變量 V 的 use 動作可以認為是和線程 T 對變量 V 的 load、read 動作相關聯的,必須連續且一起出現。這條規則要求在工作內存中,每次使用 V 前都必須先從主內存刷新最新的值,用于保證能看見其 他線程對變量 V 所做的修改。
2.只有當線程 T 對變量 V 執行的前一個動作是 assign 的時候,線程 T 才能對變量 V 執行 store 動作;并 且,只有當線程 T 對變量 V 執行的后一個動作是 store 的時候,線程 T 才能對變量 V 執行 assign 動作。線程 T 對變量 V 的 assign 動作可以認為是和線程 T 對變量 V 的 store、write 動作相關聯的,必須連續且一起出現。這條規則要求在工作內存中,每次修改V后都必須立刻同步回主內存中,用于保證其他線程可以 看到自己對變量V所做的修改。
3.假定動作A是線程T對變量V實施的use或assign 動作,假定動作F 是和動作A相關聯的load或store動作,假定動作P是和動作F相應的對變量V的read或write動作;與此類似,假定動作B是線程T對變量W實施的use或assign動作,假定動作G是和動作B相關聯的load或store 動作,假定動作Q是和動作G相應的對變量W的read或 write動作。如果A先于B,那么P先于Q。這條規則要求volatile修飾的變量不會被指令重排序優化,從而保證代碼的執行順序與程序的順序 相同。
volatile關鍵字可以解決指令重排和可見性問題但是解決不了原字性問題,synchronized可以解決原子性問題,可見性問題,指令重排問題,后面講會詳細說一下volatile關鍵字和synchronized。
這8種內存訪問操作以及上述規則的限定以及jvm內存模型對volatile 的一些特殊規定,就已經能準確地描述出Java程序中哪些內存訪問操作在并發下才是安全的。基于理解難度和嚴謹性考慮,最新的JSR-133文檔中,已經放棄了采用這8種操作去定義Java內存模型的訪問協議,縮減為4種(僅是描述方式改變了,Java 內存模型并沒有改變)。
先行發生原則
這8種內存訪問操作以及上述規則限定,再加上稍后會介紹的專門針對volatile的一些特殊規定,就已經能準確地描述出Java程序中哪些內存訪問操作在并發下才是安全的。這種定義相當嚴謹,但也是極為煩瑣,實踐起來更是無比麻煩。如果在代碼中必須考慮以上定義才能確定并發訪問下是否安全,那就太麻煩了,好在后來這種定義的一個等效判斷原則——先行發生原則的提出解決這中困惑, 如果Java內存模型中所有的有序性都僅靠volatile和synchronized來完成,那么有很多操作都將會變 得非常啰嗦,但是我們在編寫Java并發代碼的時候并沒有察覺到這一點,這是因為Java語言中有一 個“先行發生”(Happens-Before)的原則。這個原則非常重要,它是判斷數據是否存在競爭,線程是否安全的非常有用的手段。依賴這個原則,我們可以通過幾條簡單規則一攬子解決并發環境下兩個操 作之間是否可能存在沖突的所有問題,而不需要陷入Java內存模型苦澀難懂的定義之中。現在就來看看“先行發生”原則指的是什么。先行發生是Java內存模型中定義的兩項操作之間的偏序關系,比如說操作A先行發生于操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B 觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。這句話不難理解,但它意味著什么呢?我們可以舉個例子來說明一下。如代碼清單12-8所示的這三條偽代碼。
// 以下操作在線程A中執行
i = 1;
// 以下操作在線程B中執行
j = i;
// 以下操作在線程C中執行
i = 2;
假設線程A中的操作“i=1”先行發生于線程B的操作“j=i”,那我們就可以確定在線程B的操作執行后,變量j的值一定是等于1,得出這個結論的依據有兩個:一是根據先行發生原則,“i=1”的結果可以被觀察到;二是線程C還沒登場,線程A操作結束之后沒有其他線程會修改變量i的值。現在再來考慮 線程C,我們依然保持線程A和B之間的先行發生關系,而C出現在線程A和B的操作之間,但是C與B沒 有先行發生關系,那j的值會是多少呢?答案是不確定!1和2都有可能,因為線程C對變量i的影響可能 會被線程B觀察到,也可能不會,這時候線程B就存在讀取到過期數據的風險,不具備多線程安全性。下面是Java內存模型下一些“天然的”先行發生關系,這些先行發生關系無須任何同步器協助就已 經存在,可以在編碼中直接使用。如果兩個操作之間的關系不在此列,并且無法從下列規則推導出來,則它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。
·程序次序規則(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()方法檢測到是否有中斷發生。
·對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生于它的 finalize()方法的開始。
·傳遞性(Transitivity):如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出 操作A先行發生于操作C的結論。
Java語言無須任何同步手段保障就能成立的先行發生規則有且只有上面這些,下面演示一下如何使用這些規則去判定操作間是否具備順序性,對于讀寫共享變量的操作來說,就是線程是否安全。讀者還可以從下面這個例子中感受一下“時間上的先后順序”與“先行發生”之間有什么不同。演示例子如以下代碼所示。
private int value = 0;
pubilc void setValue(int value){
this.value = value;
}
public int getValue(){
return value;
}
代碼中顯示的是一組再普通不過的getter/setter方法,假設存在線程A和B,線程A先(時 間上的先后)調用了setValue(1),然后線程B調用了同一個對象的getValue(),那么線程B收到的返回值是什么?
我們依次分析一下先行發生原則中的各項規則:
1.由于兩個方法分別由線程A和B調用,不在一個線程中,所以程序次序規則在這里不適用;
2.由于沒有同步塊,自然就不會發生lock和unlock操作,所以管程鎖定規則不適用;
3.由于value變量沒有被volatile關鍵字修飾,所以volatile變量規則不適用;
4.后面的線程啟動、終止、中斷規則和對象終結規則也和這里完全沒有關系。
5.因為沒有一個適用的先行發生規則,所以最后一條傳遞性也無從談起,因此我們可以判定,盡管線程A在操作時間上先于線程B,但是無法確定線程B中getValue()方法的返回結果,換句話說,這里面的操作不是線程安全的。
那怎么修復這個問題呢?我們至少有兩種比較簡單的方案可以選擇:
1.要么把getter/setter方法都定義為synchronized方法,這樣就可以套用管程鎖定規則;
2.要么把value定義為volatile變量,由于setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變量規則來 實現先行發生關系。
通過上面的例子,我們可以得出結論:一個操作“時間上的先發生”不代表這個操作會是“先行發生”。那如果一個操作“先行發生”,是否就能推導出這個操作必定是“時間上的先發生”呢?很遺憾,這個推論也是不成立的。
一個典型的例子就是多次提到的“指令重排序”,如下代碼所示:
int i = 1;
int j = 2;
代碼所示的兩條賦值語句在同一個線程之中,根據程序次序規則,“int i=1”的操作先行發生于“int j=2”,但是“int j=2”的代碼完全可能先被處理器執行,這并不影響先行發生原則的正確性,因為我們在這條線程之中沒有辦法感知到這一點。
上面兩個例子綜合起來證明了一個結論:時間先后順序與先行發生原則之間基本沒有因果關系,所以我們衡量并發安全問題的時候不要受時間順序的干擾,一切必須以先行發生原則為準。