1. 併發編程的3個概念
併發編程時,要想併發程序正確地執行,必需要保證原子性、可見性和有序性。只要有一個沒有被保證,就有可能會致使程序運行不正確。html
1.1. 原子性
原子性:即一個或多個操做要麼所有執行而且執行過程當中不會被打斷,要麼都不執行。java
一個經典的例子就是銀行轉帳:從帳戶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
前面執行,這裏就發生了指令重排序
。app
指令重排序
通常是爲了提高程序運行效率,編譯器或處理器一般會作指令重排序:ide
- 編譯器重排序:編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序
- 處理器重排序:若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。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一定是執行完成了,且執行結果對語句三、語句4和語句5是可見的。不會將語句3放到語句一、語句2前面,也不會將語句3放到語句四、語句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++ 這樣自增自減操做就不知足這個條件
- 該變量沒有包含在具備其餘變量的不變式中