volatile的使用及其原理

一、 volatile的做用

相比Sychronized(重量級鎖,對系統性能影響較大),volatile提供了另外一種解決 可見性和有序性 ???問題的方案。對於原子性,須要強調一點,也是你們容易誤解的一點:對volatile變量的單次讀/寫操做能夠保證原子性的,如long和double類型變量,可是並不能保證i++這種操做的原子性,由於本質上 i++ 是讀、寫兩次操做html

二、volatile的使用

一、防重排序

咱們從一個最經典的例子來分析 重排序問題???。你們應該都很熟悉 單例模式 的實現,而在併發環境下的單例實現方式,咱們一般能夠採用  雙重檢查加鎖(DCL) ???的方式來實現。其源碼以下:java

 1 public class Singleton {
 2     public static volatile Singleton singleton;
 3     /**
 4      * 構造函數私有,禁止外部實例化
 5      */
 6     private Singleton() {};
 7     public static Singleton getInstance() {
 8         if (singleton == null) {
 9             synchronized (singleton) {
10                 if (singleton == null) {
11                     singleton = new Singleton();
12                 }
13             }
14         }
15         return singleton;
16     }
17 }

如今咱們分析一下爲何要在變量singleton之間加上volatile關鍵字。要理解這個問題,先要了解對象的構造過程,實例化一個對象其實能夠分爲三個步驟: 
  (1)分配內存空間。 
  (2)初始化對象。 
  (3)將內存空間的地址賦值給對應的引用。 
可是因爲操做系統能夠對指令進行重排序,因此上面的過程也可能會變成以下過程: 
  (1)分配內存空間。 
  (2)將內存空間的地址賦值給對應的引用。 
  (3)初始化對象 
  若是是這個流程,多線程環境下就可能將一個未初始化的對象引用暴露出來,從而致使不可預料的結果。所以,爲了防止這個過程的重排序,咱們須要將變量設置爲volatile類型的變量。編程

二、實現可見性

可見性問題主要指一個線程修改了共享變量值,而另外一個線程卻看不到。引發可見性問題的主要緣由是每一個線程擁有本身的一個高速緩存區——線程工做內存。volatile關鍵字能有效的解決這個問題,咱們看下下面的例子,就能夠知道其做用:緩存

 

 1 public class VolatileTest {
 2     int a = 1;
 3     int b = 2;
 4 
 5     public void change(){
 6         a = 3;
 7         b = a;
 8     }
 9 
10     public void print(){
11         System.out.println("b="+b+";a="+a);
12     }
13 
14     public static void main(String[] args) {
15         while (true){
16             final VolatileTest test = new VolatileTest();
17             new Thread(new Runnable() {
18                 @Override
19                 public void run() {
20                     try {
21                         Thread.sleep(10);
22                     } catch (InterruptedException e) {
23                         e.printStackTrace();
24                     }
25                     test.change();
26                 }
27             }).start();
28             new Thread(new Runnable() {
29                 @Override
30                 public void run() {
31                     try {
32                         Thread.sleep(10);
33                     } catch (InterruptedException e) {
34                         e.printStackTrace();
35                     }
36                     test.print();
37                 }
38             }).start();
39         }
40     }
41 }

直觀上說,這段代碼的結果只可能有兩種:b=3;a=3 或 b=2;a=1。不過運行上面的代碼(可能時間上要長一點),你會發現除了上兩種結果以外,還出現了第三種結果:安全

...... 
b=2;a=1
b=2;a=1
b=3;a=3
b=3;a=3
b=3;a=1
b=3;a=3
b=2;a=1
b=3;a=3
b=3;a=3
......

爲何會出現b=3;a=1這種結果呢?正常狀況下,若是先執行change方法,再執行print方法,輸出結果應該爲b=3;a=3。相反,若是先執行的print方法,再執行change方法,結果應該是 b=2;a=1。那b=3;a=1的結果是怎麼出來的?緣由就是第一個線程將值a=3修改後,可是對第二個線程是不可見的,因此纔出現這一結果。若是將a和b都改爲volatile類型的變量再執行,則不再會出現b=3;a=1的結果了。多線程

三、保證原子性

關於原子性的問題,上面已經解釋過。volatile只能保證對單次讀/寫的原子性。這個問題能夠看下JLS中的描述:架構

17.7 Non-Atomic Treatment of double and long 
For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write. 
Writes and reads of volatile long and double values are always atomic. 
Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values. 
Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts. 
Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.併發

這段話的內容跟我前面的描述內容大體相似。由於long和double兩種數據類型的操做可分爲高32位和低32位兩部分,所以普通的long或double類型讀/寫可能不是原子的。所以,鼓勵你們將共享的long和double變量設置爲volatile類型,這樣能保證任何狀況下對long和double的單次讀/寫操做都具備原子性。 
  關於volatile變量對原子性保證,有一個問題容易被誤解。如今咱們就經過下列程序來演示一下這個問題:app

public class VolatileTest01 {
    volatile int i;

    public void addI(){
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        final  VolatileTest01 test01 = new VolatileTest01();
        for (int n = 0; n < 1000; n++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test01.addI();
                }
            }).start();
        }
        Thread.sleep(10000);//等待10秒,保證上面程序執行完成
        System.out.println(test01.i);
    }
}

你們可能會誤認爲對變量i加上關鍵字volatile後,這段程序就是線程安全的。你們能夠嘗試運行上面的程序。下面是我本地運行的結果:981 
可能每一個人運行的結果不相同。不過應該能看出,volatile是沒法保證原子性的(不然結果應該是1000)。緣由也很簡單,i++實際上是一個複合操做,包括三步驟: 
  (1)讀取i的值。 
  (2)對i加1。 
  (3)將i的值寫回內存。 
volatile是沒法保證這三個操做是具備原子性的,咱們能夠經過 AtomicInteger 或者 Synchronized 來保證+1操做的原子性。 
注:上面幾段代碼中多處執行了Thread.sleep()方法,目的是爲了增長併發問題的產生概率,無其餘做用。less

三、volatile的原理

經過上面的例子,咱們基本應該知道了volatile是什麼以及怎麼使用。如今咱們再來看看volatile的底層是怎麼實現的。

一、可見性實現:

  在前文中已經說起過,線程自己並不直接與主內存進行數據的交互,而是經過線程的工做內存來完成相應的操做。這也是致使線程間數據不可見的本質緣由。所以要實現volatile變量的可見性,直接從這方面入手便可。對volatile變量的寫操做與普通變量的主要區別有兩點: 
  (1)修改volatile變量時會強制將修改後的值刷新的主內存中。 
  (2)修改volatile變量後會致使其餘線程工做內存中對應的變量值失效。所以,再讀取該變量值的時候就須要從新從讀取主內存中的值。 
  經過這兩個操做,就能夠解決volatile變量的可見性問題。

二、有序性實現:

  在解釋這個問題前,咱們先來了解一下Java中的happen-before規則,JSR 133中對Happen-before的定義以下:

Two actions can be ordered by a happens-before relationship.If one action happens before another, then the first is visible to and ordered before the second.

通俗一點說就是若是a happen-before b,則a所作的任何操做對b是可見的。(這一點你們務必記住,由於happen-before這個詞容易被誤解爲是時間的先後)。咱們再來看看JSR 133中定義了哪些happen-before規則:

• Each action in a thread happens before every subsequent action in that thread. 
• An unlock on a monitor happens before every subsequent lock on that monitor. 
• A write to a volatile field happens before every subsequent read of that volatile. 
• A call to start() on a thread happens before any actions in the started thread. 
• All actions in a thread happen before any other thread successfully returns from a join() on that thread. 
• If an action a happens before an action b, and b happens before an action c, then a happens before c.

翻譯過來爲:

  • 同一個線程中的,前面的操做 happen-before 後續的操做。(即單線程內按代碼順序執行。可是,在不影響在單線程環境執行結果的前提下,編譯器和處理器能夠進行重排序,這是合法的。換句話說,這一是規則沒法保證編譯重排和指令重排)。
  • 監視器上的解鎖操做 happen-before 其後續的加鎖操做。(Synchronized 規則)
  • 對volatile變量的寫操做 happen-before 後續的讀操做。(volatile 規則)
  • 線程的start() 方法 happen-before 該線程全部的後續操做。(線程啓動規則)
  • 線程全部的操做 happen-before 其餘線程在該線程上調用 join 返回成功後的操做。
  • 若是 a happen-before b,b happen-before c,則a happen-before c(傳遞性)。

四、volatile的使用優化

著名的Java併發編程大師Doug lea在JDK 7的併發包裏新增一個隊列集合類LinkedTransferQueue,它在使用volatile變量時,用一種追加字節的方式來優化隊列出隊和入隊的性能。LinkedTransferQueue的代碼以下:

/** 隊列中的頭部節點 */
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字節可以提升併發編程的效率呢?由於對於英特爾酷睿i七、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L一、L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味着,若是隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每一個處理器都會緩存一樣的頭、尾節點,當一個處理器試圖修改頭節點時,會將整個緩存行鎖定,那麼在緩存一致性機制的做用下,會致使其餘處理器不能訪問本身高速緩存中的尾節點,而隊列的入隊和出隊操做則須要不停修改頭節點和尾節點,因此在多處理器的狀況下將會嚴重影響到隊列的入隊和出隊效率。Doug lea使用追加到64字節的方式來填滿高速緩衝區的緩存行,避免頭節點和尾節點加載到同一個緩存行,使頭、尾節點在修改時不會互相鎖定。 
那麼是否是在使用volatile變量時都應該追加到64字節呢?不是的。在兩種場景下不該該使用這種方式。

  • 緩存行非64字節寬的處理器。如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個字節寬。
  • 共享變量不會被頻繁地寫。由於使用追加字節的方式須要處理器讀取更多的字節到高速緩衝區,這自己就會帶來必定的性能消耗,若是共享變量不被頻繁寫的話,鎖的概率也很是小,就不必經過追加字節的方式來避免相互鎖定。

參考: 
Java 併發編程:volatile的使用及其原理 
就是要你懂Java中volatile關鍵字實現原理

參考文檔: https://blog.csdn.net/devotion987/article/details/68486942

相關文章
相關標籤/搜索