1. 程式人生 > >再有人問你volatile是什麼,把這篇文章也發給他。

再有人問你volatile是什麼,把這篇文章也發給他。

上一篇文章中,我們圍繞volatile關鍵字做了很多闡述,主要介紹了volatile的用法、原理以及特性。在上一篇文章中,我提到過:volatile只能保證可見性和有序性,無法保證原子性。關於這部分內容,有讀者閱讀之後表示還是不是很理解,所以我再單獨寫一篇文章深入分析一下。

volatile與有序性

在上一篇文章中我們提到過:volatile一個強大的功能,那就是他可以禁止指令重排優化。通過禁止指令重排優化,就可以保證程式碼程式會嚴格按照程式碼的先後順序執行。那麼volatile又是如何禁止指令重排的呢?

先給出結論:volatile是通過記憶體屏障來來禁止指令重排的。

記憶體屏障(Memory Barrier)

是一類同步屏障指令,是CPU或編譯器在對記憶體隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後才可以開始執行此點之後的操作。下表描述了和volatile有關的指令重排禁止行為:

volatile

從上表我們可以看出:

當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

具體實現方式是在編譯期生成位元組碼時,會在指令序列中增加記憶體屏障來保證,下面是基於保守策略的JMM記憶體屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
    • 對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。
    • 對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。
  • 在每個volatile讀操作的後面插入一個LoadLoad
    屏障。
    • 對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
  • 在每個volatile讀操作的後面插入一個LoadStore屏障。
    • 對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
fences-table

所以,volatile通過在volatile變數的操作前後插入記憶體屏障的方式,來禁止指令重排,進而保證多執行緒情況下對共享變數的有序性。

volatile與可見性

在上一篇文章中我們提到過:Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變數在被修改後可以立即同步到主記憶體,被其修飾的變數在每次是用之前都從主記憶體重新整理。

其實,volatile對於可見性的實現,記憶體屏障也起著至關重要的作用。因為記憶體屏障相當於一個數據同步點,他要保證在這個同步點之後的讀寫操作必須在這個點之前的讀寫操作都執行完之後才可以執行。並且在遇到記憶體屏障的時候,快取資料會和主存進行同步,或者把快取資料寫入主存、或者從主存把資料讀取到快取。

我們在記憶體模型是怎麼解決快取一致性問題的?一文中介紹過快取快取一致性協議,同時也提到過記憶體一致性模型的實現可以通過快取一致性協議來實現。同時,留了一個問題:已經有了快取一致性協議,為什麼還需要volatile?

這個問題的答案可以從多個方面來回答:

1、並不是所有的硬體架構都提供了相同的一致性保證,Java作為一門跨平臺語言,JVM需要提供一個統一的語義。

2、作業系統中的快取和JVM中執行緒的本地記憶體並不是一回事,通常我們可以認為:MESI可以解決快取層面的可見性問題。使用volatile關鍵字,可以解決JVM層面的可見性問題。

3、快取可見性問題的延伸:由於傳統的MESI協議的執行成本比較大。所以CPU通過Store Buffer和Invalidate Queue元件來解決,但是由於這兩個元件的引入,也導致快取和主存之間的通訊並不是實時的。也就是說,快取一致性模型只能保證快取變更可以保證其他快取也跟著改變,但是不能保證立刻、馬上執行。

  • 其實,在計算機記憶體模型中,也是使用記憶體屏障來解決快取的可見性問題的(再次強調:快取可見性和併發程式設計中的可見性可以互相類比,但是他們並不是一回事兒)。寫記憶體屏障(Store Memory Barrier)可以促使處理器將當前store buffer(儲存快取)的值寫回主存。讀記憶體屏障(Load Memory Barrier)可以促使處理器處理invalidate queue(失效佇列)。進而避免由於Store Buffer和Invalidate Queue的非實時性帶來的問題。

所以,記憶體屏障也是保證可見性的重要手段,作業系統通過記憶體屏障保證快取間的可見性,JVM通過給volatile變數加入記憶體屏障保證執行緒之間的可見性。

記憶體屏障

再來總結一下Java中的記憶體屏障:用於控制特定條件下的重排序和記憶體可見性問題。Java編譯器也會根據記憶體屏障的規則禁止重排序。

volatile與原子性

以前的文章中,我們介紹synchronized的時候,提到過,為了保證原子性,需要通過位元組碼指令monitorentermonitorexit,但是volatile和這兩個指令之間是沒有任何關係的。volatile是不能保證原子性的。

網上有很多文章,拿i++的例子說明volatile不能保證原子性,然後進行各種分析,有的說由於引入記憶體屏障導致無法保證原子性,有的說一段i++程式碼,在編譯後位元組碼為:

    10: getfield      #2                  // Field i:I
    14: iconst_1
    15: iadd
    16: putfield      #2                  // Field i:I

在不考慮記憶體屏障的情況下,一個i++指令也包含了四個步驟。

這些分析,只是說明了i++本身並不是一個原子操作,即使使用volatile修飾i,也無法保證他是一個原子操作。並不能解釋為什麼volatile為啥不能保證原子性。

要我說,由於CPU按照時間片來進行執行緒排程的,只要是包含多個步驟的操作的執行,天然就是無法保證原子性的。因為這種執行緒執行,又不像資料庫一樣可以回滾。如果一個執行緒要執行的步驟有5步,執行完3步就失去了CPU了,失去後就可能再也不會被排程,這怎麼可能保證原子性呢。

為什麼synchronized可以保證原子性 ,因為被synchronized修飾的程式碼片段,在進入之前加了鎖,只要他沒執行完,其他執行緒是無法獲得鎖執行這段程式碼片段的,就可以保證他內部的程式碼可以全部被執行。進而保證原子性。

但是synchronized對原子性保證也不絕對,如果真要較真的話,一旦程式碼執行異常,也沒辦法回滾。所以呢,在併發程式設計中,原子性的定義不應該和事務中的原子性一樣。他應該定義為:一段程式碼,或者一個變數的操作,在沒有執行完之前,不能被其他執行緒執行。

那麼,為什麼volatile不能保證原子性呢?因為他不是鎖,他沒做任何可以保證原子性的處理。當然就不能保證原子性了。

總結

本文在上一篇文章的基礎上,再次介紹了volatile和原子性、有序性以及可見性之間的關係。有序性和可見性是通過記憶體屏障實現的。而volatile是無法保證原子性的。

參考資料

在這裡插入圖片描述