還怕面試官問你-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個字節寬。
共享變量不會被頻繁地寫。因爲使用追加字節的方式需要處理器讀取更多的字節到高速緩衝區,這本身就會帶來一定的性能消耗,如果共享變量不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加字節的方式來避免相互鎖定。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章