volatile詳解

volatile的3個特性:

  • 保證了各個線程之間的可見性
  • 不能保證原子性
  • 防止重排序

可見性:

首先,每一個線程都有本身的工做內存,除此以外還有一個cpu的主存,工做內存是主存的副本。線程工做的時候,不能直接操做主內存中的值,而是要將主存的值拷貝到本身的工做內存中;在修改變量是,會先在工做內存中修改,隨後刷新到主存中。java

注意: 何時線程須要將主存中的值拷貝到工做內存編程

  • 線程中釋放鎖的時
  • 線程切換時
  • CPU有空閒時間時(好比線程休眠時)

假設有一個共享變量flag爲false,線程a修改成true後,本身的工做內存修改了,也刷新到了主存。這時候線程b對flag進行對應操做時,是不知道a修改了的,也稱a對b不可見。因此咱們須要一種機制,在主存的值修改後,及時地通知全部線程,保證它們均可以看到這個變化。ide

public class ReadWriteDemo {
    
    //對於flag並無加volatile
    public boolean flag = false;
    public void change() {
        flag = true;
        System.out.println("flag has changed:" + flag);
    }

    public static void main(String[] args) {

        ReadWriteDemo readWriteDemo = new ReadWriteDemo();
        //建立一個線程,用來修改flag,如上面描述的a線程
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000);
                    readWriteDemo.change();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

        //主線程,如上面描述的b線程
        while(!readWriteDemo.flag) {
        }
        System.out.println("flag:" + readWriteDemo.flag);
    }

}

按照分析,沒有加volatile的話,主線程(b線程)是看不到子線程(a線程)修改了flag的值。也就是說,在主線程看來,在沒有特殊狀況下,flag 永遠爲false, while(!readWriteDemo.flag) {}的判斷條件爲true,系統不會執行到System.out.println("flag:" + readWriteDemo.flag);優化

爲了不偶然性,我讓程序跑了6分鐘。能夠看到,子線程確實修改了flag的值,主線程也和咱們預期同樣,看不到flag的變化,一直在死循環。若是給flag變量加一個volatile呢,預期結果是,子線程修改變量對主線程來講是可見的,主線程會退出循環。spa

能夠看到,都不到一分鐘,在子線程修改flag的值後,主線程隨即就退出循環,說明馬上感知到了flag變量的變化。線程

有趣的是什麼呢:若是ab兩個線程間隔時間不長,當b線程也延遲10s讀(不是上面的馬上讀),你會發現兩個線程之間的修改也是可見的,爲何呢,stakc overflow上有解答,執行該線程的cpu有空閒時,會去主存讀取如下共享變量來更新工做內存中的值。更有趣的是,在寫這篇文章的時候,cpu及內存是這樣的,反而能正常執行,可是能出現問題就能說明volatile的做用。code

image-20210409163242085

如何保證可見性:

首先要先講一下java內存模型,java的的內存模型規定了工做內存與主存之間交互的協議,定義了8中原子操做:排序

  1. lock:將主內存的變量鎖定,爲一個線程所獨佔。
  2. unlock:將lock加的鎖定解除,此時其餘線程能夠有機會訪問此變量。
  3. read:將主內存中的變量值讀到工做線程中。
  4. load:將read讀取到的值保存到工做內存中的變量副本中。
  5. use:將值傳遞給線程的代碼執行引擎。
  6. assign:將執行引擎處理返回的值從新賦值給變量副本。
  7. store:將變量副本的值存儲到主內存中。
  8. write:將store存儲的值寫入到主內存的共享變量中。

我上網查了下資料,也看了不一樣的博客,有講到volatile其實在底層就是加了一個lock的前綴指令。lock前綴的指令要幹什麼上面也有寫。若是對帶有volatile的變量進行寫操做會怎麼呢。JVM會像處理器發送一條lock前綴的指令,a線程就鎖定主存內的變量,修改後再刷新到主存。b線程一樣會鎖定主存內的變量,可是會發現主存內的變量和工做內存的值不同,就會從主存中讀取最新的值。從而保證了每一個線程都能對變量的改變可見。內存

原子性:

在編程世界裏面,原子性是指不能分割的操做,一個操做要麼所有執行,要麼所有不執行,是執行的最小單元。rem

public class TestAutomic {
    volatile int num = 0;
    void add() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        TestAutomic testAutomic = new TestAutomic();
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                        testAutomic.add();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
        //等待12秒,讓子線程所有執行完
        Thread.sleep(12000);
        System.out.println(testAutomic.num);
    }

}

預期現象:都說不能保證原子性了,因此,應該結果是不等於1000

不一樣電腦執行的結果不同,個人是886,可能大家的不是,可是都說明了volatile都沒法保證操做的原子性。

爲何不能保證原子性:

這要從num++操做開始講起,num++操做能夠分爲三步:

  • 讀取i的值,裝載進工做內存
  • 對i加1操做
  • 將i的值寫回工做內存,刷新到主存中

咱們知道線程的執行具備隨機性,假設a線程和b線程中的工做內存中都是num=0,a線程先搶了cpu的執行權,在工做內存進行了加1操做,還沒刷新到主存中;b線程這時候拿到了cpu的執行權,也加1;接着a線程刷新到主存num=1,而b線程刷新到主存,一樣是num=1,可是兩次操做後num應該等於2。

解決方案:

  • 使用synchronized關鍵字
  • 使用原子類

重排序:

對於咱們寫的程序,cpu會根據如何讓程序更高效來對指令經行重排序,什麼意思呢

a = 2;
b = new B();
c = 3;
d = new D();

通過優化後,可能真實的指令順序是:

a = 2;
c = 3;
b = new B();
d = new D();

並非全部的指令都會重排序,重排序與否全是看能不能使得指令更高效,還有下面一種狀況。

a = 2;
b = a;

這兩行代碼不管什麼狀況下都不會重排序,由於第二條指令是依賴第一條指令的,重排序是創建在排序後最終結果仍然保持不變的基礎上。下面將給出volatile防止重排序的例子:

public class TestReorder {
    private static int a = 0, b = 0, x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            a = 0; b = 0; x = 0; y = 0;
            //a線程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                        a = 1;
                        x = b;
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                }
            }).start();

            //b線程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                        b = 1;
                        y = a;
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                }
            }).start();

            //主線程睡100ms,以保證子線程所有執行完
            Thread.sleep(100);
            System.out.println("a=" + a + ";b=" + b + ";x=" + x + ";y=" + y);

        }
    }

}

還記得上面說過兩個線程若是沉睡時間差很少,它們之間是可見

預期結果:

  • 若是先執行a線程(a = 1, x = b = 0),再執行b線程(b = 1, y = a = 1),最終結果a = 1; b = 1; x = 0; y = 1
  • 若是先執行b線程(b = 1, y = a = 0),再執行a線程(a = 1, x = b = 1),最終結果a = 1; b = 1; x = 1; y = 0
  • 若是執行a線程過程(a = 1),接着執行了b線程(b = 1,y = a = 1)【爲何y = a必定等於1,由於它們兩個之間的改變是可見的】,最後執行了a線程(x = b = 1),最終結果a = 1;b = 1; x = 1; y = 1

能夠發現除了上面預期的三種狀況,還出現了一種a = 1; b = 1; x = 0; y = 0的狀況,相信你們也知道了,這種狀況就是由於重排序形成的。要麼是a線程重排序先執行x = b;再執行a = 1;,要麼是b線程重排序先執行了y = a;再執行了b = 1;;要麼是兩個線程都重排序了。

若是private volatile static int a = 0, b = 0, x = 0, y = 0;加了volatile關鍵字會怎麼樣呢?

爲了保證正確性,又持續跑了5分鐘,能夠發現,確實不會再出現x=0;y=0的狀況。

如何防止重排序

先來說講4個內存屏障的做用

內存屏障 做用
StoreStore屏障 禁止上面的普通寫和下面的的volatile寫重排序
StoreLoad屏障 禁止上面的volatile寫和下面volatile讀/寫重排序
LoadLoad屏障 禁止下面的普通讀和上面的volatile讀重排序
LoadStore屏障 禁止下面的普通寫和上面的volatile讀重排序

可能看做用比較抽象,直接舉例子叭

  • 對於S1; StoreStore; S2,在S2及後續寫入操做以前,保證S1的寫入操做對其它線程可見。
  • 對於S; StoreLoad; L,在L及後續讀/寫操做以前,保證S的寫入對其它線程可見。
  • 對於L1; LoadLoad; L2,在L2及後續讀操做以前,保證L1讀取數據完畢。
  • 對於L; LoadStore; S,在S及後續操做以前,保證L讀取數據完畢。

那麼volatile是如何保證有序性的呢?

  • 在每一個volatile寫操做前插入StoreStore屏障,每一個寫操做後面加一個StoreLoad屏障。
  • 在每一個volatile讀操做前插入LoadLoad屏障,在讀操做後插入LoadStore屏障。

舉例,有個對volatile變量的寫S,有個對volatile變量的讀L,會怎麼樣呢。

  • 對於寫:S1; StoreStore; S ;StoreLoad L這樣可以把S(對volatile變量保護在中間)防止重排序。
  • 對於讀同樣的道理:L1; LoadLoad; L ; LoadStore S,同樣把volatile變量保護的好好的。

有關volatile的講解就到這裏了。

相關文章
相關標籤/搜索