「工做 5 年了,居然不知道 volatile 關鍵字!」html
聽着剛面試完的架構師一頓吐槽,其餘幾個同事也都參與此次吐槽之中。java
都說國內的面試是「面試造航母,工做擰螺絲」,有時候你就會由於一個問題被PASS。c++
你工做幾年了?知道 volatile 關鍵字嗎?面試
今天就讓咱們一塊兒來學習一下 volatile 關鍵字,作一個在能夠面試中造航母的螺絲工!編程
Java語言規範第三版中對 volatile 的定義以下: 數組
java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保經過排他鎖單獨得到這個變量。緩存
Java語言提供了 volatile,在某些狀況下比鎖更加方便。安全
若是一個字段被聲明成 volatile,java線程內存模型確保全部線程看到這個變量的值是一致的。架構
一旦一個共享變量(類的成員變量、類的靜態成員變量)被 volatile 修飾以後,那麼就具有了兩層語義:併發
保證了不一樣線程對這個變量進行操做時的可見性,即一個線程修改了某個變量的值,這新值對其餘線程來講是當即可見的。
若是 final 變量也被聲明爲 volatile,那麼這就是編譯時錯誤。
ps: 一個意思是變化可見,一個是永不變化。天然水火不容。
//線程1 boolean stop = false; while(!stop){ doSomething(); } //線程2 stop = true;
這段代碼是很典型的一段代碼,不少人在中斷線程時可能都會採用這種標記辦法。
可是事實上,這段代碼會徹底運行正確麼?即必定會將線程中斷麼?
不必定,也許在大多數時候,這個代碼可以把線程中斷,可是也有可能會致使沒法中斷線程(雖然這個可能性很小,可是隻要一旦發生這種狀況就會形成死循環了)。
下面解釋一下這段代碼爲什麼有可能致使沒法中斷線程。
在前面已經解釋過,每一個線程在運行過程當中都有本身的工做內存,那麼線程1在運行的時候,會將stop變量的值拷貝一份放在本身的工做內存當中。
那麼當線程 2 更改了 stop 變量的值以後,可是還沒來得及寫入主存當中,線程 2 轉去作其餘事情了,
那麼線程 1 因爲不知道線程 2 對 stop 變量的更改,所以還會一直循環下去。
第一:使用 volatile 關鍵字會強制將修改的值當即寫入主存;
第二:使用 volatile 關鍵字的話,當線程2進行修改時,會致使線程1的工做內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);
第三:因爲線程1的工做內存中緩存變量 stop 的緩存行無效,因此線程 1 再次讀取變量 stop 的值時會去主存讀取。
那麼在線程 2 修改 stop 值時(固然這裏包括 2 個操做,修改線程 2 工做內存中的值,而後將修改後的值寫入內存),
會使得線程 1 的工做內存中緩存變量 stop 的緩存行無效,而後線程 1 讀取時,
發現本身的緩存行無效,它會等待緩存行對應的主存地址被更新以後,而後去對應的主存讀取最新的值。
那麼線程 1 讀取到的就是最新的正確的值。
從上面知道 volatile 關鍵字保證了操做的可見性,可是 volatile 能保證對變量的操做是原子性嗎?
public class VolatileAtomicTest { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final VolatileAtomicTest test = new VolatileAtomicTest(); for (int i = 0; i < 10; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { test.increase(); } }).start(); } //保證前面的線程都執行完 while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(test.inc); } }
你可能以爲是 10000,可是實際是比這個數要小。
可能有的朋友就會有疑問,不對啊,上面是對變量 inc 進行自增操做,因爲 volatile 保證了可見性,
那麼在每一個線程中對inc自增完以後,在其餘線程中都能看到修改後的值啊,因此有10個線程分別進行了 1000 次操做,那麼最終inc的值應該是 1000*10=10000。
這裏面就有一個誤區了,volatile 關鍵字能保證可見性沒有錯,可是上面的程序錯在沒能保證原子性。
可見性只能保證每次讀取的是最新的值,可是 volatile 沒辦法保證對變量的操做的原子性。
使用 Lock synchronized 或者 AtomicInteger
volatile關鍵字禁止指令重排序有兩層意思:
當程序執行到 volatile 變量的讀操做或者寫操做時,在其前面的操做的更改確定所有已經進行,且結果已經對後面的操做可見;在其後面的操做確定尚未進行;
//x、y爲非volatile變量 //flag爲volatile變量 x = 2; //語句1 y = 0; //語句2 flag = true; //語句3 x = 4; //語句4 y = -1; //語句5
因爲 flag 變量爲 volatile 變量,那麼在進行指令重排序的過程的時候,不會將語句3放到語句一、語句2前面,也不會講語句3放到語句四、語句5後面。
可是要注意語句1和語句2的順序、語句4和語句5的順序是不做任何保證的。
而且 volatile 關鍵字能保證,執行到語句3時,語句1和語句2一定是執行完畢了的,且語句1和語句2的執行結果對語句三、語句四、語句5是可見的。
//線程1: context = loadContext(); //語句1 inited = true; //語句2 //線程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
前面舉這個例子的時候,提到有可能語句2會在語句1以前執行,那麼久可能致使 context 還沒被初始化,而線程2中就使用未初始化的context去進行操做,致使程序出錯。
這裏若是用 volatile 關鍵字對 inited 變量進行修飾,就不會出現這種問題了,由於當執行到語句2時,一定能保證 context 已經初始化完畢。
而 volatile 關鍵字在某些狀況下性能要優於 synchronized,
可是要注意 volatile 關鍵字是沒法替代 synchronized 關鍵字的,由於 volatile 關鍵字沒法保證操做的原子性。
一般來講,使用 volatile 必須具有如下2個條件:
對變量的寫操做不依賴於當前值
實際上,這些條件代表,能夠被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。
事實上,個人理解就是上面的2個條件須要保證操做是原子性操做,才能保證使用volatile關鍵字的程序在併發時可以正確執行。
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag = true; }
public class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); } } return instance; } }
在 JSR-133 以前的舊 Java 內存模型中,雖然不容許 volatile 變量之間重排序,但舊的 Java 內存模型容許 volatile 變量與普通變量之間重排序。
在舊的內存模型中,VolatileExample 示例程序可能被重排序成下列時序來執行:
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 } } }
時間線:-----------------------------------------------------------------> 線程 A:(2)寫 volatile 變量; (1)修改共享變量 線程 B: (3)讀取 volatile 變量; (4)讀共享變量
在舊的內存模型中,當1和2之間沒有數據依賴關係時,1和2之間就可能被重排序(3和4相似)。
其結果就是:讀線程B執行4時,不必定能看到寫線程A在執行1時對共享變量的修改。
所以在舊的內存模型中 ,volatile 的寫-讀沒有監視器的釋放-獲所具備的內存語義。
爲了提供一種比監視器鎖更輕量級的線程之間通訊的機制,
JSR-133專家組決定加強 volatile 的內存語義:
嚴格限制編譯器和處理器對 volatile 變量與普通變量的重排序,確保 volatile 的寫-讀和監視器的釋放-獲取同樣,具備相同的內存語義。
從編譯器重排序規則和處理器內存屏障插入策略來看,只要 volatile 變量與普通變量之間的重排序可能會破壞 volatile 的內存語意,
這種重排序就會被編譯器重排序規則和處理器內存屏障插入策略禁止。
術語 | 英文單詞 | 描述 |
---|---|---|
共享變量 | Shared variables | 在多個線程之間可以被共享的變量被稱爲共享變量。共享變量包括全部的實例變量,靜態變量和數組元素。他們都被存放在堆內存中,volatile 只做用於共享變量 |
內存屏障 | Memory Barriers | 是一組處理器指令,用於實現對內存操做的順序限制 |
緩衝行 | Cache line | 緩存中能夠分配的最小存儲單位。處理器填寫緩存線時會加載整個緩存線,須要使用多個主內存讀週期 |
原子操做 | Atomic operations | 不可中斷的一個或一系列操做 |
緩存行填充 | cache line fill | 當處理器識別到從內存中讀取操做數是可緩存的,處理器讀取整個緩存行到適當的緩存(L1,L2,L3的或全部) |
緩存命中 | cache hit | 若是進行高速緩存行填充操做的內存位置仍然是下次處理器訪問的地址時,處理器從緩存中讀取操做數,而不是從內存 |
寫命中 | write hit | 當處理器將操做數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,若是存在一個有效的緩存行,則處理器將這個操做數寫回到緩存,而不是寫回到內存,這個操做被稱爲寫命中 |
寫缺失 | write misses the cache | 一個有效的緩存行被寫入到不存在的內存區域 |
那麼 volatile 是如何來保證可見性的呢?
在 x86 處理器下經過工具獲取 JIT 編譯器生成的彙編指令來看看對 volatile 進行寫操做 CPU 會作什麼事情。
instance = new Singleton();//instance是volatile變量
對應彙編
0x01a3de1d: movb $0x0,0x1104800(%esi); 0x01a3de24: lock addl $0x0,(%esp);
有 volatile 變量修飾的共享變量進行寫操做的時候會多第二行彙編代碼,
經過查 IA-32 架構軟件開發者手冊可知,lock
前綴的指令在多核處理器下會引起了兩件事情。
將當前處理器緩存行的數據會寫回到系統內存。
處理器爲了提升處理速度,不直接和內存進行通信,而是先將系統內存的數據讀到內部緩存(L1,L2或其餘)後再進行操做,但操做完以後不知道什麼時候會寫到內存,
若是對聲明瞭 volatile 變量進行寫操做,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。
可是就算寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題。
因此在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理器經過嗅探在總線上傳播的數據來檢查本身緩存的值是否是過時了,
當處理器發現本身緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操做的時候,會強制從新從系統內存裏把數據讀處處理器緩存裏。
這兩件事情在IA-32軟件開發者架構手冊的第三冊的多處理器管理章節(第八章)中有詳細闡述
Lock 前綴指令致使在執行指令期間,聲言處理器的 LOCK# 信號。
在多處理器環境中,LOCK# 信號確保在聲言該信號期間,處理器能夠獨佔使用任何共享內存。(由於它會鎖住總線,致使其餘CPU不能訪問總線,不能訪問總線就意味着不能訪問系統內存),可是在最近的處理器裏,LOCK#信號通常不鎖總線,而是鎖緩存,畢竟鎖總線開銷比較大。
在8.1.4章節有詳細說明鎖定操做對處理器緩存的影響,對於Intel486和Pentium處理器,在鎖操做時,老是在總線上聲言LOCK#信號。
但在P6和最近的處理器中,若是訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK#信號。
相反地,它會鎖定這塊內存區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操做被稱爲「緩存鎖定」,
緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域數據。
IA-32處理器和Intel 64處理器使用MESI(修改,獨佔,共享,無效)控制協議去維護內部緩存和其餘處理器緩存的一致性。
在多核處理器系統中進行操做的時候,IA-32 和Intel 64處理器能嗅探其餘處理器訪問系統內存和它們的內部緩存。
它們使用嗅探技術保證它的內部緩存,系統內存和其餘處理器的緩存的數據在總線上保持一致。
例如在Pentium和P6 family處理器中,若是經過嗅探一個處理器來檢測其餘處理器打算寫內存地址,而這個地址當前處理共享狀態,
那麼正在嗅探的處理器將無效它的緩存行,在下次訪問相同內存地址時,強制執行緩存行填充。
著名的 Java 併發編程大師 Doug lea 在 JDK7 的併發包裏新增一個隊列集合類 LinkedTransferQueue
,
他在使用 volatile 變量時,用一種追加字節的方式來優化隊列出隊和入隊的性能。
追加字節能優化性能?這種方式看起來很神奇,但若是深刻理解處理器架構就能理解其中的奧祕。
讓咱們先來看看 LinkedTransferQueue
這個類,
它使用一個內部類類型來定義隊列的頭隊列(Head)和尾節點(tail),
而這個內部類 PaddedAtomicReference 相對於父類 AtomicReference 只作了一件事情,就將共享變量追加到 64 字節。
咱們能夠來計算下,一個對象的引用佔4個字節,它追加了15個變量共佔60個字節,再加上父類的Value變量,一共64個字節。
/** head of the queue */ private transient final PaddedAtomicReference < QNode > head; /** tail of the queue */ private transient final PaddedAtomicReference < QNode > tail; static final class PaddedAtomicReference < T > extends AtomicReference < T > { // enough padding for 64bytes with 4byte refs Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe; PaddedAtomicReference(T r) { super(r); } } public class AtomicReference < V > implements java.io.Serializable { private volatile V value; //省略其餘代碼 }
由於對於英特爾酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M處理器的L1,L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味着若是隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每一個處理器都會緩存一樣的頭尾節點,當一個處理器試圖修改頭接點時會將整個緩存行鎖定,那麼在緩存一致性機制的做用下,會致使其餘處理器不能訪問本身高速緩存中的尾節點,而隊列的入隊和出隊操做是須要不停修改頭接點和尾節點,因此在多處理器的狀況下將會嚴重影響到隊列的入隊和出隊效率。
Doug lea使用追加到64字節的方式來填滿高速緩衝區的緩存行,避免頭接點和尾節點加載到同一個緩存行,使得頭尾節點在修改時不會互相鎖定。
不是的。
在兩種場景下不該該使用這種方式。
第一:緩存行非64字節寬的處理器,如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個字節寬。
第二:共享變量不會被頻繁的寫。
由於使用追加字節的方式須要處理器讀取更多的字節到高速緩衝區,這自己就會帶來必定的性能消耗,共享變量若是不被頻繁寫的話,鎖的概率也很是小,就不必經過追加字節的方式來避免相互鎖定。
ps: 突然以爲術業想專攻,博學與睿智缺一不可。
Java虛擬機規範定義的許多規則中的一條:全部對基本類型的操做,除了某些對long類型和double類型的操做以外,都是原子級的。
目前的JVM(java虛擬機)都是將32位做爲原子操做,並不是64位。
當線程把主存中的 long/double類型的值讀到線程內存中時,多是兩次32位值的寫操做,顯而易見,若是幾個線程同時操做,那麼就可能會出現高低2個32位值出錯的狀況發生。
要在線程間共享long與double字段時,必須在synchronized中操做,或是聲明爲volatile。
volatile 做爲 JMM 中很是重要的一個關鍵字,基本也是面試高併發必問的知識點。
但願本文對你的工做學習面試有所幫助,若是有其餘想法的話,也能夠評論區和你們分享哦。
各位極客的點贊收藏轉發,是老馬寫做的最大動力!
更多精彩內容,能夠 嶶ィ訁関註【老馬嘯西風】