摘要:如果編寫的併發程式出現問題時,很難通過除錯來解決相應的問題,此時,需要一行行的檢查程式碼,這個時候,如果充分理解並掌握了Java的記憶體模型,你就能夠很快分析並定位出問題所在。
本文分享自華為雲社群《 【高併發】如何解決可見性和有序性問題?這次徹底懂了!》,作者:冰 河 。
今天,我們先來看看在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; }
以上示例來源於:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong
Java記憶體模式的底層實現
主要是通過記憶體屏障(memory barrier)禁止重排序的, 即時編譯器根據具體的底層體系架構, 將這些記憶體屏障替換成具體的 CPU 指令。 對於編譯器而言,記憶體屏障將限制它所能做的重排序優化。 而對於處理器而言, 記憶體屏障將會導致快取的重新整理操作。 比如, 對於volatile, 編譯器將在volatile欄位的讀寫操作前後各插入一些記憶體屏障。