該文章屬於《Java併發編程》系列文章,若是想了解更多,請點擊《Java併發編程之總目錄》編程
在前面的文章中,咱們已經瞭解了Java的內存模型,瞭解了其可見性問題及指令重排序及Happen-Before原則,如今咱們來了解一下關鍵字volatile。在Java中volatile能夠算是Java提供的輕量級同步實現機制,可是在平時開發中,咱們更多的是使用synchronized來進行同步。對於volatile,你們老是不能正確的且完整的理解。因此下面,我就和你們一塊兒來了解一下volatile。緩存
當一個變量定義爲volatile後,那麼該變量對全部線程都是「可見的」,其中「可見的」是指當一條線程修改了這個變量的值,那麼新值對於其餘線程來講是能夠當即知道的。可能你們仍是很差的理解。若是你閱讀過上篇文章Java併發編程之Java內存模型,你應該很快的理解。不過沒有大礙,經過下列圖片你們應該很快的瞭解。安全
咱們已經知道在Java內存模型中,內存分爲了線程的工做內存及主內存。在上圖中,線程A與線程B分別從主內存中獲取變量a(用volatile修飾)到本身的工做內存中,也就是如今線程A與線程B中工做內存中的a如今的變量爲12,當線程A修改a的值爲8時,會將修改後的值(a=8)同步到主內存中,同時那麼會致使線程B中的緩存a變量的值(a=12)無效,會讓線程B從新重主內存中獲取新的值(a=8)。bash
在上篇文章Java併發編程之Java內存模型中咱們曾經講過,物理計算機爲了處理緩存不一致的問題。提出了緩存一致性的協議,其中緩存一致性的核心思想是:當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。多線程
既然volatile修飾的變量能具備「可見性」,那麼volatile內部確定是走的底層,同時也確定知足緩存一致性原則。由於涉及到底層彙編,這裏咱們不要去了解彙編語言,咱們只要知道當用volatile修飾變量時,生成的彙編指令會比普通的變量聲明會多一個Lock指令。那麼Lock指令會在多核處理器下會作兩件事情。併發
一樣的在上篇文章Java併發編程之Java內存模型中,咱們提到了爲了提升CPU(處理器)的處理數據的速度,CPU(處理器)會對沒有數據依賴性的指令進行重排序,可是CPU(處理器)的重排序會對多線程帶來問題。具體問題咱們用下列僞代碼來闡述:app
public class Demo {
private int a = 0;
private boolean isInit = false;
private Config config;
public void init() {
config = readConfig();//1
isInit = true;//2
}
public void doSomething() {
if (isInit) {//3
doSomethingWithconfig();//4
}
}
}
複製代碼
isInit用來標誌是否已經初始化配置。其中1,2操做是沒有數據依賴性,同理三、4操做也是沒有數據依賴性的。那麼CPU(處理器)可能對一、2操做進行重排序。對三、4操做進行重排序。如今咱們加入線程A操做Init()方法,線程B操做doSomething()方法,那麼咱們看看重排序對多線程狀況下的影響。post
上圖中2操做排在了1操做前面。當CPU時間片轉到線程B。線程B判斷 if (isInit)爲true,接下來接着執行 doSomethingWithconfig(),可是咱們Config尚未初始化。因此在多線程的狀況下。重排序會影響程序的執行結果。因此爲了防止重排序帶來的問題。Java內存模型規定了使用volatile來修飾相應變量時,能夠防止CPU(處理器)在處理指令的時候禁止重排序。具體以下圖所示。優化
public class Demo {
private int a = 0;
private volatile boolean isInit = false;
private Config config;
public void init() {
config = readConfig();//1
isInit = true;//2
}
public void doSomething() {
if (isInit) {//3
doSomethingWithconfig();//4
}
}
}
複製代碼
那麼爲了處理CPU重排序的問題。Java定義瞭如下規則防止CPU的重排序。ui
從上表咱們能夠看出
爲了具體實現上訴咱們提到的重排序規則,在Java中對於volatile修飾的變量,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序問題。在瞭解內存屏障以前,咱們先複習以前的主內存與工做內存交互的8種原子操做,由於內存屏障主要是對Java內存模型的幾種原子操做進行限制的。具體內存8種原子操做,以下圖所示:
這裏對內存屏障所涉及到的兩種操做進行解釋:
下面是基於volatile修飾的變量,編譯器在指令序列插入的內存屏障保守插入策略以下:
上面咱們講到了在插入內存屏障時,編譯器若是採用保守策略的狀況下,分別會在volatile寫與volatile讀插入不一樣的內存屏障,那如今咱們來看一下,在實際開發中,編譯器在使用內存屏障時的優化。
public class VolatileBarrierDemo {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
public void readAndWrite() {
int i = v1;//第一個volatile讀
int j = v2;//第二個volatile讀
a = i + j;//普通寫
v1 = i + 1;//第一個volatile寫
v2 = j * 2;//第二個volatile寫
}
}
複製代碼
那麼針對上述代碼,咱們生成相應的屏障(圖片在手機端觀看可能會不太清除,建議在pc端上觀看)
觀察上圖,咱們發現,在編譯器生成屏障時,省略了第一個volatile讀下的loadstore屏障,省略了第二個volatile讀下的loadload屏障,省略了第一個volatile寫下的storeload屏障。結合上訴咱們所講的loadstore屏障、loadload屏障、storeload屏障下的語義,咱們能獲得省略如下屏障的緣由。
其中你們要注意的是,優化結束後的storeload屏障時不能省略的,由於在第二個volatile寫以後,方法理解return,此時編譯器可能沒法肯定後面是否會有讀寫操做,爲了安全起見,編譯器一般會在這裏加入一個storeload屏障。
上面咱們講了編譯器在生成屏障的時候,會根據程序的邏輯操做省略沒必要要的內存屏障。可是因爲不一樣的處理器有不一樣的「鬆耦度」的內存模型,內存屏障的優化根據不一樣的處理器有着不一樣的優化方式。以x86處理器爲例。針對咱們上面所描述的編譯器內存屏障優化圖。在x86處理器中,除最後的storeload屏障外,其餘的屏障都會省略。
x86處理器與其餘處理器的內存屏障的優化,這裏不過的描述,有興趣的小夥伴能夠查閱相關資料繼續研究。
在volatile使用的時候,須要注意volatile只保證可見性,並不能保證原子性,這裏所提到的原子性須要給你們補充一個知識點。
在Java中,對基本的數據類型的變量的訪問和讀寫操做都是原子性操做,且這些操做在CPU中不能夠在中途暫停而後再調度,既不被中斷操做,要不執行完成,要不就不執行。
直接經過定義來理解確實比較困難,經過下面這個例子,讓咱們一塊兒來了解。
x = 10; //語句1
x++; //語句2
x = x + 1; //語句3
複製代碼
你們能夠來猜一猜,以上3個語句有哪些是具備原子性呢。好了。我告訴答案吧,只有語句1具備原子性。
你們對此會感到很疑惑。
對於語句2,3由於涉及到多個操做,且在多線程的狀況下,CPU能夠進行時間片的切換操做(也就是能夠暫停在某個操做後)。那麼就可能出現線程安全的問題。
描述了原子性後,相信你們都會有個疑問「volatile不具有原子性有什麼關係呢?其實緣由很簡單,雖然volatile是具有可見性的(也就是指當一條線程修改了這個變量的值,那麼新值對於其餘線程來講是能夠當即知道的),可是對於該變量有可能有多個操做例如上文提到的x++。那麼在有多個操做的狀況下,CPU任然能夠先暫停而後在調度的。既然能被暫停後繼續在調度,那麼volatile確定是不具有原子性的了。
如今咱們已經瞭解了volatile的相關特性,那麼就來講說,volatile的具體使用場景,由於volatie變量只能保證可見性,並不能保證原子性,因此在輕量級線程同步中咱們可使用volatile關鍵字。可是有兩個前提條件:
直接理解上述兩個條件,可能會有點困難,下面分別對着兩個前提條件進行解釋:
volatile int a = 0;
//在多線程狀況下錯誤,在單線程狀況下正確的方式
public void doSomeThingA() {
//在單線程狀況下,不會出現線程安全的問題,正確
//在多線程狀況下,a最終的值依賴於當前a的值,錯誤
a++;
}
//正確的使用方式
public void doSomeThingB() {
//不論是在單線程仍是多線程的狀況下,都不會出現線程安全的問題
if(a==0){
a = 1;
}
}
複製代碼
在上述僞代碼中,咱們能明確的看出,只要volatile修飾的變量不涉及與運算結果的依賴,那麼不論是在多線程,仍是單線程的狀況下,都是正確的。固然我這裏只是將a變量定義成成int,對於其餘剩下的基礎類型數據也是適用的。
其實理解第二個條件,你們能夠反過來理解,即便用volatile的變量不能包含在其餘變量的不變式中,下面僞代碼將會經過反例說明:
private volatile int lower;
private volatile int upper;
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
複製代碼
在上述代碼中,咱們明顯發現其中包含了一個不變式 —— 下界老是小於或等於上界(也就是lower<=upper)。那麼在多線程的狀況下,兩個線程在同一時間使用不一致的值執行 setLower 和 setUpper 的話,則會使範圍處於不一致的狀態。例如,若是初始狀態是(0, 5),同一時間內,線程 A 調用setLower(4) 而且線程 B 調用setUpper(3),顯然這兩個操做交叉存入的值是不符合條件的,那麼兩個線程都會經過用於保護不變式的檢查,使得最後的範圍值是(4, 3)。很顯然這個結果是錯誤的。