volatile一般被比喻成"輕量級的synchronized",也是Java併發編程中比較重要的一個關鍵字。和synchronized不一樣,volatile是一個變量修飾符,只能用來修飾變量。沒法修飾方法及代碼塊等。html
volatile的用法比較簡單,只須要在聲明一個可能被多線程同時訪問的變量時,使用volatile修飾就能夠了。java
如如下代碼,是一個比較典型的使用雙重鎖校驗的形式實現單例的,其中使用volatile關鍵字修飾可能被多個線程同時訪問到的singleton。c++
public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
爲了提升處理器的執行速度,在處理器和內存之間增長了多級緩存來提高。可是因爲引入了多級緩存,就存在緩存數據不一致問題。算法
可是,對於volatile變量,當對volatile變量進行寫操做的時候,JVM會向處理器發送一條lock前綴的指令,將這個緩存中的變量回寫到系統主存中。編程
可是就算寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題,因此在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議緩存
緩存一致性協議:每一個處理器經過嗅探在總線上傳播的數據來檢查本身緩存的值是否是過時了,當處理器發現本身緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操做的時候,會強制從新從系統內存裏把數據讀處處理器緩存裏。服務器
因此,若是一個變量被volatile所修飾的話,在每次數據變化以後,其值都會被強制刷入主存。而其餘處理器的緩存因爲遵照了緩存一致性協議,也會把這個變量的值從主存加載到本身的緩存中。這就保證了一個volatile在併發編程中,其值在多個緩存中是可見的。網絡
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。數據結構
Java內存模型規定了全部的變量都存儲在主內存中,每條線程還有本身的工做內存,線程的工做內存中保存了該線程中是用到的變量的主內存副本拷貝,線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存。多線程
不一樣的線程之間也沒法直接訪問對方工做內存中的變量,線程間變量的傳遞均須要本身的工做內存和主存之間進行數據同步進行。因此,就可能出現線程1改了某個變量的值,可是線程2不可見的狀況。
前面的關於volatile的原理中介紹過了,Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變量在被修改後能夠當即同步到主內存,被其修飾的變量在每次是用以前都從主內存刷新。所以,可使用volatile來保證多線程操做時變量的可見性。
有序性即程序執行的順序按照代碼的前後順序執行。
除了引入了時間片之外,因爲處理器優化和指令重排等,CPU還可能對輸入代碼進行亂序執行,好比load->add->save 有可能被優化成load->save->add 。這就是可能存在有序性問題。
而volatile除了能夠保證數據的可見性以外,還有一個強大的功能,那就是他能夠禁止指令重排優化等。
普通的變量僅僅會保證在該方法的執行過程當中所依賴的賦值結果的地方都能得到正確的結果,而不能保證變量的賦值操做的順序與程序代碼中的執行順序一致。
volatile能夠禁止指令重排,這就保證了代碼的程序會嚴格按照代碼的前後順序執行。這就保證了有序性。被volatile修飾的變量的操做,會嚴格按照代碼順序執行,load->add->save 的執行順序就是:load、add、save。
volatile與原子性原子性是指一個操做是不可中斷的,要所有執行完成,要不就都不執行。
線程是CPU調度的基本單位。CPU有時間片的概念,會根據不一樣的調度算法進行線程調度。當一個線程得到時間片以後開始執行,在時間片耗盡以後,就會失去CPU使用權。因此在多線程場景下,因爲時間片在線程間輪換,就會發生原子性問題。
爲了保證原子性,須要經過字節碼指令monitorenter和monitorexit,可是volatile和這兩個指令之間是沒有任何關係的。
因此,volatile是不能保證原子性的。
在如下兩個場景中可使用volatile來代替synchronized:
除以上場景外,都須要使用其餘方式來保證原子性,如synchronized或者concurrent包。
咱們來看一下volatile和原子性的例子:
public class Test { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保證前面的線程都執行完 Thread.yield(); System.out.println(test.inc); } }
以上代碼比較簡單,就是建立10個線程,而後分別執行1000次i++操做。正常狀況下,程序的輸出結果應該是10000,可是,屢次執行的結果都小於10000。這其實就是volatile沒法知足原子性的緣由。
爲何會出現這種狀況呢,那就是由於雖然volatile能夠保證inc在多個線程之間的可見性。可是沒法inc++的原子性。
咱們介紹過了volatile關鍵字和synchronized關鍵字。如今咱們知道,synchronized能夠保證原子性、有序性和可見性。而volatile卻只能保證有序性和可見性。
咱們知道volatile關鍵字的做用是保證變量在多線程之間的可見性,它是java.util.concurrent包的核心,沒有volatile就沒有這麼多的併發類給咱們使用。
本文詳細解讀一下volatile關鍵字如何保證變量在多線程之間的可見性,在此以前,有必要講解一下CPU緩存的相關知識,掌握這部分知識必定會讓咱們更好地理解volatile的原理,從而更好、更正確地地使用volatile關鍵字。
一、CPU緩存
CPU緩存的出現主要是爲了解決CPU運算速度與內存讀寫速度不匹配的矛盾,由於CPU運算速度要比內存讀寫速度快得多,舉個例子:
這種訪問速度的顯著差別,致使CPU可能會花費很長時間等待數據到來或把數據寫入內存。
基於此,如今CPU大多數狀況下讀寫都不會直接訪問內存(CPU都沒有鏈接到內存的管腳),取而代之的是CPU緩存,CPU緩存是位於CPU與內存之間的臨時存儲器,它的容量比內存小得多可是交換速度卻比內存快得多。而緩存中的數據是內存中的一小部分數據,但這一小部分是短期內CPU即將訪問的,當CPU調用大量數據時,就可先從緩存中讀取,從而加快讀取速度。
按照讀取順序與CPU結合的緊密程度,CPU緩存可分爲:
一級緩存:簡稱L1 Cache,位於CPU內核的旁邊,是與CPU結合最爲緊密的CPU緩存
二級緩存:簡稱L2 Cache,份內部和外部兩種芯片,內部芯片二級緩存運行速度與主頻相同,外部芯片二級緩存運行速度則只有主頻的一半
三級緩存:簡稱L3 Cache,部分高端CPU纔有
每一級緩存中所存儲的數據所有都是下一級緩存中的一部分,這三種緩存的技術難度和制形成本是相對遞減的,因此其容量也相對遞增。
當CPU要讀取一個數據時,首先從一級緩存中查找,若是沒有再從二級緩存中查找,若是仍是沒有再從三級緩存中或內存中查找。通常來講每級緩存的命中率大概都有80%左右,也就是說所有數據量的80%均可以在一級緩存中找到,只剩下20%的總數據量才須要從二級緩存、三級緩存或內存中讀取。
二、使用CPU緩存帶來的問題
用一張圖表示一下CPU-->CPU緩存-->主內存數據讀取之間的關係:
當系統運行時,CPU執行計算的過程以下:
程序以及數據被加載到主內存指令和數據被加載到CPU緩存CPU執行指令,把結果寫到高速緩存高速緩存中的數據寫回主內存
若是服務器是單核CPU,那麼這些步驟不會有任何的問題,可是若是服務器是多核CPU,那麼問題來了,以Intel Core i7處理器的高速緩存概念模型爲例(圖片摘自《深刻理解計算機系統》):
試想下面一種狀況:
核0讀取了一個字節,根據局部性原理,它相鄰的字節一樣被被讀入核0的緩存核3作了上面一樣的工做,這樣核0與核3的緩存擁有一樣的數據核0修改了那個字節,被修改後,那個字節被寫回核0的緩存,可是該信息並無寫回主存核3訪問該字節,因爲核0並未將數據寫回主存,數據不一樣步。
爲了解決這個問題,CPU製造商制定了一個規則:當一個CPU修改緩存中的字節時,服務器中其餘CPU會被通知,它們的緩存將視爲無效。因而,在上面的狀況下,核3發現本身的緩存中數據已無效,核0將當即把本身的數據寫回主存,而後核3從新讀取該數據。
三、反彙編Java字節碼,查看彙編層面對volatile關鍵字作了什麼
有了上面的理論基礎,咱們能夠研究volatile關鍵字究竟是如何實現的。首先寫一段簡單的代碼:
/** * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7048693.html */ public class LazySingleton { private static volatile LazySingleton instance = null; public static LazySingleton getInstance() { if (instance == null) { instance = new LazySingleton(); } return instance; } public static void main(String[] args) { LazySingleton.getInstance(); } }
首先反編譯一下這段代碼的.class文件,看一下生成的字節碼:
沒有任何特別的。要知道,字節碼指令,好比上圖的getstatic、ifnonnull、new等,最終對應到操做系統的層面,都是轉換爲一條一條指令去執行,咱們使用的PC機、應用服務器的CPU架構一般都是IA-32架構的,這種架構採用的指令集是CISC(複雜指令集),而彙編語言則是這種指令集的助記符。
所以,既然在字節碼層面咱們看不出什麼端倪,那下面就看看將代碼轉換爲彙編指令能看出什麼端倪。
Windows上要看到以上代碼對應的彙編碼不難(吐槽一句,說說不難,爲了這個問題我找遍了各類資料,差點就準備安裝虛擬機,在Linux系統上搞了),訪問hsdis工具路徑可直接下載hsdis工具,下載完畢以後解壓,將hsdis-amd64.dll與hsdis-amd64.lib兩個文件放在%JAVA_HOME%jrebinserver路徑下便可,以下圖:
而後跑main函數,跑main函數以前,加入以下虛擬機參數:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance
這麼長長的彙編代碼,可能你們不知道CPU在哪裏作了手腳,沒事不難,定位到5九、60兩行:
0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
之因此定位到這兩行是由於這裏結尾寫明瞭line 14,line 14即volatile變量instance賦值的地方。後面的add dword ptr [rsp],0h都是正常的彙編語句,意思是將雙字節的棧指針寄存器+0,這裏的關鍵就是add前面的lock指令,後面詳細分析一下lock指令的做用和爲何加上lock指令後就能保證volatile關鍵字的內存可見性。
四、lock指令作了什麼
以前有說過IA-32架構,關於CPU架構的問題你們有興趣的能夠本身查詢一下,這裏查詢一下IA-32手冊關於lock指令的描述,沒有IA-32手冊的能夠去這個地址下載IA-32手冊下載地址,是個中文版本的手冊。
我摘抄一下IA-32手冊中關於lock指令做用的一些描述(由於lock指令的做用在手冊中散落在各處,並非在某一章或者某一節專門講):
在修改內存操做時,使用LOCK前綴去調用加鎖的讀-修改-寫操做,這種機制用於多處理器系統中處理器之間進行可靠的通信,具體描述以下:
(1)在Pentium和早期的IA-32處理器中,LOCK前綴會使處理器執行當前指令時產生一個LOCK#信號,這種老是引發顯式總線鎖定出現
(2)在Pentium四、Inter Xeon和P6系列處理器中,加鎖操做是由高速緩存鎖或總線鎖來處理。若是內存訪問有高速緩存且隻影響一個單獨的高速緩存行,那麼操做中就會調用高速緩存鎖,而系統總線和系統內存中的實際區域內不會被鎖定。同時,這條總線上的其它Pentium四、Intel Xeon或者P6系列處理器就回寫全部已修改的數據並使它們的高速緩存失效,以保證系統內存的一致性。若是內存訪問沒有高速緩存且/或它跨越了高速緩存行的邊界,那麼這個處理器就會產生LOCK#信號,並在鎖定操做期間不會響應總線控制請求
32位IA-32處理器支持對系統內存中的某個區域進行加鎖的原子操做。這些操做經常使用來管理共享的數據結構(如信號量、段描述符、系統段或頁表),兩個或多個處理器可能同時會修改這些數據結構中的同一數據域或標誌。處理器使用三個相互依賴的機制來實現加鎖的原子操做:
IA-32處理器提供有一個LOCK#信號,會在某些關鍵內存操做期間被自動激活,去鎖定系統總線。當這個輸出信號發出的時候,來自其餘處理器或總線代理的控制請求將被阻塞。軟件可以經過預先在指令前添加LOCK前綴來指定須要LOCK語義的其它場合。
在Intel38六、Intel48六、Pentium處理器中,明確地對指令加鎖會致使LOCK#信號的產生。由硬件設計人員來保證系統硬件中LOCK#信號的可用性,以控制處理器間的內存訪問。
對於Pentinum四、Intel Xeon以及P6系列處理器,若是被訪問的內存區域是在處理器內部進行高速緩存的,那麼一般不發出LOCK#信號;相反,加鎖只應用於處理器的高速緩存。
爲顯式地強制執行LOCK語義,軟件能夠在下列指令修改內存區域時使用LOCK前綴。當LOCK前綴被置於其它指令以前或者指令沒有對內存進行寫操做(也就是說目標操做數在寄存器中)時,會產生一個非法操做碼異常(#UD)。
【1】位測試和修改指令(BTS、BTR、BTC)
【2】交換指令(XADD、CMPXCHG、CMPXCHG8B)
【3】自動假設有LOCK前綴的XCHG指令
【4】下列單操做數的算數和邏輯指令:INC、DEC、NOT、NEG
【5】下列雙操做數的算數和邏輯指令:ADD、ADC、SUB、SBB、AND、OR、XOR
一個加鎖的指令會保證對目標操做數所在的內存區域加鎖,可是系統可能會將鎖定區域解釋得稍大一些。軟件應該使用相同的地址和操做數長度來訪問信號量(用做處理器之間發送信號的共享內存)。
例如,若是一個處理器使用一個字來訪問信號量,其它處理器就不該該使用一個字節來訪問這個信號量。總線鎖的完整性不收內存區域對齊的影響。加鎖語義會一直持續,以知足更新整個操做數所需的總線週期個數。
可是,建議加鎖訪問應該對齊在它們的天然邊界上,以提高系統性能:
【1】任何8位訪問的邊界(加鎖或不加鎖)
【2】鎖定的字訪問的16位邊界
【3】鎖定的雙字訪問的32位邊界
【4】鎖定的四字訪問的64位邊界
對全部其它的內存操做和全部可見的外部事件來講,加鎖的操做都是原子的。全部取指令和頁表操做可以越過加鎖的指令。加鎖的指令可用於同步一個處理器寫數據而另外一個處理器讀數據的操做。
IA-32架構提供了幾種機制用來強化或弱化內存排序模型,以處理特殊的編程情形。這些機制包括:
【1】I/O指令、加鎖指令、LOCK前綴以及串行化指令等,強制在處理器上進行較強的排序
【2】SFENCE指令(在Pentium III中引入)和LFENCE指令、MFENCE指令(在Pentium4和Intel Xeon處理器中引入)提供了
某些特殊類型內存操做的排序和串行化功能
...(這裏還有兩條就不寫了)
這些機制能夠經過下面的方式使用。
總線上的內存映射設備和其它I/O設備一般對向它們緩衝區寫操做的順序很敏感,I/O指令(IN指令和OUT指令)如下面的方式對這種訪問執行強寫操做的排序。在執行了一條I/O指令以前,處理器等待以前的全部指令執行完畢以及全部的緩衝區都被都被寫入了內存。只有取指令和頁表查詢可以越過I/O指令,後續指令要等到I/O指令執行完畢纔開始執行。
反覆思考IA-32手冊對lock指令做用的這幾段描述,能夠得出lock指令的幾個做用:
鎖總線,其它CPU對內存的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖緩存替代鎖總線,由於鎖總線的開銷比較大,鎖總線期間其餘CPU無法訪問內存lock後的寫操做會回寫已修改的數據,同時讓其它CPU相關緩存行失效,從而從新從主存中加載最新的數據不是內存屏障卻能完成相似內存屏障的功能,阻止屏障兩遍的指令重排序
(1)中寫了因爲效率問題,實際後來的處理器都採用鎖緩存來替代鎖總線,這種場景下多緩存的數據一致是經過緩存一致性協議來保證的,咱們來看一下什麼是緩存一致性協議。
五、緩存一致性協議
講緩存一致性以前,先說一下緩存行的概念:
緩存是分段(line)的,一個段對應一塊存儲空間,咱們稱之爲緩存行,它是CPU緩存中可分配的最小存儲單元,大小32字節、64字節、128字節不等,這與CPU架構有關,一般來講是64字節。
當CPU看到一條讀取內存的指令時,它會把內存地址傳遞給一級數據緩存,一級數據緩存會檢查它是否有這個內存地址對應的緩存段,若是沒有就把整個緩存段從內存(或更高一級的緩存)中加載進來。注意,這裏說的是一次加載整個緩存段,這就是上面提過的局部性原理。
上面說了,LOCK#會鎖總線,實際上這不現實,由於鎖總線效率過低了。所以最好能作到:使用多組緩存,可是它們的行爲看起來只有一組緩存那樣。緩存一致性協議就是爲了作到這一點而設計的,就像名稱所暗示的那樣,這類協議就是要使多組緩存的內容保持一致。
緩存一致性協議有多種,可是平常處理的大多數計算機設備都屬於"嗅探(snooping)"協議,它的基本思想是:
全部內存的傳輸都發生在一條共享的總線上,而全部的處理器都能看到這條總線:緩存自己是獨立的,可是內存是共享資源,全部的內存訪問都要通過仲裁(同一個指令週期中,只有一個CPU緩存能夠讀寫內存)。
CPU緩存不只僅在作內存傳輸的時候才與總線打交道,而是不停在嗅探總線上發生的數據交換,跟蹤其餘緩存在作什麼。因此當一個緩存表明它所屬的處理器去讀寫內存時,其它處理器都會獲得通知,它們以此來使本身的緩存保持同步。只要某個處理器一寫內存,其它處理器立刻知道這塊內存在它們的緩存段中已失效。
MESI協議是當前最主流的緩存一致性協議,在MESI協議中,每一個緩存行有4個狀態,可用2個bit表示,它們分別是:
這裏的I、S和M狀態已經有了對應的概念:失效/未載入、乾淨以及髒的緩存段。因此這裏新的知識點只有E狀態,表明獨佔式訪問,這個狀態解決了"在咱們開始修改某塊內存以前,咱們須要告訴其它處理器"這一問題:只有當緩存行處於E或者M狀態時,處理器才能去寫它,也就是說只有在這兩種狀態下,處理器是獨佔這個緩存行的。
當處理器想寫某個緩存行時,若是它沒有獨佔權,它必須先發送一條"我要獨佔權"的請求給總線,這會通知其它處理器把它們擁有的同一緩存段的拷貝失效(若是有)。只有在得到獨佔權後,處理器才能開始修改數據----而且此時這個處理器知道,這個緩存行只有一份拷貝,在我本身的緩存裏,因此不會有任何衝突。
反之,若是有其它處理器想讀取這個緩存行(立刻能知道,由於一直在嗅探總線),獨佔或已修改的緩存行必須先回到"共享"狀態。若是是已修改的緩存行,那麼還要先把內容回寫到內存中。
六、由lock指令回看volatile變量讀寫
相信有了上面對於lock的解釋,volatile關鍵字的實現原理應該是一目瞭然了。首先看一張圖:
工做內存Work Memory其實就是對CPU寄存器和高速緩存的抽象,或者說每一個線程的工做內存也能夠簡單理解爲CPU寄存器和高速緩存。
那麼當寫兩條線程Thread-A與Threab-B同時操做主存中的一個volatile變量i時,Thread-A寫了變量i,那麼:
Thread-A發出LOCK#指令發出的LOCK#指令鎖總線(或鎖緩存行),同時讓Thread-B高速緩存中的緩存行內容失效Thread-A向主存回寫最新修改的i。
Thread-B讀取變量i,那麼:
Thread-B發現對應地址的緩存行被鎖了,等待鎖的釋放,緩存一致性協議會保證它讀取到最新的值。
由此能夠看出,volatile關鍵字的讀和普通變量的讀取相比基本沒差異,差異主要仍是在變量的寫操做上。
文源網絡,僅供學習之用,若有侵權,聯繫刪除。