“工作 5 年了,竟然不知道 volatile 關鍵字!”
聽着剛面試完的架構師一頓吐槽,其他幾個同事也都參與這次吐槽之中。
都說國內的面試是“面試造航母,工作擰螺絲”,有時候你就會因爲一個問題被PASS。
你工作幾年了?知道 volatile 關鍵字嗎?
今天就讓我們一起來學習一下 volatile 關鍵字,做一個在可以面試中造航母的螺絲工!
volatile
Java語言規範第三版中對 volatile 的定義如下:
java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保通過排他鎖單獨獲得這個變量。
Java語言提供了 volatile,在某些情況下比鎖更加方便。
如果一個字段被聲明成 volatile,java線程內存模型確保所有線程看到這個變量的值是一致的。
“工作 5 年了,竟然不知道 volatile 關鍵字!”
聽着剛面試完的架構師一頓吐槽,其他幾個同事也都參與這次吐槽之中。
都說國內的面試是“面試造航母,工作擰螺絲”,有時候你就會因爲一個問題被PASS。
你工作幾年了?知道 volatile 關鍵字嗎?
今天就讓我們一起來學習一下 volatile 關鍵字,做一個在可以面試中造航母的螺絲工!
福利 福利 福利 免費領取Java架構技能地圖 注意了是免費送
volatile
Java語言規範第三版中對 volatile 的定義如下:
java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保通過排他鎖單獨獲得這個變量。
Java語言提供了 volatile,在某些情況下比鎖更加方便。
如果一個字段被聲明成 volatile,java線程內存模型確保所有線程看到這個變量的值是一致的。
語義
一旦一個共享變量(類的成員變量、類的靜態成員變量)被 volatile 修飾之後,那麼就具備了兩層語義:
保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
禁止進行指令重排序。
注意
如果 final 變量也被聲明爲 volatile,那麼這就是編譯時錯誤。
ps: 一個意思是變化可見,一個是永不變化。自然水火不容。
問題引入
Error.java
//線程1
boolean stop = false;
while(!stop){
doSomething();
}
//線程2
stop = true;
複製代碼
這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會採用這種標記辦法。
問題分析
但是事實上,這段代碼會完全運行正確麼?即一定會將線程中斷麼?
不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死循環了)。
下面解釋一下這段代碼爲何有可能導致無法中斷線程。
在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那麼線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內存當中。
那麼當線程 2 更改了 stop 變量的值之後,但是還沒來得及寫入主存當中,線程 2 轉去做其他事情了,
那麼線程 1 由於不知道線程 2 對 stop 變量的更改,因此還會一直循環下去。
使用 volatile
第一:使用 volatile 關鍵字會強制將修改的值立即寫入主存;
第二:使用 volatile 關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);
第三:由於線程1的工作內存中緩存變量 stop 的緩存行無效,所以線程 1 再次讀取變量 stop 的值時會去主存讀取。
那麼在線程 2 修改 stop 值時(當然這裏包括 2 個操作,修改線程 2 工作內存中的值,然後將修改後的值寫入內存), 會使得線程 1 的工作內存中緩存變量 stop 的緩存行無效,然後線程 1 讀取時, 發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。
那麼線程 1 讀取到的就是最新的正確的值。
volatile 保證原子性嗎
從上面知道 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關鍵字禁止指令重排序有兩層意思:
當程序執行到 volatile 變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;
在進行指令優化時,不能將在對 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放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。
但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。
並且 volatile 關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句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;
}
複製代碼
單例 double check
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 的增強
在 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 的內存語意, 這種重排序就會被編譯器重排序規則和處理器內存屏障插入策略禁止。
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 會做什麼事情。
java
instance = new Singleton();//instance是volatile變量
複製代碼
對應彙編
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);
複製代碼
有 volatile 變量修飾的共享變量進行寫操作的時候會多第二行彙編代碼, 通過查 IA-32 架構軟件開發者手冊可知,lock 前綴的指令在多核處理器下會引發了兩件事情。
將當前處理器緩存行的數據會寫回到系統內存。
這個寫回內存的操作會引起在其他 CPU 裏緩存了該內存地址的數據無效。
處理器爲了提高處理速度,不直接和內存進行通訊,而是先將系統內存的數據讀到內部緩存(L1,L2或其他)後再進行操作,但操作完之後不知道何時會寫到內存,
如果對聲明瞭 volatile 變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。
但是就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題。
所以在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了, 當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存裏把數據讀到處理器緩存裏。
這兩件事情在IA-32軟件開發者架構手冊的第三冊的多處理器管理章節(第八章)中有詳細闡述
Lock 前綴指令會引起處理器緩存回寫到內存
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處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處理共享狀態, 那麼正在嗅探的處理器將無效它的緩存行,在下次訪問相同內存地址時,強制執行緩存行填充。
volatile 的使用優化
著名的 Java 併發編程大師 Doug lea 在 JDK7 的併發包裏新增一個隊列集合類 LinkedTransferQueue, 他在使用 volatile 變量時,用一種追加字節的方式來優化隊列出隊和入隊的性能。
追加字節能優化性能?這種方式看起來很神奇,但如果深入理解處理器架構就能理解其中的奧祕。
讓我們先來看看 LinkedTransferQueue 這個類, 它使用一個內部類類型來定義隊列的頭隊列(Head)和尾節點(tail), 而這個內部類 PaddedAtomicReference 相對於父類 AtomicReference 只做了一件事情,就將共享變量追加到 64 字節。
我們可以來計算下,一個對象的引用佔4個字節,它追加了15個變量共佔60個字節,再加上父類的Value變量,一共64個字節。
LinkedTransferQueue.java
/** 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;
//省略其他代碼
}
複製代碼
爲什麼追加64字節能夠提高併發編程的效率呢?
因爲對於英特爾酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M處理器的L1,L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味着如果隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭尾節點,當一個處理器試圖修改頭接點時會將整個緩存行鎖定,那麼在緩存一致性機制的作用下,會導致其他處理器不能訪問自己高速緩存中的尾節點,而隊列的入隊和出隊操作是需要不停修改頭接點和尾節點,所以在多處理器的情況下將會嚴重影響到隊列的入隊和出隊效率。
Doug lea使用追加到64字節的方式來填滿高速緩衝區的緩存行,避免頭接點和尾節點加載到同一個緩存行,使得頭尾節點在修改時不會互相鎖定。
那麼是不是在使用Volatile變量時都應該追加到64字節呢?
不是的。
在兩種場景下不應該使用這種方式。
第一:緩存行非64字節寬的處理器,如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個字節寬。
第二:共享變量不會被頻繁的寫。
因爲使用追加字節的方式需要處理器讀取更多的字節到高速緩衝區,這本身就會帶來一定的性能消耗,共享變量如果不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加字節的方式來避免相互鎖定。
ps: 忽然覺得術業想專攻,博學與睿智缺一不可。
double/long 線程不安全
Java虛擬機規範定義的許多規則中的一條:所有對基本類型的操作,除了某些對long類型和double類型的操作之外,都是原子級的。
目前的JVM(java虛擬機)都是將32位作爲原子操作,並非64位。
當線程把主存中的 long/double類型的值讀到線程內存中時,可能是兩次32位值的寫操作,顯而易見,如果幾個線程同時操作,那麼就可能會出現高低2個32位值出錯的情況發生。
要在線程間共享long與double字段時,必須在synchronized中操作,或是聲明爲volatile。
作者:老馬嘯西風
鏈接:https://juejin.im/post/6885690021823643656
來源:掘金
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
語義
一旦一個共享變量(類的成員變量、類的靜態成員變量)被 volatile 修飾之後,那麼就具備了兩層語義:
保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
禁止進行指令重排序。
注意
如果 final 變量也被聲明爲 volatile,那麼這就是編譯時錯誤。
ps: 一個意思是變化可見,一個是永不變化。自然水火不容。
問題引入
Error.java
//線程1
boolean stop = false;
while(!stop){
doSomething();
}
//線程2
stop = true;
複製代碼
這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會採用這種標記辦法。
問題分析
但是事實上,這段代碼會完全運行正確麼?即一定會將線程中斷麼?
不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死循環了)。
下面解釋一下這段代碼爲何有可能導致無法中斷線程。
在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那麼線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內存當中。
那麼當線程 2 更改了 stop 變量的值之後,但是還沒來得及寫入主存當中,線程 2 轉去做其他事情了,
那麼線程 1 由於不知道線程 2 對 stop 變量的更改,因此還會一直循環下去。
使用 volatile
第一:使用 volatile 關鍵字會強制將修改的值立即寫入主存;
第二:使用 volatile 關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);
第三:由於線程1的工作內存中緩存變量 stop 的緩存行無效,所以線程 1 再次讀取變量 stop 的值時會去主存讀取。
那麼在線程 2 修改 stop 值時(當然這裏包括 2 個操作,修改線程 2 工作內存中的值,然後將修改後的值寫入內存), 會使得線程 1 的工作內存中緩存變量 stop 的緩存行無效,然後線程 1 讀取時, 發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。
那麼線程 1 讀取到的就是最新的正確的值。
volatile 保證原子性嗎
從上面知道 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關鍵字禁止指令重排序有兩層意思:
當程序執行到 volatile 變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;
在進行指令優化時,不能將在對 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放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。
但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。
並且 volatile 關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句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;
}
複製代碼
單例 double check
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 的增強
在 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 的內存語意, 這種重排序就會被編譯器重排序規則和處理器內存屏障插入策略禁止。
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 會做什麼事情。
java
instance = new Singleton();//instance是volatile變量
複製代碼
對應彙編
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);
複製代碼
有 volatile 變量修飾的共享變量進行寫操作的時候會多第二行彙編代碼, 通過查 IA-32 架構軟件開發者手冊可知,lock 前綴的指令在多核處理器下會引發了兩件事情。
將當前處理器緩存行的數據會寫回到系統內存。
這個寫回內存的操作會引起在其他 CPU 裏緩存了該內存地址的數據無效。
處理器爲了提高處理速度,不直接和內存進行通訊,而是先將系統內存的數據讀到內部緩存(L1,L2或其他)後再進行操作,但操作完之後不知道何時會寫到內存,
如果對聲明瞭 volatile 變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。
但是就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題。
所以在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了, 當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存裏把數據讀到處理器緩存裏。
這兩件事情在IA-32軟件開發者架構手冊的第三冊的多處理器管理章節(第八章)中有詳細闡述
Lock 前綴指令會引起處理器緩存回寫到內存
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處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處理共享狀態, 那麼正在嗅探的處理器將無效它的緩存行,在下次訪問相同內存地址時,強制執行緩存行填充。
volatile 的使用優化
著名的 Java 併發編程大師 Doug lea 在 JDK7 的併發包裏新增一個隊列集合類 LinkedTransferQueue, 他在使用 volatile 變量時,用一種追加字節的方式來優化隊列出隊和入隊的性能。
追加字節能優化性能?這種方式看起來很神奇,但如果深入理解處理器架構就能理解其中的奧祕。
讓我們先來看看 LinkedTransferQueue 這個類, 它使用一個內部類類型來定義隊列的頭隊列(Head)和尾節點(tail), 而這個內部類 PaddedAtomicReference 相對於父類 AtomicReference 只做了一件事情,就將共享變量追加到 64 字節。
我們可以來計算下,一個對象的引用佔4個字節,它追加了15個變量共佔60個字節,再加上父類的Value變量,一共64個字節。
LinkedTransferQueue.java
/** 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;
//省略其他代碼
}
複製代碼
爲什麼追加64字節能夠提高併發編程的效率呢?
因爲對於英特爾酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M處理器的L1,L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味着如果隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭尾節點,當一個處理器試圖修改頭接點時會將整個緩存行鎖定,那麼在緩存一致性機制的作用下,會導致其他處理器不能訪問自己高速緩存中的尾節點,而隊列的入隊和出隊操作是需要不停修改頭接點和尾節點,所以在多處理器的情況下將會嚴重影響到隊列的入隊和出隊效率。
Doug lea使用追加到64字節的方式來填滿高速緩衝區的緩存行,避免頭接點和尾節點加載到同一個緩存行,使得頭尾節點在修改時不會互相鎖定。
那麼是不是在使用Volatile變量時都應該追加到64字節呢?
不是的。
在兩種場景下不應該使用這種方式。
第一:緩存行非64字節寬的處理器,如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個字節寬。
第二:共享變量不會被頻繁的寫。
因爲使用追加字節的方式需要處理器讀取更多的字節到高速緩衝區,這本身就會帶來一定的性能消耗,共享變量如果不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加字節的方式來避免相互鎖定。
ps: 忽然覺得術業想專攻,博學與睿智缺一不可。
double/long 線程不安全
Java虛擬機規範定義的許多規則中的一條:所有對基本類型的操作,除了某些對long類型和double類型的操作之外,都是原子級的。
目前的JVM(java虛擬機)都是將32位作爲原子操作,並非64位。
當線程把主存中的 long/double類型的值讀到線程內存中時,可能是兩次32位值的寫操作,顯而易見,如果幾個線程同時操作,那麼就可能會出現高低2個32位值出錯的情況發生。
要在線程間共享long與double字段時,必須在synchronized中操作,或是聲明爲volatile。