1. 併發編程的3個概念 併發編程時,要想併發程式正確地執行,必須要保證原子性、可見性和有序性。只要有一個沒有被保證,就有可能會導致程式運行不正確。 1.1. 原子性 原子性:即一個或多個操作要麼全部執行並且執行過程中不會被打斷,要麼都不執行。 一個經典的例子就是銀行轉賬:從賬戶A向賬戶B轉賬100 ...
1. 併發編程的3個概念
併發編程時,要想併發程式正確地執行,必須要保證原子性、可見性和有序性。只要有一個沒有被保證,就有可能會導致程式運行不正確。
1.1. 原子性
原子性:即一個或多個操作要麼全部執行並且執行過程中不會被打斷,要麼都不執行。
一個經典的例子就是銀行轉賬:從賬戶A向賬戶B轉賬1000元,此時包含兩個操作:賬戶A減去1000元,賬戶B加上1000元。這兩個操作必須具備原子性才能保證轉賬安全。假如賬戶A減去1000元之後,操作被打斷了,賬戶B卻沒有收到轉過來的1000元,此時就出問題了。
1.2. 可見性
可見性:即多個線程訪問同一個變數時,一個線程修改了這個變數的值,其他線程能夠立即看得到修改的最新值。
例如下段代碼,線程1修改i的值,線程2卻沒有立即看到線程1修改的i的最新值:
//線程1執行的代碼 int i = 0; i = 10; //線程2執行的代碼 j = i;
假如執行線程1的是CPU1,執行線程2的是CPU2。當線程1執行 i=10
時,會將CPU1的高速緩存中i的值賦值為10,卻沒有立即寫入主記憶體中。此時線程2執行 j=i
,會先從主記憶體中讀取i的值並載入到CPU2的高速緩存中,此時主記憶體中的i=0,那麼就會使得j最終賦值為0,而不是10。
1.3. 有序性
有序性:即程式執行的順序按代碼的先後順序執行。
例如下麵這段代碼:
int i = 0; boolean flag = false; i = 1; flag = true;
在代碼順序上 i=1
在 flag=true
前面,而 JVM 在真正執行代碼的時候不一定能保證 i=1
在flag=true
前面執行,這裡就發生了指令重排序
。
指令重排序
一般是為了提升程式運行效率,編譯器或處理器通常會做指令重排序:
- 編譯器重排序:編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序
- 處理器重排序:如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。CPU 在指令重排序時會考慮指令之間的數據依賴性,如果指令2必須依賴用到指令1的結果,那麼CPU會保證指令1在指令2之前執行。
指令重排序不保證程式中各個語句的執行順序和代碼中的一致,但會保證程式最終執行結果和代碼順序執行的結果是一致的。比如上例中的代碼, i=1
和 flag=true
兩個語句先後執行對最終的程式結果沒有影響,就有可能 CPU 先執行 flag=true
,後執行 i=1
。
2. java 記憶體模型
由於 volatile 關鍵字是與 java 記憶體模型相關的,因此瞭解 volatile 前,需要先瞭解下 java 記憶體模型相關概念
2.1. 硬體效率與緩存一致性
電腦執行程式時,每條指令都是在 CPU 中執行的,而執行指令過程中,勢必涉及到數據的讀取和寫入。CPU 在與記憶體交互時,需要讀取運算數據、存儲結果數據,這些 I/O 操作的速度與 CPU 的處理速度有幾個數量級的差距,所以不得不加入一層讀寫速度儘可能接近 CPU 運算速度的高速緩存(Cache)來作為記憶體與 CPU 之間的緩衝:將運算需要使用的數據複製到高速Cache中;運算結束後再從高速Cache同步回記憶體中。這樣 CPU 就無需等待緩慢的記憶體讀寫了。
這在單線程中運行是沒有問題的,但在多線程中運行就引入了 緩存一致性
的問題:在多處理系統中,每個處理器都有自己的高速緩存,而它們又共用同一主記憶體。當多個 CPU 的運算任務都涉及同一主記憶體區域時,將可能導致各自的緩存數據不一致,此時同步回主記憶體時以誰的數據為準呢?
為瞭解決緩存一致性問題,通常有兩種解決方法:
- 在匯流排加 LOCK# 鎖的方式
- 緩存一致性協議
早期的 CPU 中,通過在匯流排上加 LOCK# 鎖的形式來解決,因為 CPU 在和其他部件通信時都是通過匯流排進行,如果對匯流排加 LOCK# 鎖,也就阻塞了 CPU 對其他部件訪問(如記憶體),而使得只能有一個 CPU 使用這個變數的記憶體。
但這種方式有一個問題,在鎖住匯流排期間,其他 CPU 無法訪問記憶體,導致效率低下。
所有就出現了緩存一致性協議,最著名的就是 Intel 的 MESI 協議,MESI協議保證了每個緩存中使用的共用變數的副本是一致的, 它的核心思想是:CPU寫數據時,如果操作的變數是共用變數(其他 CPU 的高速緩存中也存在該變數的副本),會發出信號通知其他 CPU 將該變數的緩存設置為無效狀態,那麼當其他 CPU 讀取該變數時,就會從記憶體重新讀取。
JVM 有自己的記憶體模型,在訪問緩存時,遵循一些協議來解決緩存一致性的問題。
2.2. 主記憶體和工作記憶體
Java虛擬機規範中試圖定義一種 Java 記憶體模型(JMM, Java Memory Model)來屏蔽硬體和操作系統的記憶體訪問差異,實現 Java 程式在各種平臺上達到一致的記憶體訪問效果。
Java 記憶體模型主要目標:是定義程式中各個變數的訪問規則,即存儲變數到記憶體和從記憶體中取出變數這樣的底層細節。為了較好的執行性能,Java 記憶體模型並沒有限制使用 CPU 的寄存器和高速緩存來提升指令執行速度,也沒有限制編譯器對指令做重排序。也就是說:在 Java 記憶體模型中,也會存在緩存一致性問題和指令重排序問題。
Java 記憶體模型規定所有的變數(包括實例欄位、靜態欄位、構成數組對象的元素,不包括線程私有的局部變數和方法參數,因為這些不會出現競爭問題)都存儲在主記憶體中,每條線程有自己的工作記憶體(可與之前將講的CPU高速緩存類比),線程的工作記憶體中保存了被該線程使用到的變數的主記憶體拷貝副本。線程對變數的所有操作(read,write)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數,線程間變數值的傳遞需要通過主記憶體來完成。如圖所示:
2.3. JMM如何處理原子性
像以下語句:
x = 10; //語句1 y = x; //語句2 x++; //語句3 x = x + 1; //語句4
只有語句1才是原子性的操作,其他都不是原子性操作。
語句1是直接將10賦值給x變數,也就是說線程執行這個語句時,會直接將10寫入到工作記憶體中。
語句2包含了兩個操作,先讀取x的值,然後將x的值寫入到工作記憶體賦值給y,這兩個操作合起來就不是原子性操作了。
語句3和4都包括3個操作,先讀取x的值,然後加1操作,最後寫入新值。
單線程環境下,我們可以認為整個步驟都是原子性的。但多線程環境下則不同,只有基本數據類型的訪問讀寫是具備原子性的,如果還需要提供更大範圍的原子性保證,可以使用同步代碼塊 -- synchronized 關鍵字。在 synchronized 塊之間的操作具備原子性。
2.4. JMM如何處理可見性
Java 記憶體模型是通過變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體刷新變數值這種依賴主記憶體作為傳遞媒介的方式實現可見性的。普通變數和 volatile 變數都如此,區別在於:
- volatile 特殊規則保證了新值能立即同步回主記憶體,以及每次改前立即從主記憶體刷新。因此 volatile 變數保證了多線程操作時變數的可見性
- 而普通變數無法保證這一點,因為普通的共用變數修改後,什麼時候同步寫回主記憶體是不確定的,其他線程讀取時,此時記憶體中的可能還是原來的舊值。
除了 volatile 變數外,synchronized 和 final 關鍵字也能實現可見性。
synchronized 同步塊的可見性是由:對一個變數執行 unlock 操作前,必須先把此變數同步回主記憶體中
這條規則獲得的。
final 可見性是指:被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把this的引用傳遞出去,那在其他線程中就能看見final欄位的值
。
2.5. JMM如何處理有序性
Java 程式中天然的有序性可概括為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句指:線程內表現為串列語義
,後半句是指:指令重排序
現象和工作記憶體和主記憶體同步延遲
現象
Java 中提供了 volatile 和 synchronized 關鍵字來保證線程之間操作的有序性。volatile 本身就包含了禁止指令重排序的語義,而 synchronized 是由一個變數在同一時刻只允許一條線程對其 lock 操作
這條規則獲得,這條規則決定了持有同一個鎖的兩個同步代碼塊只能串列的執行。
happens-before 原則
Java記憶體模型中,有序性保證不僅只有 synchronized 和 volatile,否則一切操作都將變得繁瑣。Java 中還有一個 happens-before 原則
,它是判斷線程是否安全的主要依據。依靠這個規則,可以保證程式的有序性,如果兩個操作的執行順序無法從 happens-before 原則中推導出來,則他們就不能保證有序性,可以隨意重排序。
happens-before(先行發生)
是 Java 記憶體模型中定義的兩項操作之間的偏序關係,如果操作A先行發生於操作B,那麼就是說發生操作B之前,操作A產生的影響能被操作B觀察到。影響包括修改記憶體中共用變數的值、發送了消息、調用了方法等。
下麵是 Java 記憶體模型下的天然的先行發生關係,這些關係無需任何同步就已經存在:
程式次序規則
:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確的來說,應該是控制流順序,而不是代碼順序,因為要考慮分支、迴圈等結構管程鎖定規則
:一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作volatile變數規則
:對一個 volatile 變數的寫操作先行發生於後面對這個變數的讀操作線程啟動規則
:Thread 對象的 start() 方法先行發生於此線程的每個一個動作線程終止規則
:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值手段檢測到線程是否已經終止執行線程中斷規則
:對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可通過 Thread.isinterrupted() 檢測是否有中斷發生對象終結規則
:一個對象的初始化完成先行發生於他的 finalize() 方法的開始傳遞規則
:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
第一條程式次序原則
,"書寫在前面的操作先行發生於書寫在後面的操作",這個應該是一段程式代碼的執行在單線程中看起來是有序的,因為虛擬機可能對程式代碼中不存在數據依賴性的指令進行重排序,但最終執行結果與順序執行的結果是一致的。而在多線程中,無法保證程式執行的有序性。
第二條,第三條分別是關於 synchronized同步塊 和 volatile 的規則。第四至第七條是關於 Thread 線程的規則。第八條是體現了 happens-before 原則的傳遞性。
下麵是一個利用 happens-before 規則判斷操作間是否具備順序性的例子:
private int value=0; public void setValue() { this.value = value; } public int setValue() { return value; }
這段是一段普通的 getter/setter 方法,假如線程A先調用(時間上的先後)了 setValue(1),然後線程B調用了同一個對象的 getValue(),那麼線程B的返回值是什麼呢?
我們按以上 happens-before 規則分析:
- 由於存線上程A和線程B調用,不在一個線程中,
程式次序原則
則不適用; - 沒有同步快,也沒有unlock和lock操作,所以
管程鎖定規則
不適用; - 由於 value 沒有被 volatile 修飾,所以
volatile變數規則
不適用; - 後面的線程啟動、終止、中斷、終結和這裡沒有關係;
- 由於沒有適用的 happens-before 規則,最後的傳遞性也不適用
因此,可以判定儘管線程A在操作時間上先與線程B,但無法確定線程B中 getValue() 的返回值,也就是說,這裡的操作不是線程安全的。
該如何修複這個問題呢?可以有兩種方法:
- 將 getter/setter 定義為 synchronized 方法,這樣可以套用
管程鎖定規則
- 使用 volatile 關鍵字修飾 value,這樣可以套用
volatile變數規則
時間先後順序和 happens-before 原則之間沒有太大的關係,所以當我們衡量併發安全問題時,不要受到時間順序的干擾,一切應以 happens-before 原則為準。
3. volatile 實現原理
volatile 關鍵字是 JVM 提供的最輕量級的同步機制,當一個變數定義為 volatile 後,它將具有普通變數沒有的兩種特性:
保證此變數對所有線程的可見性
:當一個線程修改了該變數的值,新值對於其他線程來說是可以立即得知的。禁止指令重排序優化
。普通變數只能保證在方法執行過程中所有依賴賦值結果的地方都能獲得正確的結果,而不能保證變數賦值操作的順序和代碼中的順序一致,這也就是上文中提到的 Java 記憶體模型中所謂的"線程內表現為串列語義"。
3.1. volatile 保證原子性嗎
基於 volatile 變數的運算在併發下並不一定是線程安全的。因為 Java 里的運算並非原子操作,例如下麵是一個 volatile 變數自增運算的例子:
public class VolatileTest { public static volatile int race = 0; public void increase() { race++; } public static void main(String[] args) { Thread[] threads = new Thread[20]; for(int i=0; i<20; i++){ threads[i] = new Thread( new Runnable() { @Override public void run() { for(int i=0; i<10000; i++) increase(); }; }); threads[i].start(); } while(Thread.activeCount()>1) // 等待所有累加的線程都結束 Thread.yield(); System.out.println(race); } }
這段代碼發起了20個線程,每個線程對 race 累加10000次,如果併發正確的話,輸出結果應該是200000。而運行完這段代碼後,每次輸出的結果不一樣,都是小於200000。
問題就在於 race 通過 volatile 修飾只能保證每次讀取的都是最新的值,但不保證 race++ 是原子性的操作,它包括讀取變數的初始化,加1操作,將新值同步寫到主記憶體 三步。自增操作的三個子操作可能會分開執行。
假如某時刻 race 值為10,線程A 對 race 做自增操作,先讀取 race 的最新值10,此時 volatile 保證了 race 的值在此刻是正確的,但執行加1的時候,其他線程可能已經將 race 的值加大了,此時線程A工作記憶體中的 race 值就變成了過期的數據,然後將過期較小的新值同步回主記憶體。此時,多個線程對 race 分別做了一次自增操作,但可能主記憶體中的 race 值只增加了1。
volatile 無法保證對變數的任何操作都是原子性的,可以使用 synchronized 或 java.util.concurrent 中的原子類來修改。
3.2. volatile 保證可見性嗎
下麵代碼,線程A先執行,線程B後執行
//線程A boolean stop = false; while(!stop){ doSomething(); } //線程B stop = true;
這段代碼在大多數時候,能將線程A中的while迴圈結束,但有時候也會導致無法結束線程,造成一直while迴圈。原因在於:前面提到每個線程都有自己的工作記憶體,線程A運行時,會將 stop=false 的值同步一份在自己的工作記憶體中。當線程B更新了stop的值為true後,可能還沒來得及同步到主記憶體中,就去做其他事情了。此時線程B中 stop=true 的修改對於線程A是可不見的,導致線程A會一直迴圈下去。
如果將stop使用 volatile 修飾後,就可以保證線程A能退出迴圈。在於:使用 volatile 關鍵字會強制將線程B修改的新值stop立即同步至主記憶體。當線程B修改時,會導致線程A工作記憶體中stop的緩存行無效,反映到硬體上,就是CPU的高速緩存中對應的緩存行無效。線程A的工作記憶體中stop的緩存行無效後,會到主記憶體中再次讀取變數stop的新值。從而 volatile 保證了共用變數的可見性。
3.3. volatile 保證有序性嗎
volatile 可以通過禁止指令重排序來保證有序性,有兩層意思:
- 當程式執行到 volatile 變數的讀操作或寫操作時:在其前面的操作肯定全部已經完成,且結果對後面的操作可見。在其後面的操作肯定還沒進行
- 指令重排序優化時,不能將 volatile 變數前面的語句放在其後面執行,也不能將 volatile 變數後面的語句放到其前面執行。
舉個例子如下,flag是 volatile 變數,x/y都是非 volatile 變數:
x = 2; //語句1 y = 0; //語句2 flag = true; //語句3 x = 4; //語句4 y = -1; //語句5
在指令重排序時候,因為flag是 volatile 變數。所以執行到語句3時,語句1和語句2必定是執行完成了,且執行結果對語句3、語句4和語句5是可見的。不會將語句3放到語句1、語句2前面,也不會將語句3放到語句4、語句5後面。語句1和語句2的順序,語句4和語句5的順序是不做保證的。
下麵是一個指令重排序會幹擾程式併發執行的例子:
Map config; volatile boolean init = false; // 變數定義為volatile // 線程A執行 // 讀取配置信息,讀取完後將init設置為true,以通知其他線程配置使用 config = loadConfig(); init = true; // 線程B執行 // 等待init為true,代表線程A已經將配置初始化好 while(!init) { sleep(); } doSomeThingWhihConfig(config); // 使用線程A中初始化好的配置信息
假如 init 變數沒有使用 volatile 修飾,可能由於指令重排序的優化,導致線程A最後一句 init=true 提前執行(指這句代碼對應的彙編代碼被提前執行),這樣線程B中使用配置信息的代碼就可能出錯。而使用 volatile 對 init 變數進行修飾,就可以避免這種情況,因為執行到 init=true 時,可以保證 config 已經初始化好了。
3.4. 記憶體屏障
volatile 關鍵字是如何禁止指令重排序的?關鍵在於有 volatile 關鍵字和沒有 volatile 關鍵字所生成的彙編代碼,加入 volatile 修飾的變數,賦值後會多執行一個lock首碼指令,這個指令相當於一個記憶體屏障
。通過記憶體屏障實現對記憶體操作的順序限制,它提供了3個功能:
- 確保指令重排序時不會把後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排序到記憶體屏障的後面。這樣形成了指令重排序無法越過記憶體屏障的效果
- 強制將對工作記憶體的修改立即寫入主記憶體
- 如果是寫操作,會導致其他 CPU 中對應的緩存行無效
只有一個 CPU 訪問記憶體時,不需要記憶體屏障;但如果有兩個或更多 CPU 訪問同一塊記憶體,且其中一個在觀察另一個,就需要記憶體屏障來保證一致性了。
3.5. volatile 使用場景
某些情況下,volatile 同步機制的性能確實要優於鎖(使用 synchronized 或 java.util.concurrent 包裡面的鎖),但由於對鎖實現的很多優化和消除,使得很難量化的認為 volatile 會比 synchronized 快多少。如果 volatile 和自己比較的話,volatile 讀操作的性能消耗與普通變數基本沒有什麼差別,但寫操作可能慢一些,因為它需要在本地代碼中插入許多記憶體屏障指令保證處理器不會亂序執行。即便如此,大多數場景下 volatile 的總開銷仍然比鎖低,volatile 無法保證操作的原子性,是無法替代 synchronized的。在 volatile 和鎖之間選擇的唯一依據是 volatile 的語義能否滿足場景的需求。通常,使用 volatile 必須具備以下兩個條件:
- 對變數的寫操作不依賴於當前值,例如 count++ 這樣自增自減操作就不滿足這個條件
- 該變數沒有包含在具有其他變數的不變式中