Java高併發編程三--volatile使用及其實現原理

經過前面一章咱們瞭解了synchronized是一個重量級的鎖,雖然JVM對它作了不少優化,而下面介紹的volatile則是輕量級的synchronized。若是一個變量使用volatile,則它比使用synchronized的成本更加低,由於它不會引發線程上下文的切換和調度。Java語言規範對volatile的定義以下:html

Java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保經過排他鎖單獨得到這個變量。java

上面比較繞口,通俗點講就是說一個變量若是用volatile修飾了,則Java能夠確保全部線程看到這個變量的值是一致的,若是某個線程對volatile修飾的共享變量進行更新,那麼其餘線程能夠立馬看到這個更新,這就是所謂的線程可見性。volatile雖然看起來比較簡單,使用起來無非就是在一個變量前面加上volatile便可,可是要用好並不容易,下面會詳細講到.數據庫

一.操做系統語義

計算機在運行程序時,每條指令都是在CPU中執行的,在執行過程當中勢必會涉及到數據的讀寫。咱們知道程序運行的數據是存儲在主存中,這時就會有一個問題,讀寫主存中的數據沒有CPU中執行指令的速度快,若是任何的交互都須要與主存打交道則會大大影響效率,因此就有了CPU高速緩存。CPU高速緩存爲某個CPU獨有,只與在該CPU運行的線程有關。編程

有了CPU高速緩存雖然解決了效率問題,可是它會帶來一個新的問題:數據一致性。在程序運行中,會將運行所須要的數據複製一份到CPU高速緩存中,在進行運算時CPU再也不也主存打交道,而是直接從高速緩存中讀寫數據,只有當運行結束後纔會將數據刷新到主存中。舉一個簡單的例子:緩存

1安全

i++i++多線程

當線程運行這段代碼時,首先會從主存中讀取i( i = 1),而後複製一份到CPU高速緩存中,而後CPU執行 + 1 (2)的操做,而後將數據(2)寫入到告訴緩存中,最後刷新到主存中。其實這樣作在單線程中是沒有問題的,有問題的是在多線程中。以下:併發

假若有兩個線程A、B都執行這個操做(i++),按照咱們正常的邏輯思惟主存中的i值應該=3,但事實是這樣麼?分析以下:app

兩個線程從主存中讀取i的值(1)到各自的高速緩存中,而後線程A執行+1操做並將結果寫入高速緩存中,最後寫入主存中,此時主存i==2,線程B作一樣的操做,主存中的i仍然=2。因此最終結果爲2並非3。這種現象就是緩存一致性問題。編程語言

解決緩存一致性方案有兩種:

  1. 經過在總線加LOCK#鎖的方式
  2. 經過緩存一致性協議

可是方案1存在一個問題,它是採用一種獨佔的方式來實現的,即總線加LOCK#鎖的話,只能有一個CPU可以運行,其餘CPU都得阻塞,效率較爲低下。

第二種方案,緩存一致性協議(MESI協議)它確保每一個緩存中使用的共享變量的副本是一致的。其核心思想以下:當某個CPU在寫數據時,若是發現操做的變量是共享變量,則會通知其餘CPU告知該變量的緩存行是無效的,所以其餘CPU在讀取該變量時,發現其無效會從新從主存中加載數據。

二.Java內存模型

上面從操做系統層次闡述瞭如何保證數據一致性,下面咱們來看一下Java內存模型,稍微研究一下Java內存模型爲咱們提供了哪些保證以及在Java中提供了哪些方法和機制來讓咱們在進行多線程編程時可以保證程序執行的正確性。

在併發編程中咱們通常都會遇到這三個基本概念:原子性、可見性、有序性。咱們稍微看下volatile

1.原子性:即一個操做或者多個操做 要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。

原子性就像數據庫裏面的事務同樣,他們是一個團隊,同生共死。其實理解原子性很是簡單,咱們看下面一個簡單的例子便可:

i = 0;        //    ---1
j = i ;       //     ---2
i++;          //  ---3
i = j + 1;   // ---4
/*
上面四個操做,有哪一個幾個是原子操做,那幾個不是?
若是不是很理解,可能會認爲都是原子性操做,其實只有1纔是原子操做,其他均不是。

1—在Java中,對基本數據類型的變量和賦值操做都是原子性操做;
2—包含了兩個操做:讀取i,將i值賦值給j
3—包含了三個操做:讀取i值、i + 1 、將+1結果賦值給i;
4—同三同樣
*/

在單線程環境下咱們能夠認爲整個步驟都是原子性操做,可是在多線程環境下則不一樣,Java只保證了基本數據類型的變量和賦值操做纔是原子性的(注:在32位的JDK環境下,對64位數據的讀取不是原子性操做*,如long、double)。要想在多線程環境下保證原子性,則能夠經過鎖、synchronized來確保。

volatile是沒法保證複合操做的原子性

2.可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。

在上面已經分析了,在多線程環境下,一個線程對共享變量的操做對其餘線程是不可見的。

Java提供了volatile來保證可見性。

當一個變量被volatile修飾後,表示着線程本地內存無效,當一個線程修改共享變量後他會當即被更新到主內存中,當其餘線程讀取共享變量時,它會直接從主內存中讀取。
固然,synchronize和鎖均可以保證可見性。

3.有序性:即程序執行的順序按照代碼的前後順序執行。

在Java內存模型中,爲了效率是容許編譯器和處理器對指令進行重排序,固然重排序它不會影響單線程的運行結果,可是對多線程會有影響。

Java提供volatile來保證必定的有序性。最著名的例子就是單例模式裏面的DCL(雙重檢查鎖)。這裏LZ就再也不闡述了。

三.volatile的使用

要使 volatile 變量提供理想的線程安全,必須同時知足下面兩個條件:

  • 對變量的寫操做不依賴於當前值。
  • 該變量沒有包含在具備其餘變量的不變式中。

實際上,這些條件代表,能夠被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。

第一個條件的限制使 volatile 變量不能用做線程安全計數器。雖然增量操做(x++)看上去相似一個單獨操做,實際上它是一個由讀取-修改-寫入操做序列組成的組合操做,必須以原子方式執行,而 volatile 不能提供必須的原子特性。實現正確的操做須要使 x 的值在操做期間保持不變,而 volatile 變量沒法實現這點。(然而,若是將值調整爲只從單個線程寫入,那麼能夠忽略第一個條件。)

大多數編程情形都會與這兩個條件的其中之一衝突,使得 volatile 變量不能像 synchronized 那樣廣泛適用於實現線程安全。清單 1 顯示了一個非線程安全的數值範圍類。它包含了一個不變式 —— 下界老是小於或等於上界。

一、防止重排序

package com.zzl.concurrent;

public class Singleton {
    public static volatile Singleton singleton;

    /**
     * 構造函數私有,禁止外部實例化
     */
    private Singleton() {};

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

二、實現可見性

package com.zzl.test.concurrent;

public class VolatileTest {
    int a = 1;
    int b = 2;

    public void change(){
        a = 3;
        b = a;
    }

    public void print(){
        System.out.println("b="+b+";a="+a);
    }

    public static void main(String[] args) {
        while (true){
            final VolatileTest test = new VolatileTest();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();

        }
    }
}
/* 直觀上說,這段代碼的結果只可能有兩種: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變量對原子性保證,有一個問題容易被誤解。如今咱們就經過下列程序來演示一下這個問題:

package com.zzl.test.concurrent;

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後,這段程序就是線程安全的。
你們能夠嘗試運行上面的程序。下面是我本地運行的結果:
978

 可能每一個人運行的結果不相同。不過應該能看出,volatile是沒法保證原子性的(不然結果應該是1000)。緣由也很簡單,i++實際上是一個複合操做,包括三步驟:

  (1)讀取i的值。

  (2)對i加1。

  (3)將i的值寫回內存。

volatile是沒法保證這三個操做是具備原子性的,咱們能夠經過AtomicInteger或者Synchronized來保證+1操做的原子性。

注:上面幾段代碼中多處執行了Thread.sleep()方法,目的是爲了增長併發問題的產生概率,無其餘做用。

四.volatile原理

  一、可見性實現:

  在前文中已經說起過,線程自己並不直接與主內存進行數據的交互,而是經過線程的工做內存來完成相應的操做。這也是致使線程間數據不可見的本質緣由。所以要實現volatile變量的可見性,直接從這方面入手便可。對volatile變量的寫操做與普通變量的主要區別有兩點:

  (1)修改volatile變量時會強制將修改後的值刷新的主內存中。

  (2)修改volatile變量後會致使其餘線程工做內存中對應的變量值失效。所以,再讀取該變量值的時候就須要從新從讀取主內存中的值。

  經過這兩個操做,就能夠解決volatile變量的可見性問題。

       2. 禁止指令重排序:

在執行程序時爲了提升性能,編譯器和處理器一般會對指令作重排序:

  1. 編譯器重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序;
  2. 處理器重排序。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序;

指令重排序對單線程沒有什麼影響,他不會影響程序的運行結果,可是會影響多線程的正確性。既然指令重排序會影響到多線程執行的正確性,那麼咱們就須要禁止重排序。那麼JVM是如何禁止重排序的呢?這個問題稍後回答,咱們先看另外一個原則happens-before,happen-before原則保證了程序的「有序性」,它規定若是兩個操做的執行順序沒法從happens-before原則中推到出來,那麼他們就不能保證有序性,能夠隨意進行重排序。其定義以下:

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

咱們着重看第三點volatile規則:對volatile變量的寫操做 happen-before 後續的讀操做。爲了實現volatile內存語義,JMM會重排序,其規則以下:

這裏咱們主要看下第三條:volatile變量的保證有序性的規則。《Java併發編程:核心理論》一文中提到太重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM會對volatile變量限制這兩種類型的重排序。下面是JMM針對volatile變量所規定的重排序規則表:

  三、內存屏障

爲了實現volatile可見性和happen-befor的語義。JVM底層是經過一個叫作「內存屏障」的東西來完成。內存屏障,也叫作內存柵欄,是一組處理器指令,用於實現對內存操做的順序限制。下面是完成上述規則所要求的內存屏障:

Required barriers 2nd operation
1st operation Normal Load Normal Store Volatile Load Volatile Store
Normal Load       LoadStore
Normal Store       StoreStore
Volatile Load LoadLoad LoadStore LoadLoad LoadStore
Volatile Store     StoreLoad StoreStore

(1)LoadLoad 屏障
執行順序:Load1—>Loadload—>Load2
確保Load2及後續Load指令加載數據以前能訪問到Load1加載的數據。

(2)StoreStore 屏障
執行順序:Store1—>StoreStore—>Store2
確保Store2以及後續Store指令執行前,Store1操做的數據對其它處理器可見。

(3)LoadStore 屏障
執行順序: Load1—>LoadStore—>Store2
確保Store2和後續Store指令執行前,能夠訪問到Load1加載的數據。

(4)StoreLoad 屏障
執行順序: Store1—> StoreLoad—>Load2
確保Load2和後續的Load指令讀取以前,Store1的數據對其餘處理器是可見的。

最後我能夠經過一個實例來講明一下JVM中是如何插入內存屏障的:

package com.paddx.test.concurrent;

public class MemoryBarrier {
    int a, b;
    volatile int v, u;

    void f() {
        int i, j;

        i = a;
        j = b;
        i = v;
        //LoadLoad
        j = u;
        //LoadStore
        a = i;
        b = j;
        //StoreStore
        v = i;
        //StoreStore
        u = j;
        //StoreLoad
        i = u;
        //LoadLoad
        //LoadStore
        j = b;
        a = i;
    }
}

五:總結

整體上來講volatile的理解仍是比較困難的,若是不是特別理解,也不用急,徹底理解須要一個過程,在後續的文章中也還會屢次看到volatile的使用場景。這裏暫且對volatile的基礎知識和原來有一個基本的瞭解。整體來講,volatile是併發編程中的一種優化,在某些場景下能夠代替Synchronized。可是,volatile的不能徹底取代Synchronized的位置,只有在一些特殊的場景下,才能適用volatile。總的來講,必須同時知足下面兩個條件才能保證在併發環境的線程安全:

  (1)對變量的寫操做不依賴於當前值。

  (2)該變量沒有包含在具備其餘變量的不變式中。

相關文章
相關標籤/搜索