大家好,我是冰河~~
寫在前面
大冰:小菜童鞋,目前,我們把所有可見性問題、原子性問題和有序性問題都介紹完了,感覺自己有啥進步嗎?
小菜:大冰哥,通過前面的學習,感覺自己進步确實挺大的,原來學習并發程式設計包含的知識點這麼多,我之前以為隻是簡單的建立一個線程而已,怪不得上次我沒有通過面試呢!
大冰:是的,并發程式設計包含的知識點很多,我們慢慢學習。之前,我們介紹了可見性問題、原子性問題和有序性問題,那麼今天,我們就來講講如何解決可見性和有序性問題。
問題排查
我們之前通過:
《
【高并發】一文解密詭異并發問題的第一個幕後黑手——可見性問題》
【高并發】解密導緻并發問題的第二個幕後黑手——原子性問題 【高并發】解密導緻并發問題的第三個幕後黑手——有序性問題詳細介紹了導緻并發程式設計出現各種詭異問題的三個“幕後黑手”,接下來,我們就開始手撕這三個“幕後黑手”,讓并發程式設計不再困難!
今天,我們先來看看在Java中是如何解決線程的可見性和有序性問題的,說到這,就不得不提一個Java的核心技術,那就是——Java的記憶體模型。
如果編寫的并發程式出現問題時,很難通過調試來解決相應的問題,此時,需要一行行的檢查代碼,這個時候,如果充分了解并掌握了Java的記憶體模型,你就能夠很快分析并定位出問題所在。
什麼是Java記憶體模型?
在記憶體裡,Java記憶體模型規定了所有的變量都存儲在主記憶體(實體記憶體)中,每條線程還有自己的工作記憶體,線程對變量的所有操作都必須在工作記憶體中進行。不同的線程無法通路其他線程的工作記憶體裡的内容。我們可以使用下圖來表示在邏輯上 線程、主記憶體、工作記憶體的三者互動關系。
現在,我們都了解了緩存導緻了可見性問題,編譯優化導緻了有序性問題。也就是說解決可見性和有序性問題的最直接的辦法就是禁用緩存和編譯優化。但是,如果隻是簡單的禁用了緩存和編譯優化,那我們寫的所謂的高并發程式的性能也就高不到哪去了!甚至會和單線程程式的性能沒什麼兩樣!有時,由于競争鎖的存在,可能會比單線程程式的性能還要低。
那麼,既然不能完全禁用緩存和編譯優化,那如何解決可見性和有序性的問題呢?其實,合理的方案應該是按照需要禁用緩存和編譯優化。什麼是按需禁用緩存和編譯優化呢?簡單點來說,就是需要禁用的時候禁用,不需要禁用的時候就不禁用。有些人可能會說,這不廢話嗎?其實不然,我們繼續向下看。
何時禁用和不禁用緩存和編譯優化,可以根據編寫高并發程式的開發人員的要求來合理的确定(這裡需要重點了解)。是以,可以這麼說,為了解決可見性和有序性問題,Java隻需要提供給Java程式員按照需要禁用緩存和編譯優化的方法即可。
Java記憶體模型是一個非常複雜的規範,網上關于Java記憶體模型的文章很多,但是大多數說的都是理論,理論說多了就成了廢話。這裡,我不會太多的介紹Java記憶體模型那些晦澀難懂的理論知識。 其實,作為開發人員,我們可以這樣了解Java的記憶體模型:Java記憶體模型規範了Java虛拟機(JVM)如何提供按需禁用緩存和編譯優化的方法。
說的具體一些,這些方法包括:volatile、synchronized和final關鍵字,以及Java記憶體模型中的Happens-Before規則。
volatile為何能保證線程間可見?
volatile關鍵字不是Java特有的,在C語言中也存在volatile關鍵字,這個關鍵字最原始的意義就是禁用CPU緩存。
例如,我們在程式中使用volatile關鍵字聲明了一個變量,如下所示。
volatile int count = 0
此時,Java對這個變量的讀寫,不能使用CPU緩存,必須從記憶體中讀取和寫入。
藍色的虛線箭頭代表禁用了CPU緩存,黑色的實線箭頭代表直接從主記憶體中讀寫資料。
接下來,我們一起來看一個代碼片段,如下所示。
【示例一】
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 1;
v = true;
}
public void reader() {
if (v == true) {
//x的值是多少呢?
}
}
}
以上示例來源于:
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong這裡,假設線程A執行writer()方法,按照volatile會将v=true寫入記憶體;線程B執行reader()方法,按照volatile,線程B會從記憶體中讀取變量v,如果線程B讀取到的變量v為true,那麼,此時的變量x的值是多少呢??
這個示例程式給人的直覺就是x的值為1,其實,x的值具體是多少和JDK的版本有關,如果使用的JDK版本低于1.5,則x的值可能為1,也可能為0。如果使用1.5及1.5以上版本的JDK,則x的值就是1。
看到這個,就會有人提出問題了?這是為什麼呢?其實,答案就是在JDK1.5版本中的Java記憶體模型中引入了Happens-Before原則。
Happens-Before原則
我們可以将Happens-Before原則總結成如下圖所示。
接下來,我們就結合案例程式來說明Java記憶體模型中的Happens-Before原則。
【原則一】程式次序規則
在一個線程中,按照代碼的順序,前面的操作Happens-Before于後面的任意操作。
例如【示例一】中的程式x=1會在v=true之前執行。這個規則比較符合單線程的思維:在同一個線程中,程式在前面對某個變量的修改一定是對後續操作可見的。
【原則二】volatile變量規則
對一個volatile變量的寫操作,Happens-Before于後續對這個變量的讀操作。
也就是說,對一個使用了volatile變量的寫操作,先行發生于後面對這個變量的讀操作。這個需要大家重點了解。
【原則三】傳遞規則
如果A Happens-Before B,并且B Happens-Before C,則A Happens-Before C。
我們結合【原則一】、【原則二】和【原則三】再來看【示例一】程式,此時,我們可以得出如下結論:
(1)x = 1 Happens-Before 寫變量v = true,符合【原則一】程式次序規則。
(2)寫變量v = true Happens-Before 讀變量v = true,符合【原則二】volatile變量規則。
再根據【原則三】傳遞規則,我們可以得出結論:x = 1 Happens-Before 讀變量v=true。
也就是說,如果線程B讀取到了v=true,那麼,線程A設定的x = 1對線程B就是可見的。換句話說,就是此時的線程B能夠通路到x=1。
其實,Java 1.5版本的 java.util.concurrent并發工具就是靠volatile語義來實作可見性的。
【原則四】鎖定規則
對一個鎖的解鎖操作 Happens-Before于後續對這個鎖的加鎖操作。
例如,下面的代碼,在進入synchronized代碼塊之前,會自動加鎖,在代碼塊執行完畢後,會自動釋放鎖。
【示例二】
public class Test{
private int x = 0;
public void initX{
synchronized(this){ //自動加鎖
if(this.x < 10){
this.x = 10;
}
} //自動釋放鎖
}
}
我們可以這樣了解這段程式:假設變量x的值為10,線程A執行完synchronized代碼塊之後将x變量的值修改為10,并釋放synchronized鎖。當線程B進入synchronized代碼塊時,能夠擷取到線程A對x變量的寫操作,也就是說,線程B通路到的x變量的值為10。
【原則五】線程啟動規則
如果線程A調用線程B的start()方法來啟動線程B,則start()操作Happens-Before于線程B中的任意操作。
我們也可以這樣了解線程啟動規則:線程A啟動線程B之後,線程B能夠看到線程A在啟動線程B之前的操作。
我們來看下面的代碼。
【示例三】
//線上程A中初始化線程B
Thread threadB = new Thread(()->{
//此處的變量x的值是多少呢?答案是100
});
//線程A在啟動線程B之前将共享變量x的值修改為100
x = 100;
//啟動線程B
threadB.start();
上述代碼是線上程A中執行的一個代碼片段,根據【原則五】線程的啟動規則,線程A啟動線程B之後,線程B能夠看到線程A在啟動線程B之前的操作,線上程B中通路到的x變量的值為100。
【原則六】線程終結規則
線程A等待線程B完成(線上程A中調用線程B的join()方法實作),當線程B完成後(線程A調用線程B的join()方法傳回),則線程A能夠通路到線程B對共享變量的操作。
例如,線上程A中進行的如下操作。
【示例四】
Thread threadB = new Thread(()-{
//線上程B中,将共享變量x的值修改為100
x = 100;
});
//線上程A中啟動線程B
threadB.start();
//線上程A中等待線程B執行完成
threadB.join();
//此處通路共享變量x的值為100
【原則七】線程中斷規則
對線程interrupt()方法的調用Happens-Before于被中斷線程的代碼檢測到中斷事件的發生。
例如,下面的程式代碼。線上程A中中斷線程B之前,将共享變量x的值修改為100,則當線程B檢測到中斷事件時,通路到的x變量的值為100。
【示例五】
//線上程A中将x變量的值初始化為0
private int x = 0;
public void execute(){
//線上程A中初始化線程B
Thread threadB = new Thread(()->{
//線程B檢測自己是否被中斷
if (Thread.currentThread().isInterrupted()){
//如果線程B被中斷,則此時X的值為100
System.out.println(x);
}
});
//線上程A中啟動線程B
threadB.start();
//線上程A中将共享變量X的值修改為100
x = 100;
//線上程A中中斷線程B
threadB.interrupt();
}
【原則八】對象終結原則
一個對象的初始化完成Happens-Before于它的finalize()方法的開始。
例如,下面的程式代碼。
【示例六】
public class TestThread {
public TestThread(){
System.out.println("構造方法");
}
@Override
protected void finalize() throws Throwable {
System.out.println("對象銷毀");
}
public static void main(String[] args){
new TestThread();
System.gc();
}
}
運作結果如下所示。
構造方法
對象銷毀
再說final關鍵字
使用final關鍵字修飾的變量,是不會被改變的。但是在Java 1.5之前的版本中,使用final修飾的變量也會出現錯誤的情況,在Java 1.5版本之後,Java記憶體模型對使用final關鍵字修飾的變量的重排序進行了一定的限制。隻要我們能夠提供正确的構造函數就不會出現問題。
例如,下面的程式代碼,在構造函數中将this指派給了全局變量global.obj,此時對象初始化還沒有完成,此時對象初始化還沒有完成,此時對象初始化還沒有完成,重要的事情說三遍!!線程通過global.obj讀取的x值可能為0。
【示例七】
final x = 0;
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
Java記憶體模式的底層實作
主要是通過記憶體屏障(memory barrier)禁止重排序的, 即時編譯器根據具體的底層體系架構, 将這些記憶體屏障替換成具體的 CPU 指令。 對于編譯器而言,記憶體屏障将限制它所能做的重排序優化。 而對于處理器而言, 記憶體屏障将會導緻緩存的重新整理操作。 比如, 對于volatile, 編譯器将在volatile字段的讀寫操作前後各插入一些記憶體屏障。
如果覺得文章對你有點幫助,請微信搜尋并關注「 冰河技術 」微信公衆号,跟冰河學習高并發程式設計技術。
寫在最後
大冰:這就是今天的主要内容了,今天的内容非常重要,是從根源上深刻了解如何解決線程的可見性和有序性問題,小菜童鞋,回去後一定要好好複習下。
小菜:好的,大冰哥,回去我一定好好複習。
最後,附上并發程式設計需要掌握的核心技能知識圖,祝大家在學習并發程式設計時,少走彎路。