摘要
在上一篇文章當中,講到了CPU快取導致可見性、執行緒切換導致了原子性、編譯優化導致了有序性問題。那麼這篇文章就先解決其中的可見性和有序性問題,引出了今天的主角:Java記憶體模型(面試併發的時候會經常考核到)
什麼是Java記憶體模型?
現在知道了CPU快取導致可見性、編譯優化導致了有序性問題,那麼最簡單的方式就是直接禁用CPU快取和編譯優化。但是這樣做我們的效能可就要爆炸了~。我們應該按需禁用。
Java記憶體模型是有一個很複雜的規範,但是站在程式設計師的角度上可以理解為:Java記憶體模型規範了JVM如何提供按需禁用快取和編譯優化的方法。
具體包括 volatile、synchronized、final三個關鍵字,以及六項Happens-Before規則。
volatile關鍵字
volatile有禁用CPU快取的意思,禁用CPU快取那麼運算元據變數時直接是直接從記憶體中讀取和寫入。如:使用volatile宣告變數 volatile boolean v = false
,那麼操作變數v
時則必須從記憶體中讀取或寫入,但是在低於Java版本1.5以前,可能會有問題。
在下面這段程式碼當中,假設執行緒A執行了write
方法,執行緒B執行了reader
方法,假設執行緒B判斷到了this.v == true
進入到了判斷條件中,那麼此時的x會是多少呢?
public class VolatileExample {
private int x = 0;
private volatile boolean v = false;
public void write() {
this.x = 666;
this.v = true;
}
public void reader() {
if (this.v == true) {
// 這裡的x會是多少呢?
}
}
}
在1.5版本之前,該值可能為666,也可能為0;因為變數x
並沒有禁用快取(volatile),但是在1.5版本以後,該值一定為666;因為Happens-Before規則。
什麼是Happens-Before規則
Happens-Before規則要表達的是:前面一個操作的結果對後續是可見的。如果第一次接觸該規則,可能會有一些困惑,但是多去閱讀幾遍,就會加深理解。
1.程式的順序性規則
這條規則是指在一個執行緒中,按照程式順序,前面的操作Happens-Before於後續的任意操作(意思就是前面的操作結果對於後續任意操作都是可以看到的)。就如上面的那段程式碼,按照程式的順序:this.x = 666
Happens-Before於 this.v = true
。
2.Volatile 變數規則
這條規則指的是對一個Volatile變數的寫操作,Happens-Before該變數的讀操作。意思也就是:假設該變數被執行緒A寫入後,那麼該變數對於任何執行緒都是可見的。也就是禁用了CPU快取的意思,如果是這樣的話,那麼和1.5版本以前沒什麼區別啊!那麼如果再看一下規則3,就不同了。
3.傳遞性
這條規則指的是:如果 A Happens-Before 於B,且 B Happens-Before 於 C。那麼 A Happens-Before 於 C。這就是傳遞性的規則。我們再來看看剛才那段程式碼(我複製下來方便看)
public class VolatileExample {
private int x = 0;
private volatile boolean v = false;
public void write() {
this.x = 666;
this.v = true;
}
public void reader() {
if (this.v == true) {
// 讀取變數x
}
}
}
在上面程式碼,我們可以看到,this.x = 666
Happens-Before this.v = true
,this.v = true
Happens-Before 讀取變數x
,根據傳遞性規則this.x = 666
Happens-Befote 讀取變數x
,那麼說明了讀取到變數this.v = true
時,那麼此時的讀取變數x
的指必定為666
假設執行緒A執行了write
方法,執行緒B執行reader
方法且此時的this.v == true
,那麼根據剛才所說的傳遞性規則,讀取到的變數x
必定為666
。這就是1.5版本對volatile語義的增強。而如果在版本1.5之前,因為變數x
並沒有禁用快取(volatile),所以變數x
可能為0
哦。
4.管程中鎖的規則
這條規則是指對一個鎖的解鎖操作 Happens-Before 於後續對這個鎖的加鎖操作。管程是一種通用的同步原語,在Java中,synchronized是Java裡對管程的實現。
管程中的鎖在Java裡是隱式實現的。如下面的程式碼,在進入同步程式碼塊前,會自動加鎖,而在程式碼塊執行完後會自動解鎖。這裡的加鎖和解鎖都是編譯器幫我們實現的。
synchronized(this) { // 此處自動加鎖
// x是共享變數,初始值 = 0
if (this.x < 12) {
this.x = 12;
}
} // 此處自動解鎖
結合管程中的鎖規則,假設x
的初始值為0,執行緒A執行完程式碼塊後值會變成12,那麼當執行緒A解鎖後,執行緒B獲取到鎖進入到程式碼塊後,就能看到執行緒A的執行結果x = 12
。這就是管程中鎖的規則
5.執行緒的start()規則
這條規則是關於執行緒啟動的,該規則指的是主執行緒A啟動子執行緒B後,子執行緒B能夠看到主執行緒啟動子執行緒B前的操作。
用HappensBefore解釋:執行緒A呼叫執行緒B的start方法 Happens-Before 執行緒B中的任意操作。參考程式碼如下:
int x = 0;
public void start() {
Thread thread = new Thread(() -> {
System.out.println(this.x);
});
this.x = 666;
// 主執行緒啟動子執行緒
thread.start();
}
此時在子執行緒中列印的變數x
值為666,你也可以嘗試一下。
6.執行緒join()規則
這條規則是關於執行緒等待的,該規則指的是主執行緒A等待子執行緒B完成(主線A通過呼叫子執行緒B的join()
方法實現),當子執行緒B完成後,主執行緒能夠看到子執行緒的操作,這裡的看到指的是共享變數 的操作,用Happens-Before解釋:如果線上程A中呼叫了子執行緒B的join()
方法併成功返回,那麼子執行緒B的任意操作 Happens-Before 於主執行緒呼叫子執行緒Bjoin()
方法的後續操作。看程式碼比較容易理解,示例程式碼如下:
int x = 0;
public void start() {
Thread thread = new Thread(() -> {
this.x = 666;
});
// 主執行緒啟動子執行緒
thread.start();
// 主執行緒呼叫子執行緒的join方法進行等待
thread.join();
// 此時的共享變數 x == 666
}
被忽略的final
在1.5版本之前,除了值不可改變以外,final
欄位其實和普通的欄位一樣。
在1.5以後的Java記憶體模型中,對final
型別變數重排進行了約束。現在只要我們的提供正確的建構函式沒有逸出,那麼在建構函式初始化的final
欄位的最新值,必定可以被其他執行緒所看到。程式碼如下:
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
當執行緒執行reader()
方法,並且f != null
時,那麼此時的final
欄位修飾的f.x
必定為 3
,但是y
不能保證為4
,因為它不是final
的。如果這是在1.5版本之前,那麼f.x
也是不能保證為3
。
那麼何為逸出呢?我們修改一下建構函式:
public FinalFieldExample() {
x = 3;
y = 4;
// 此處為逸出
f = this;
}
這裡就不能保證 f.x == 3
了,就算x
變數是用final
修飾的,為什麼呢?因為在建構函式中可能會發生指令重排,執行變成下面這樣:
// 此處為逸出
f = this;
x = 3;
y = 4;
那麼此時的f.x == 0
。所以在建構函式中沒有逸出,那麼final修飾的欄位沒有問題。詳情的案例可以參考這個文件
總結
在這篇文章當中,我一開始對於文章最後部分的final
約束重排一直看的不懂。網上不斷地搜尋資料和看文章當中提供的資料我才慢慢看懂,反覆看了不下十遍。可能腦子不太靈活吧。
該文章主要的核心內容就是Happens-Before規則,把這幾條規則搞懂了就ok。
參考文章:極客時間:Java併發程式設計實戰 02
個人部落格網址: https://colablog.cn/
如果我的文章幫助到您,可以關注我的微信公眾號,第一時間分享文章給您