摘要:如果編寫的并發程式出現問題時,很難通過調試來解決相應的問題,此時,需要一行行的檢查代碼,這個時候,如果充分了解并掌握了Java的記憶體模型,你就能夠很快分析并定位出問題所在。
本文分享自華為雲社群《 【高并發】如何解決可見性和有序性問題?這次徹底懂了!》,作者:冰 河 。
今天,我們先來看看在Java中是如何解決線程的可見性和有序性問題的,說到這,就不得不提一個Java的核心技術,那就是——Java的記憶體模型。
如果編寫的并發程式出現問題時,很難通過調試來解決相應的問題,此時,需要一行行的檢查代碼,這個時候,如果充分了解并掌握了Java的記憶體模型,你就能夠很快分析并定位出問題所在。
在記憶體裡,Java記憶體模型規定了所有的變量都存儲在主記憶體(實體記憶體)中,每條線程還有自己的工作記憶體,線程對變量的所有操作都必須在工作記憶體中進行。不同的線程無法通路其他線程的工作記憶體裡的内容。我們可以使用下圖來表示在邏輯上 線程、主記憶體、工作記憶體的三者互動關系。

現在,我們都了解了緩存導緻了可見性問題,編譯優化導緻了有序性問題。也就是說解決可見性和有序性問題的最直接的辦法就是禁用緩存和編譯優化。但是,如果隻是簡單的禁用了緩存和編譯優化,那我們寫的所謂的高并發程式的性能也就高不到哪去了!甚至會和單線程程式的性能沒什麼兩樣!有時,由于競争鎖的存在,可能會比單線程程式的性能還要低。
那麼,既然不能完全禁用緩存和編譯優化,那如何解決可見性和有序性的問題呢?其實,合理的方案應該是按照需要禁用緩存和編譯優化。什麼是按需禁用緩存和編譯優化呢?簡單點來說,就是需要禁用的時候禁用,不需要禁用的時候就不禁用。有些人可能會說,這不廢話嗎?其實不然,我們繼續向下看。
何時禁用和不禁用緩存和編譯優化,可以根據編寫高并發程式的開發人員的要求來合理的确定(這裡需要重點了解)。是以,可以這麼說,為了解決可見性和有序性問題,Java隻需要提供給Java程式員按照需要禁用緩存和編譯優化的方法即可。
Java記憶體模型是一個非常複雜的規範,網上關于Java記憶體模型的文章很多,但是大多數說的都是理論,理論說多了就成了廢話。這裡,我不會太多的介紹Java記憶體模型那些晦澀難懂的理論知識。 其實,作為開發人員,我們可以這樣了解Java的記憶體模型:Java記憶體模型規範了Java虛拟機(JVM)如何提供按需禁用緩存和編譯優化的方法。
說的具體一些,這些方法包括:volatile、synchronized和final關鍵字,以及Java記憶體模型中的Happens-Before規則。
volatile關鍵字不是Java特有的,在C語言中也存在volatile關鍵字,這個關鍵字最原始的意義就是禁用CPU緩存。
例如,我們在程式中使用volatile關鍵字聲明了一個變量,如下所示。
此時,Java對這個變量的讀寫,不能使用CPU緩存,必須從記憶體中讀取和寫入。
藍色的虛線箭頭代表禁用了CPU緩存,黑色的實線箭頭代表直接從主記憶體中讀寫資料。
接下來,我們一起來看一個代碼片段,如下所示。
以上示例來源于: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原則總結成如下圖所示。
接下來,我們就結合案例程式來說明Java記憶體模型中的Happens-Before原則。
在一個線程中,按照代碼的順序,前面的操作Happens-Before于後面的任意操作。
例如【示例一】中的程式x=1會在v=true之前執行。這個規則比較符合單線程的思維:在同一個線程中,程式在前面對某個變量的修改一定是對後續操作可見的。
對一個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代碼塊之前,會自動加鎖,在代碼塊執行完畢後,會自動釋放鎖。
我們可以這樣了解這段程式:假設變量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中執行的一個代碼片段,根據【原則五】線程的啟動規則,線程A啟動線程B之後,線程B能夠看到線程A在啟動線程B之前的操作,線上程B中通路到的x變量的值為100。
線程A等待線程B完成(線上程A中調用線程B的join()方法實作),當線程B完成後(線程A調用線程B的join()方法傳回),則線程A能夠通路到線程B對共享變量的操作。
例如,線上程A中進行的如下操作。
對線程interrupt()方法的調用Happens-Before于被中斷線程的代碼檢測到中斷事件的發生。
例如,下面的程式代碼。線上程A中中斷線程B之前,将共享變量x的值修改為100,則當線程B檢測到中斷事件時,通路到的x變量的值為100。
一個對象的初始化完成Happens-Before于它的finalize()方法的開始。
例如,下面的程式代碼。
運作結果如下所示。
使用final關鍵字修飾的變量,是不會被改變的。但是在Java 1.5之前的版本中,使用final修飾的變量也會出現錯誤的情況,在Java 1.5版本之後,Java記憶體模型對使用final關鍵字修飾的變量的重排序進行了一定的限制。隻要我們能夠提供正确的構造函數就不會出現問題。
例如,下面的程式代碼,在構造函數中将this指派給了全局變量global.obj,此時對象初始化還沒有完成,此時對象初始化還沒有完成,此時對象初始化還沒有完成,重要的事情說三遍!!線程通過global.obj讀取的x值可能為0。
主要是通過記憶體屏障(memory barrier)禁止重排序的, 即時編譯器根據具體的底層體系架構, 将這些記憶體屏障替換成具體的 CPU 指令。 對于編譯器而言,記憶體屏障将限制它所能做的重排序優化。 而對于處理器而言, 記憶體屏障将會導緻緩存的重新整理操作。 比如, 對于volatile, 編譯器将在volatile字段的讀寫操作前後各插入一些記憶體屏障。
點選關注,第一時間了解華為雲新鮮技術~