volatile的底層實現原理
CPU的術語定義
volatile是輕量級的synchronized,比之執行成本更低,因為它不會引起執行緒的上下文切換,它在多處理器開發中保證了共享變數的“可見性”,“可見性”的意思是當一個執行緒修改一個變數時,另外一個執行緒能讀到這個修改的值。
volatile的定義和原理
Java語言規範第三版中對volatile的定義如下: java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該確保通過排他鎖單獨獲得這個變數。Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個欄位被宣告成volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
package com.own.learn.concurrent.Volatile;
/**
* java -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*com.own.learn.concurrent.Volatile.com.own.learn.concurrent.VolatileBarrierExample
*/
public class VolatileBarrierExample {
volatile Long v1 = null;
public static void main(String[] args) {
VolatileBarrierExample ex = new VolatileBarrierExample();
ex.readAndWrite();
}
void readAndWrite() {
v1 = 1L;
}
}
輸出彙編程式碼:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*VolatileBarrierExample.readAndWrite -XX:CompileCommand=compileonly,*VolatileBarrierExample.readAndWrite com.own.learn.concurrent.Volatile.VolatileBarrierExample
可以看到v1 = 1L;
可以找到
0x00007f55cd100684: mov 0x20(%rsp),%rsi
0x00007f55cd100689: mov %rax,%r10
0x00007f55cd10068c: shr $0x3,%r10
0x00007f55cd100690: mov %r10d,0xc(%rsi)
0x00007f55cd100694: shr $0x9,%rsi
0x00007f55cd100698: movabs $0x7f55dd1cb000,%rdi
0x00007f55cd1006a2: movb $0x0,(%rsi,%rdi,1)
0x00007f55cd1006a6: lock addl $0x0,(%rsp)
通過查IA-32架構軟體開發者手冊可知,lock字首的指令在多核處理器下會引發了兩件事情。
- 將當前處理器快取行的資料會寫回到系統記憶體。
Lock字首指令導致在執行指令期間,聲言處理器的 LOCK# 訊號。在多處理器環境中,LOCK# 訊號確保在聲言該訊號期間,處理器可以獨佔使用任何共享記憶體。(因為它會鎖住匯流排,導致其他CPU不能訪問匯流排,不能訪問匯流排就意味著不能訪問系統記憶體),但是在最近的處理器裡,LOCK#訊號一般不鎖匯流排,而是鎖快取,畢竟鎖匯流排開銷比較大。在8.1.4章節有詳細說明鎖定操作對處理器快取的影響,對於Intel486和Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#訊號。但在P6和最近的處理器中,如果訪問的記憶體區域已經快取在處理器內部,則不會聲言LOCK#訊號。相反地,它會鎖定這塊記憶體區域的快取並回寫到記憶體,並使用快取一致性機制來確保修改的原子性,此操作被稱為“快取鎖定”,快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料。 ps: CPU的位數指的是資料匯流排位數,而決定最大支援記憶體的則是地址匯流排位數。
這個寫回記憶體的操作會引起在其他CPU裡快取了該記憶體地址的資料無效。 IA-32處理器和Intel 64處理器使用MESI(修改,獨佔,共享,無效)控制協議去維護內部快取和其他處理器快取的一致性。在多核處理器系統中進行操作的時候,IA-32 和Intel 64處理器能嗅探其他處理器訪問系統記憶體和它們的內部快取。它們使用嗅探技術保證它的內部快取,系統記憶體和其他處理器的快取的資料在總線上保持一致。例如在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫記憶體地址,而這個地址當前處理共享狀態,那麼正在嗅探的處理器將無效它的快取行,在下次訪問相同記憶體地址時,強制執行快取行填充。
volatile的可見性分析
package com.own.learn.concurrent.Volatile;
public class VolatileVisibilityTest2 {
public volatile boolean flag = false;
public static void main(String[] args) {
final VolatileVisibilityTest2 volatileVisibilityTest2 = new VolatileVisibilityTest2();
new Thread(() -> {
try {
Thread.sleep(10);
} catch (Exception e) {
e.printStackTrace();
}
volatileVisibilityTest2.flag = true;
}).start();
new Thread(() -> {
while (!volatileVisibilityTest2.flag) {
}
System.out.println(" 2 " + true);
}).start();
}
}
主執行緒定義了一個flag變數,兩個子執行緒相互修改是可見的。 執行緒本身並不直接與主記憶體進行資料的互動,而是通過執行緒的工作記憶體來完成相應的操作。這也是導致執行緒間資料不可見的本質原因。因此要實現volatile變數的可見性,直接從這方面入手即可。對volatile變數的寫操作與普通變數的主要區別有兩點: (1)修改volatile變數時會強制將修改後的值重新整理的主記憶體中。 (2)修改volatile變數後會導致其他執行緒工作記憶體中對應的變數值失效。因此,再讀取該變數值的時候就需要重新從讀取主記憶體中的值。 通過這兩個操作,就可以解決volatile變數的可見性問題。
原子性
volatile只能保證對單次讀/寫的原子性。因為long和double兩種資料型別的操作可分為高32位和低32位兩部分,因此普通的long或double型別讀/寫可能不是原子的。因此,鼓勵大家將共享的long和double變數設定為volatile型別,這樣能保證任何情況下對long和double的單次讀/寫操作都具有原子性。
package com.own.learn.concurrent.Volatile;
public class VolatileActorTest {
volatile int i;
public void addI() {
i++;
}
public static void main(String[] args) throws Exception {
VolatileActorTest volatileActorTest = new VolatileActorTest();
for (int i=0; i< 100; i++) {
new Thread(new Runnable() {
@Override
public void run() {
volatileActorTest.addI();
}
}).start();
}
Thread.sleep(1000);//等待10秒,保證上面程式執行完成
System.out.println(volatileActorTest.i);
}
}
多執行幾次發現,結果不一定是100.
防重排序
public class VolatileSingleTest {
volatile static B b = null;
public synchronized void getB() {
if (b == null) {
synchronized (VolatileSingleTest.class) {
if (null == b) {
b = new B();
}
}
}
}
class B {
}
}
b = new B();其實發生了三件事: memory = allocate(); //1:為物件分配記憶體空間 ctorInstance(memory) /:2 :初始化物件 instance = memory;//3 : 設定instance指向剛分配的記憶體地址 其中,volatile擔心2和3重排了
volatile的使用優化
佇列集合類LinkedTransferQueue,在使用volatile變數時,追加64位元組的方式來優化隊列出隊和入隊的效能
/** 佇列中的頭部節點 */
private transient final PaddedAtomicReference<QNode> head;
/** 佇列中的尾部節點 */
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference <T> extends AtomicReference T> {
// 使用很多4個位元組的引用追加到64個位元組
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;
// 省略其他程式碼
}
追加位元組能優化效能?這種方式看起來很神奇,但如果深入理解處理器架構就能理解其中的奧祕。讓我們先來看看LinkedTransferQueue這個類,它使用一個內部類型別來定義佇列的頭節點(head)和尾節點(tail),而這個內部類PaddedAtomicReference相對於父類AtomicReference只做了一件事情,就是將共享變數追加到64位元組。我們可以來計算下,一個物件的引用佔4個位元組,它追加了15個變數(共佔60個位元組),再加上父類的value變數,一共64個位元組。 為什麼追加64位元組能夠提高併發程式設計的效率呢?因為對於英特爾酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L1、L2或L3快取的快取記憶體行是64個位元組寬,不支援部分填充快取行,這意味著,如果佇列的頭節點和尾節點都不足64位元組的話,處理器會將它們都讀到同一個快取記憶體行中,在多處理器下每個處理器都會快取同樣的頭、尾節點,當一個處理器試圖修改頭節點時,會將整個快取行鎖定,那麼在快取一致性機制的作用下,會導致其他處理器不能訪問自己快取記憶體中的尾節點,而佇列的入隊和出隊操作則需要不停修改頭節點和尾節點,所以在多處理器的情況下將會嚴重影響到佇列的入隊和出隊效率。Doug lea使用追加到64位元組的方式來填滿高速緩衝區的快取行,避免頭節點和尾節點載入到同一個快取行,使頭、尾節點在修改時不會互相鎖定。 那麼是不是在使用volatile變數時都應該追加到64位元組呢?不是的。在兩種場景下不應該使用這種方式。
快取行非64位元組寬的處理器。如P6系列和奔騰處理器,它們的L1和L2快取記憶體行是32個位元組寬。 共享變數不會被頻繁地寫。因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,如果共享變數不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。