【Java併發程式設計】從CPU快取模型到JMM來理解volatile關鍵字

天喬巴夏丶發表於2020-09-21

併發程式設計三大特性

原子性

一個操作或者多次操作,要麼所有的操作全部都得到執行並且不會受到任何因素的干擾而中斷,要麼所有的操作都執行,要麼都不執行

對於基本資料型別的訪問,讀寫都是原子性的【long和double可能例外】。

如果需要更大範圍的原子性保證,可以使用synchronized關鍵字滿足。

可見性

當一個變數對共享變數進行了修改,另外的執行緒都能立即看到修改後的最新值

volatile保證共享變數可見性,除此之外,synchronizedfinal都可以 實現可見性。

synchronized:對一個變數執行unclock之前,必須先把此變數同步回主記憶體中。

final:被final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把this的引用傳遞出去,其他執行緒中就能夠看見final欄位的值。

有序性

即程式執行的順序按照程式碼的先後順序執行【由於指令重排序的存在,Java 在編譯器以及執行期間對輸入程式碼進行優化,程式碼的執行順序未必就是編寫程式碼時候的順序】,volatile通過禁止指令重排序保證有序性,除此之外,synchronized關鍵字也可以保證有序性,由【一個變數在同一時刻只允許一條執行緒對其進行lock操作】這條規則獲得。

CPU快取模型是什麼

快取記憶體為何出現?

計算機在執行程式時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到資料的讀取和寫入。由於程式執行過程中的臨時資料是存放在主存(實體記憶體)當中的,這時就存在一個問題,由於CPU執行速度很快,而從記憶體讀取資料和向記憶體寫入資料的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。

為了解決CPU處理速度和記憶體不匹配的問題,CPU Cache出現了。

圖源:JavaGuide

快取一致性問題

當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中。

在單執行緒中執行是沒有任何問題的,但是在多執行緒環境下問題就會顯現。舉個簡單的例子,如下面這段程式碼:

i = i + 1;

按照上面分析,主要分為如下幾步:

  • 從主存讀取i的值,複製一份到快取記憶體中。
  • CPU執行執行執行對i進行加1操作,將資料寫入快取記憶體。
  • 運算結束後,將快取記憶體中的資料重新整理到記憶體中。

多執行緒環境下,可能出現什麼現象呢?

  • 初始時,兩個執行緒分別讀取i的值,存入各自所在的CPU快取記憶體中。
  • 執行緒T1進行加1操作,將i的最新值1寫入記憶體。
  • 此時執行緒T2的快取記憶體中i的值還是0,進行加1操作,並將i的最新值1寫入記憶體。

最終的結果i = 1而不是i = 2,得出結論:如果一個變數在多個CPU中都存在快取(一般在多執行緒程式設計時才會出現),那麼就可能存在快取不一致的問題。

如何解決快取不一致

解決快取不一致的問題,通常來說有如下兩種解決方案【都是在硬體層面上提供的方式】:

通過在匯流排加LOCK#鎖的方式

在早期的CPU當中,是通過在匯流排上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。比如上面例子中 如果一個執行緒在執行 i = i +1,如果在執行這段程式碼的過程中,在匯流排上發出了LCOK#鎖的訊號,那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從變數i所在的記憶體讀取變數,然後進行相應的操作。這樣就解決了快取不一致的問題。

但,有一個問題,在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下,於是就出現了下面的快取一致性協議。

通過快取一致性協議

較著名的就是Intel的MESI協議,MESI協議保S證了每個快取中使用的共享變數的副本是一致的。

當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的【嗅探機制:每個處理器通過嗅探在匯流排上傳播的資料來檢查自己的快取的值是否過期】,那麼它就會從記憶體重新讀取

基於MESI一致性協議,每個處理器需要不斷從主記憶體嗅探和CAS不斷迴圈,無效互動會導致匯流排頻寬達到峰值,出現匯流排風暴

圖源:JavaFamily 敖丙三太子

JMM記憶體模型是什麼

JMM【Java Memory Model】:Java記憶體模型,是java虛擬機器規範中所定義的一種記憶體模型,Java記憶體模型是標準化的,遮蔽掉了底層不同計算機的區別,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果

它描述了Java程式中各種變數【執行緒共享變數】的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節。

注意,為了獲得較好的執行效能,Java記憶體模型並沒有限制執行引擎使用處理器的暫存器或者快取記憶體來提升指令執行速度,也沒有限制編譯器對指令進行重排序。也就是說,在java記憶體模型中,也會存在快取一致性問題和指令重排序的問題。

JMM的規定

所有的共享變數都儲存於主記憶體,這裡所說的變數指的是【例項變數和類變數】,不包含區域性變數,因為區域性變數是執行緒私有的,因此不存在競爭問題

每個執行緒都有自己的工作記憶體(類似於前面的快取記憶體)。執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接對主存進行操作。

每個執行緒不能訪問其他執行緒的工作記憶體。

Java對三大特性的保證

原子性

在Java中,對基本資料型別的變數的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。

為了更好地理解上面這句話,可以看看下面這四個例子:

x = 10;  	//1
y = x;   	//2
x ++;    	//3
x = x + 1;  //4
  1. 只有語句1是原子性操作:直接將數值10賦值給x,也就是說執行緒執行這個語句的會直接將數值10寫入到工作記憶體中
  2. 語句2實際包含兩個操作:先去讀取x的值,再將x的值寫入工作記憶體,雖然兩步分別都是原子操作,但是合起來就不能算作原子操作了。
  3. 語句3和4表示:先讀取x的值,進行加1操作,寫入新的值

需要注意的點:

  • 在32位平臺下,對64位資料的讀取和賦值是需要通過兩個操作來完成的,不能保證其原子性。在目前64位JVM中,已經保證對64位資料的讀取和賦值也是原子性操作了。https://www.zhihu.com/question/38816432
  • Java記憶體模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可以通過synchronized和Lock來實現。

可見性

Java提供了volatile關鍵字來保證可見性。

當一個共享變數被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他執行緒需要讀取時,它會去記憶體中讀取新值。

另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個執行緒獲取鎖然後執行同步程式碼,並且在釋放鎖之前會將對變數的修改重新整理到主存當中。因此可以保證可見性。

有序性

在Java記憶體模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。

在Java裡面,可以通過volatile關鍵字來保證有序性,另外也可以通過synchronized和Lock來保證有序性。

Java記憶體模型具備一些先天的有序性,前提是兩個操作滿足happens-before原則,摘自《深入理解Java虛擬機器》:

  • 程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作【讓程式看起來像是按照程式碼順序執行,虛擬機器只會對不存在資料依賴性的指令進行重排序,只能保證單執行緒中執行結果的正確性,多執行緒結果正確性卻無法保證】
  • 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作
  • volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作
  • 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
  • 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作
  • 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生
  • 執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行
  • 物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始

如果兩個操作的執行次序無法從happens-before原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它們進行重排序。

volatile解決的問題

  • 保證了不同執行緒對共享變數【類的成員變數,類的靜態成員變數】進行操作是時的可見性,一個執行緒修改了某個變數的值,新值對其他執行緒來說是立即可見的

  • 禁止指令重排序。

舉個簡單的例子,看下面這段程式碼:

//執行緒1
boolean volatile stop = false;
while(!stop){
    doSomething();
}
//執行緒2
stop = true;
  1. 執行緒1和2各自都擁有自己的工作記憶體,執行緒1和執行緒2首先都會將stop變數的值拷貝一份放到自己的工作記憶體中,
  2. 共享變數stop通過volatile修飾,執行緒2將stop的值改為true將會立即寫入主記憶體。
  3. 執行緒2寫入主記憶體之後,導致執行緒1工作記憶體中快取變數stop的快取行無效。
  4. 執行緒1的工作記憶體中快取變數stop的快取行無效,導致執行緒1會再次從主存中讀取stop值。

volatile保證原子性嗎?怎麼解決?

volatile無法保證原子性,如對一個volatile修飾的變數進行自增操作i ++,無法保證多執行緒下結果的正確性。

解決方法:

  • 使用synchronized關鍵字或者Lock加鎖,保證某個程式碼塊 在同一時刻只能被一個執行緒執行。
  • 使用JUC包下的原子類,如AtomicInteger等。【Atomic利用CAS來實現原子操作】。

volatile的實現原理

下面這段話摘自《深入理解Java虛擬機器》:

觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編程式碼發現,加入volatile關鍵字時,會多出一個lock字首指令。

lock字首指令實際上相當於一個記憶體屏障(也成記憶體柵欄),記憶體屏障會提供3個功能:

  • 它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成;
  • 它會強制將對快取的修改操作立即寫入主存;
  • 如果是寫操作,它會導致其他CPU中對應的快取行無效。

volatile和synchronized的區別

volatile變數讀操作的效能消耗與普通變數幾乎沒有什麼差別,但是寫操作則會慢一些,因為它需要在原生程式碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下volatile的總開銷仍然要比鎖來的低

  • volatile只能用於變數,而synchronized可以修飾方法以及程式碼塊。
  • volatile能保證可見性,但是不能保證原子性。synchronized兩者都能保證。如果只是對一個共享變數進行多個執行緒的賦值,而沒有其他的操作,推薦使用volatile,它更加輕量級。
  • volatile 關鍵字主要用於解決變數在多個執行緒之間的可見性,而 synchronized 關鍵字解決的是多個執行緒之間訪問資源的同步性。

volatile的使用條件

使用volatile必須具備兩個條件【保證原子】:

  • 對變數的寫操作不依賴於當前值。
  • 該變數沒有包含在具有其他變數的不變式中。

volatile與雙重檢查鎖實現單例

用雙重檢查鎖的方式實現單例模式:

public class Singleton {
	//注意使用volatile防止指令重排序
    private volatile static Singleton instance;
	//私有化構造方法,單例模式基本操作
    private Singleton() {
    }
	//靜態獲取單例的方法
    public  static Singleton getInstance() {
       //先判斷物件是否已經例項過,沒有例項化過才進入加鎖程式碼
        if (instance == null) {
            //類物件加鎖
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

使用volatile的原因:防止指令重排序。

instance= new Singleton();這一步,是一個例項化的過程,底層其實分為三部執行:

  1. 為instance分配記憶體空間:memory = allocate();
  2. 例項化instance。ctorInstance(memory);
  3. 將instance指向分配的記憶體地址。instance = memory;

由於JVM具有指令重排序的特性,指令的執行順序可能會變成1,3,2。在多執行緒環境下,可能某個執行緒可能會得到未初始化的例項。

舉個例子:加入執行緒A執行了1和2之後,執行緒B呼叫getInstance的時候,會發現instance不為null,會直接返回這個沒有執行過指令3的例項。

參考

相關文章