JUC回顧之-volatile的原理和使用

 1.計算機內存模型的相關概念原理 

     計算機在執行程序時,每條指令都是在CPU中執行的,在指令的執行過程當中,涉及到數據的讀取和寫入。因爲程序在運行的過程當中數據是放在"主存"中的,html

因爲數據從主存中讀取數據和寫入數據要比CPU執行指令的速度慢的多,若是任什麼時候候對數據的操做都須要經過和主存進行交互,會大大下降指令的執行速度。java

所以在CPU處理器裏面有了高速緩存。c++

      也就是,當程序的運行過程當中,會將運算的須要的數據從主存複製一份到CPU的高速緩存中,那麼當CPU進行計算時就能夠直接從他的高速緩存讀取數據編程

和向高速緩存寫入數據,當運算以後將高速緩存中的數據刷新到主存中。api

下面是計算機中,數據緩存經過總線、緩存一致性協議在處理器CPU和內存之間的傳遞過程:緩存

 

 

2.緩存不一致問題解決

舉個例子說明:安全

    例如多線程

           int i= 0;併發

           i=i+1;oracle

        這段代碼在計算機中是如何計算的。

    當線程執行到這個語句的時候,會從主存中讀取數據i的值,而後複製一份到高速緩存中,而後CPU指令對i進行+1操做,而後將數據寫入到高速緩存中,最後將高速緩存中的i最新的值刷新到主存中。

        這個代碼在單線程中運行是沒有問題的,可是在多線程中運行就有問題了。在多核的CPU中,每條線程可能運行於不一樣的CPU中,所以每一個線程運行時有本身的高速緩存。

        假若有兩個線程A,B;初始的時候分別從主存中讀取i的值,而後放在各自所在的CPU高速緩存中,而後線程A進行+1操做,而後把i最新的值寫入到主存。此時線程B的高速緩存中i的值仍是0,進行+1操做,i的值爲1.而後線程B把i的值寫入到內存。最終i的值是1,而不是2.

       這就是緩存一致性問題。

也就是說,若是一個變量在多個CPU中都存在緩存(通常在多線程編程時纔會出現),那麼就可能存在緩存不一致的問題。

  爲了解決緩存不一致性問題,一般來講有如下2種解決方法:

  1)經過在總線加LOCK#鎖的方式

  2)經過緩存一致性協議

  這2種方式都是硬件層面上提供的方式。

      在早期的CPU當中,是經過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。由於CPU和其餘部件進行通訊都是經過總線來進行的,若是對總線加LOCK#鎖的話,也就是說阻塞了其餘CPU對其餘部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。好比上面例子中 若是一個線程在執行 i = i +1,若是在執行這段代碼的過程當中,在總線上發出了LOCK#鎖的信號,那麼只有等待這段代碼徹底執行完畢以後,其餘CPU才能從變量i所在的內存讀取變量,而後進行相應的操做。這樣就解決了緩存不一致的問題。

     可是上面的方式會有一個問題,因爲在鎖住總線期間,其餘CPU沒法訪問內存,致使效率低下。

     因此就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每一個緩存中使用的共享變量的副本是一致的。

     它核心的思想是:當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。 

3.深刻剖析volatile關鍵字原理

 一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾以後,那麼就具有了兩層含義

 volatile關鍵字的做用:

 * 可見性:能夠保證不一樣線程對這個變量的可見性,一旦某個線程修改了volatile變量的值,這個值對其餘線程是可見的。

 * 原子性:對單個volatile的讀和寫具備原子性,可是對volatile++這種複合的計數操做不具備原子性。不能用於線程安全計數器。 由於volatile++這種操做,實質上是由一個讀取-修改-寫入操做序列組成的組合操做。

看下面的代碼:

package concurrentMy.Volatiles;

public class VolatileFeaturesExample {
    
    int a = 0;
    volatile boolean  flag = true;
    
    public void writer(){
        a = 1; //1
        flag = true; //2
    }
    
    public void reader(){
        if(flag){  //3
            int i= a; //4
            System.out.println(i);
        }
    }
    

}

假設線程A執行writer方法後,線程B執行reader方法。

(1)從happens-before原則上來說,對volatile的寫操做必定happen-before對volatile的讀。也就是說上述代碼2 happens-before與3,根據程序的執行順序1 happens-before 2,3 happens-before 4。根據happens-before的傳遞性,1 happens-before 4.也就保證了線程A,寫入volatile flag 變量,當即對B線程可見。

(2)從JMM內存語義上來說,當寫一個volatile變量的時候,JMM會把該線程的對應的本地緩存中的共享變量值當即刷新到主內存中。當讀一個volatile共享變量時候,JMM會把該線程對應的本地緩存置爲無效,也就是上面緩存一致性說的會把該CPU線程對應的緩存行至爲無效。直接從主存中讀取。

   下圖爲線程A執行volatile flag寫後,共享變量的狀態示意圖:

   

從寫-讀的內存語義上來說,一個線程把共享的volatile寫入線程本地內存,而後在刷新到主內存,而後其餘線程從主內存中

讀取這個共享變量。這樣其實就實現了線程之間的通訊,經過主內存。

(1)線程A寫一個volatile變量,實質上是線程A向將要讀這個volatile變量的某個線程發出了消息。

(2)線程A寫一個volatile變量,而後線程B讀這個volatile變量,這個過程實質上是線程A經過主內存向線程B發送消息。

3.底層實現

 「觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令」

  lock前綴指令實際上至關於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

  1)它確保指令重排序時不會把其後面的指令排到內存屏障以前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操做已經所有完成;

  2)它會強制將對緩存的修改操做當即寫入主存;

  3)若是是寫操做,它會致使其餘CPU中對應的緩存行無效。

  編譯器不會對volatile變量的讀和讀後面的任意內存操做重排序;編譯器不會對volatile變量寫和寫前面任意內存操做作重排序。

 

4.鎖和volatile關鍵字的對比

     功能上鎖比volatile更強大,能夠保證操做的原子;而可伸縮性和執行的性能上volatile比鎖更有優點。

     volatile能夠當作一種"程度較輕的synchronized",與synchronized 塊相比,volatile變量的使用所需的編碼較少,而且運行開銷比較小。

     可是不能保證原子性,須要結合一些技術來保證,好比CAS。併發包下面的原子類,可重入鎖的實現就是經過volatile結合CAS來實現的。

5.開銷較低的讀-寫鎖策略:

 之因此將這種技術稱之爲 「開銷較低的讀-寫鎖」 是由於您使用了不一樣的同步機制進行讀寫操做。由於本例中的寫操做違反了使用 volatile 的第一個條件,

 所以不能使用 volatile 安全地實現計數器 —— 您必須使用鎖。然而,您能夠在讀操做中使用 volatile 確保當前值的可見性,所以可使用鎖進行全部

 變化的操做,使用 volatile 進行只讀操做。其中,鎖一次只容許一個線程訪問值,volatile 容許多個線程執行讀操做,所以當使用 volatile 保證讀代

 碼路徑時,要比使用鎖執行所有代碼路徑得到更高的共享度 —— 就像讀-寫操做同樣。然而,要隨時牢記這種模式的弱點:若是超越了該模式的最基本應用,

 結合這兩個競爭的同步機制將變得很是困難。

 

6.volatitle的使用場景:

經過關鍵字sychronize能夠防止多個線程進入同一段代碼,在某些特定的場景下中,volatitle至關於一個輕量級的sychronize,由於不會引發線程的上下文切換。可是volatitle的使用必須知足兩個條件:

1. 對變量的寫操做不依賴當前值,如多線程對共享變量執行i++操做,是沒法經過volatile保證結果的正確性的;

2.該變量沒有包含在具備其餘變量的不變式中,經過下面的例子來了解;參考例子(3)

下面看一組例子:多線程對共享變量++操做,單使用volatile變量的話,會出現線程安全的問題,會致使計數不對,下面經過幾種方法實現計數功能:

(1)加ReentrantLock互斥鎖保證原子性:

package concurrentMy.Volatiles;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 
 * (類型功能說明描述)
 *
 * <p>
 * 修改歷史:                                            <br>  
 * 修改日期            修改人員       版本             修改內容<br>  
 * -------------------------------------------------<br>  
 * 2016年4月8日 下午6:07:36   user     1.0        初始化建立<br>
 * </p> 
 *
 * @author        Peng.Li 
 * @version        1.0  
 * @since        JDK1.7
 */
public class VolatiteLock implements Runnable{
    // 不能保證原子性,若是不加synchronized的話
    private volatile int inc = 0;
    Lock lock = new ReentrantLock();
    

    /**
     * 
     * 理解:高速緩存 - 主存
     * 經過ReentrantLock保證原子性:讀主存,在高速緩存中計算獲得+1後的值,寫回主存
     * (方法說明描述) 
     *
     */
    public  void increase() {
        lock.lock();
        try {
            inc++;
        } catch (Exception e) {
            e.printStackTrace();
        }finally{
            lock.unlock();
        }

    }
    

    public void run() {
        for (int i = 0; i < 10000; i++) {
            increase();
        }

    }

    public static void main(String[] args) throws InterruptedException {

        VolatiteLock v = new VolatiteLock();
        // 線程1
        Thread t1 = new Thread(v);
        // 線程2
        Thread t2 = new Thread(v);
        t1.start();
        t2.start();

        // for(int i=0;i<100;i++){
        // System.out.println(i);
        // }

        System.out.println(Thread.activeCount() + Thread.currentThread().getId() + Thread.currentThread().getName());

        while (Thread.activeCount() > 1)
            // 保證前面的線程都執行完
            Thread.yield();
        
        //20000
        System.out.println(v.inc);
    }

}

 (2)使用原子類,原子自增操做,其底層實現,經過volatile+Cas保證原子性操做,保證讀-改-寫操做順序執行,不會發生線程安全的問題。

package concurrentMy.Volatiles;

import java.util.concurrent.atomic.AtomicInteger;

/**
 *     
 *     
 *     
 *  
 * <p>
 * 修改歷史:                                            <br>  
 * 修改日期            修改人員       版本             修改內容<br>  
 * -------------------------------------------------<br>  
 * 2015年7月14日 下午3:58:30   user     1.0        初始化建立<br>
 * </p> 
 *
 * @author        Peng.Li 
 * @version        1.0  
 * @since        JDK1.7
 */
public class VolatileAtomic implements Runnable {
    private AtomicInteger ai = new AtomicInteger(0);

    /**
     * atomic是利用CAS來實現原子性操做的(Compare And Swap),CAS其實是利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操做。
     * (方法說明描述) 
     *
     */
    public void increaseAtomic() {
        ai.incrementAndGet();
    }

    public void run() {
        for (int i = 0; i < 10000; i++) {
            increaseAtomic();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        VolatileAtomic v = new VolatileAtomic();
        // 線程1
        Thread t1 = new Thread(v);
        // 線程2
        Thread t2 = new Thread(v);
        t1.start();
        t2.start();

        // for(int i=0;i<100;i++){
        // System.out.println(i);
        // }

        System.out.println(Thread.activeCount() + Thread.currentThread().getId() + Thread.currentThread().getName());
        while (Thread.activeCount() > 1)
            // 保證前面的線程都執行完
            Thread.yield();

        System.out.println(v.ai);
    }

}

(3):對於「volatitle的使用場景2」的解釋以下:

 

public class NumberRange {
    private volatile int lower = 0; private volatile int upper = 10; public int getLower() { return lower; } public int getUpper() { return upper; } public void setLower(int value) { if (value > upper) throw new IllegalArgumentException(...); lower = value; } public void setUpper(int value) { if (value < lower) throw new IllegalArgumentException(...); upper = value; } }

上述代碼中,上下界初始化分別爲0和10,假設線程A和B在某一個時刻同時執行了setLower(8)和setUpper(5),且經過了檢查,那麼就設置了一個無效的範圍(8,5)

因此在這種場景下,須要經過lock保證setLower和setUpper在每個時刻只有一個線程執行;

下面是項目中常常用到的volatile關鍵字的兩個場景:

1.狀態標記量

在高併發的場景中,經過一個boolean類型的變量控制開關按鈕,控制代碼的邏輯開關(當即生效的開關),好比是否走促銷的邏輯,該如何實現?

public class SwitchControl {
    private volatile int isOpen; public void run() { if (isOpen) { //促銷邏輯 } else { //正常邏輯  } } public void setIsopen(boolean isopen) { this.isopen = isopen } }

這裏舉個例子說明了volatile的使用方法:用戶的請求線程執行到run方法的時候,若是開啓促銷活動,能夠經過mcc配置的開關開啓爲true,因爲isOpen是volatile修飾的,因此一經修改,其餘線程均可以拿到isOpen的最新值,在高併發下用戶的請求線程能夠執行到促銷的邏輯了;

 

2.單例的應用中double check檢查防止進行重排序

  單例模式不少人忽略寫volatile關鍵字,由於大部分狀況下沒有這個關鍵字,程序也很好的的運行,可是代碼穩定性不是100%,說不定在某個時刻,隱藏的bug就出來了,可能在高併發的狀況下,出現指令重排序,致使線程拿到的單例對象沒有初始化;

public class Singleton {
   private volatile static Singleton instance; private Singleton(){} public static Singleton getInstatance(){ if(instance == null){ // 0 若是不加這個if思考問題? synchronized(Singleton.class){ if(instance = null){ //1 instance = new Singleton(); //2 初始化單例類 } } } return instance; // 3 } }

 若是不加0處的if判斷,會致使多個線程頻繁調用 getInstance方法的時候,將會致使鎖競爭,致使性能開銷;因而想出了雙重檢查斷定來下降同步的開銷;

若是第一次檢查instance不爲null,那麼就不須要進行下面的加鎖和初始化操做了,所以能夠下降synchronize帶來的性能開銷;

1.多個線程試圖在同一個時間點建立對象,會經過加鎖來保證只有一個線程能建立對象。

2.在對象建立好以後,執行getInstance()方法將不須要獲取鎖,直接返回已經建立好的的對象。

思考:若是再2處的代碼不加volatile關鍵字,這個單例程序會不會有問題?

首先在理解下volatile內存的可見性,volatile的可見性是基於內存屏障實現的,什麼是內存屏障?內存屏障,是一個CPU的指令,在程序運行時,爲了提升執行的性能,編譯器和處理器會對指令進行重排序,JMM爲了保證不一樣不一樣編譯器和CPU上有相同的結果,經過插入特定類型的內存屏障來禁止特定類型的編譯器的重排序和處理器的重排序,插入一條內存屏障告訴編譯器和CPU;無論什麼指令都不能對這條內存屏障進行指令的重排序。若是再2處不加volatile關鍵字,因爲1出的代碼內部其實相似這樣的實現,對象的初始化過程實際上是這樣的:

instance = new Singleton(); //2 初始化單例類

分爲3個步驟:

*  1. memory = allocate(); 分配對象的內存空間,在堆上
* 2.ctorInstance(memory); 初始化對象
* 3.instance = memory; 設置instance指向剛分配的內存地址

 不加volatile可能致使上面3調語句的執行過程隨意進行重排序,即執行的過程多是123,或者是132;若是是132執行過程,假如A執行完3了,B線程也調用getInstance方法,根據0處的代碼if(instance == null)由於A線程給instance分配了內存地址,因此致使B線程認爲這個對象不爲null,直接返回了這個對象;可是這個對象尚未執行2,因此對象其實仍是未初始化。那麼程序就出現了問題。

若是加了volatile,會插入內存屏障,會禁止1,2,3步驟的重排序,不容許2,3進行重排序,那麼不會發生B訪問到的是一個未初始化的對象;

經過觀察volatile變量和普通變量的彙編代碼能夠發現,操做volatile變量多出了一個lock前綴指令:

Java代碼:
instance = new Singleton(); 彙編代碼: 0x01a3de1d: movb $0x0,0x1104800(%esi); 0x01a3de24: **lock** addl $0x0,(%esp);

這個lock前綴指令至關於內存屏障,提供瞭如下保證:

一、將當前CPU緩存行的數據寫會到主內存;

二、這個寫回內存的操做致使在其餘CPU裏面的緩存了該內存地址的數據無效;

CPU爲了提升性能,並不直接和內存進行通訊,而是將內存的數據讀取到內部緩存(L1,L2)再進行操做,可是操做完並不能肯定什麼時候寫回到內存,若是對volatile變量進行寫操做,當CPU執行到Lock前綴指令時候,會將這個變量的緩存行的數據寫回到主內存,不過仍是存在數據一致性的問題,就算內存是最新的,其餘CPU緩存仍是舊值,因此爲了保證各個CPU緩存的一致性,每一個CPU經過嗅探在總線上傳播的數據來檢查本身緩存數據的有效性,當發現本身的緩存行對應的內存地址的數據被修改,就會將該緩存行設置爲無效狀態,當CPU讀取變量時,發現緩存行被設置爲無效,就會從新到主內存讀取數據到緩存中。

 

補充第二種線程安全的延遲初始化方案(這個方案被稱爲 Initialization On Demand Holder idiom IODH):這種方案是經過JVM類的初始化期間獲取這個初始化鎖,而且每一個線程至少獲取一次鎖開確保這個類已經被初始化過了;這就保證在同一個時刻,A線程在調用getInstance方法初始化下面InstanceHolder類的時候,B線程是須要等待A初始化完後,拿到這個初始化鎖才能初始化話這個類,保證了B線程沒法看到Instance = new Instance(); 內部的3部初始化重排序過程,也就不會拿到一個未初始化的實例;下面給出第二種單例的寫法:

public class InstanceFactory{
    private static class InstanceHolder{
        public static Instance instance = new Instance();
   }
      
   public static Instance getInstance(){
       return InstanceHolder.instance;
  }
}

兩種線程安全延遲初始化方案的對比:

延遲初始化下降了初始化類或者建立實例的開銷,若是確實須要對實例字段採用線程安全的延遲初始化,基於volatile的延遲化方案;若是確實須要對靜態字段的使用線程安全的延遲初始化,那麼建議採用類初始化方案;

 

參考文章:1.http://www.ibm.com/developerworks/cn/java/j-jtp06197.html

              2.http://www.cnblogs.com/dolphin0520/p/3920373.html  

              3.http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/package-frame.html

              4 深刻淺出 Java Concurrency (5): 原子操做 part 4:http://www.blogjava.net/xylz/archive/2010/07/04/325206.html 

              5.狼哥:https://www.jianshu.com/p/195ae7c77afe

相關文章
相關標籤/搜索