Java並發編程(1)-Java內存模型
本文主要是學習Java內存模型的筆記以及加上自己的一些案例分享,如有錯誤之處請指出。
一 Java內存模型的基礎
1、並發編程模型的兩個問題
在並發編程中,需要了解並會處理這兩個關鍵問題:
1.1、線程之間如何通信?
通信是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。
a) 在共享內存的並發模型裏,線程之間共享程序的公共狀態,通過寫-讀內存中的公共狀態進行隱式通信。(重點)
b) 在消息傳遞的並發模型裏,線程之間沒有公共狀態,線程之間必須通過發送消息來顯式進行通信。
1.2、線程之間如何同步?
同步是指程序中用於控制不同線程間操作發生相對順序的機制。
在共享內存的並發模型裏,同步是顯示進行的。因為程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。
在消息傳遞的並發模型裏,由於消息的發送必須在消息的接收之前,因此同步是隱式進行的。
知道並了解上面兩個問題後,對java內存模型的了解,就打下了基礎。因為Java的並發模型采用的是共享內存模型,java線程之間的通信總是隱式進行,整個通信過程對程序員完全透明。
2、Java內存模型的抽象結構
在Java中,所有實例域、靜態域和數組元素都存儲在堆內存中, 堆內存在線程之間是共享的(詳細可以參考JVM運行時數據區域的劃分及其作用)。而虛擬機棧(其中包括局部變量、方法參數定義等..)是線程私有的,不會在線程之間共享,所以它們不會有內存可見性的問題,也不受內存模型的影響。
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、從源代碼到指令序列的重排序
在執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分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表示允許重排序):
4、內存屏障
為了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分為4類,如下表
從上表可以看出StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理大多支持該屏障(其他類型屏障不一定支持)。執行該屏障開銷會很昂貴,因為當前處理通常要把寫緩沖區的數據全部刷新到內存中。
5、happens-before簡介
後續會詳細介紹,這裏只是提出點,聲明這是JMM中存在的概念。
二 重排序
重排序是指編譯器和處理器為了優化程序性能而對指令序列進行重新排序的一種手段。並不是所有都會進行重排序,除了上面提到Java編譯器會在適當的時候插入內存屏障來禁止重排外,還得遵循以下幾個特性:
1、存在數據依賴性禁止重排(單線程)。
前面提到過編譯器和處理器可能會操作做重排序。但是編譯器和處理器在重排序序時,會遵守數據依賴性,不會改變存在數據依賴關系的兩個操作的執行順序。
數據依賴分為下列三種類型:
這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不編譯器和處理器考慮。
2、遵循as-if-serial語義
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中1、2步驟沒有數據依賴,那麽編譯器和處理器就有可能將其進行重排序,如果排序結果成下圖,那麽線程2就看不到線程1對共享變量a的操作了。
三 順序一致性
1、數據競爭與順序一致性
當程序為正確同步時,就可能存在數據競爭。Java內存模型規範對數據競爭的定義如下:
在一個線程中寫一個變量,
在另一個線程讀同一個變量,
而且寫和讀沒有通過同步來排序。
當代碼中包含數據競爭時,程序的執行往往產生違反直覺的結果(譬如重排序案例中的ReorderExample )。如果一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。JMM對正確同步的多線程程序的內存一致性做了如下保證:
如果程序是正確同步的,程序的執行將具有順序一致性-----即程序的執行結果與該程序在順序一致性內存模型中的執行的結果相同。
2、順序一致性模型
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它為程序員提供了極強的內存可見性保證。它有兩大特性:
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
happens-before是JMM最核心的概念,所以理解happens-before是理解JMM的關鍵。下面我們從三方面去理解。
1、JMM的設計
1.1 設計考慮的因素:
a) 需要考慮程序員對內存模型的使用。程序員希望內存模型易於理解、易於編程,希望基於一個強內存模型來編寫代碼。
b)需要考慮編譯器和處理器對內存模型的實現。編譯器和處理器希望內存模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化來提高性能。編譯器和處理器希望實現一個弱內存模型。
1.2 設計目標:由於這兩個因素相互矛盾,這兩個點也就成了設計JMM的核心目標:一方面,要為程序員提供足夠強的內存可見性保證;另一方面,對編譯器和處理器的限制要盡可能地放松。
1.3 設計結果
JMM把happens-before要求禁止的重排序分為下面兩類,並采取了不同的策略:
a) 會改變程序執行結果的重排序,對於這種JMM要求編譯器和處理器必須禁止這種重排序(在重排序應該有體現)。
b) 不會改變程序執行結果的重排序,對於這種JMM對編譯器和處理器不作要求(JMM允許這種重排序)。
設計示意圖如下:
從上圖可以看出兩點:
- JMM提供的happens-before規則能滿足程序員的要求:它不僅簡單易懂,而且提供了足夠強的內存可見性保證。
- JMM對編譯器和處理器的束縛已經盡可能少。從上圖來看,JMM其實是在遵循一個基本原則:只要不改變程序的執行結果(指單線程或正確同步的多線程),編譯器和處理器怎麽優化都行。例如:如果編譯器經過細致的分析後,認定一個鎖只會被單線程訪問,那麽這個鎖可以被消除。
2、happens-before的定義
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指定的順序來執行的。
這兩者都是為了在不改變執行結果的前提下,盡可能地提供程序執行的並行度。
3 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規則:
五 volatile內存語義
Java內存模型-volatile的內存語義
六 鎖的內存語義
Java內存模型-鎖的內存語義
七 final域的內存語義
Java內存模型-final域的內存語義
八 參考文獻
《Java並發編程的藝術》
Java並發編程(1)-Java內存模型