在多線程的世界裏,一共有三個問題:原子性問題、可見性問題、有序性問題。整個java併發體系也是圍繞着如何解決這三個問題來設計的。volatile關鍵字也不例外,我們都知道它解決了可見性和有序性,但是不能保證原子性。這篇文章也主要基於其中一個特性,也就是研究一下volatile是如何保證有序性的。
一、有序性
1、有序性案例
有序性指的是:程序執行的順序按照代碼的先後順序執行。我們可以先看一個被列舉了一萬次的代碼:
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
按照我們自己常規的想法,順序應該從上往下依次執行,但是真實情況是:jvm會在真正執行這段代碼的時候進行優化,發生指令的重排序。因此不能保證語句1一定在語句2先執行。
按照我們自己常規的想法,順序應該從上往下依次執行,但是真實情況是:jvm會在真正執行這段代碼的時候進行優化,發生指令的重排序。因此不能保證語句1一定在語句2先執行。
2、數據依賴性
上面的例子,你還會發現這樣一個特點,就算是發生了指令的重排序,但是最後的結果總是正確的。我們再舉一個例子:
int a = 10; //語句1
int r = 2; //語句2
a = a + 3; //語句3
r = a*a; //語句4
這種情況會發生指令重排序嗎?顯然不會,原因是處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令2必須用到指令1的結果,那麼處理器一定保證指令1在指令2執行。
3、多線程問題
這種數據的依賴性在單線程環境下一點問題沒有,因爲總能保證數據的正確,但是在多線程環境下就會出現錯誤。我們再舉一個例子:
class Test {
int a = 0;
boolean flag = false;
public void do1() {
a = 1; // 1
flag = true; // 2
}
public void do2() {
if (flag) { // 3
int i = a * a; // 4
}
}
}
上面的這段代碼由於語句1和語句2沒有數據依賴性,因此會發生指令重排。do2只要看到flag爲true,就執行。因此可能的順序是:
(1)語句1先於語句2:語句2->語句3->語句1->語句4。這時候的結果i=1。
(1)語句2先於語句1:語句2->語句3->語句4->語句1。這時候的結果i=0。
現在我們可以看到在多線程環境下如果發生了指令的重排序,會對結果造成影響。
上面一開始提到過,volatile可以保證有序性,也就是可以防止指令重排序。那麼它是如何解決的呢?這就是內存屏障。因此我們從內存屏障講起。
二、內存屏障
1、什麼是內存屏障
內存屏障其實就是一個CPU指令,在硬件層面上來說可以扥爲兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障。主要有兩個作用:
(1)阻止屏障兩側的指令重排序;
(2)強制把寫緩衝區/高速緩存中的髒數據等寫回主內存,讓緩存中相應的數據失效。
在JVM層面上來說作用與上面的一樣,但是種類可以分爲四種:
2、volatile如何保證有序性?
首先一個變量被volatile關鍵字修飾之後有兩個作用:
(1)對於寫操作:對變量更改完之後,要立刻寫回到主存中。
(2)對於讀操作:對變量讀取的時候,要從主存中讀,而不是緩存。
OK,現在針對上面JVM的四種內存屏障,應用到volatile身上。因此volatile也帶有了這種效果。其實上面提到的這些內存屏障應用的效果,可以是用happen-before來總結歸納。
3、內存屏障分類
內存屏障有三種類型和一種僞類型:
(1)lfence:即讀屏障(Load Barrier),在讀指令前插入讀屏障,可以讓高速緩存中的數據失效,重新從主內存加載數據,以保證讀取的是最新的數據。
(2)sfence:即寫屏障(Store Barrier),在寫指令之後插入寫屏障,能讓寫入緩存的最新數據寫回到主內存,以保證寫入的數據立刻對其他線程可見。
(3)mfence,即全能屏障,具備ifence和sfence的能力。
(4)Lock前綴:Lock不是一種內存屏障,但是它能完成類似全能型內存屏障的功能。
爲什麼說Lock是一種僞類型的內存屏障,是因爲內存屏障具有happen-before的效果,而Lock在一定程度上保證了先後執行的順序,因此也叫做僞類型。比如,IO操作的指令,當指令不執行時,就具有了mfence的功能。
OK,一句話說完就是內存屏障保證了volatile的有序性。當然,我在知乎等很多平臺也看到了從計算機底層角度來分析的。還特地去看了看相關文獻。發現這裏面要是詳細寫,不是一兩篇就能完成的。