Java基礎之volatile詳解

2022-07-06 14:00:20
本篇文章給大家帶來了關於的相關知識,其中主要整理了volatile的相關問題,包括了volatile保證可見性、volatile不保證原子性、volatile禁止指令重排等等內容,下面一起來看一下,希望對大家有幫助。

推薦學習:《》

問:請談談你對volatile的理解?
答:volatile是Java虛擬機器器提供的輕量級的同步機制,它有3個特性
1)保證可見性
2)不保證原子性
3)禁止指令重排

剛學完java基礎,如果有人問你什麼是volatile?它有什麼作用的話,相信一定非常懵逼…
可能看了答案,也完全不明白,什麼是同步機制?什麼是可見性?什麼是原子性?什麼是指令重排?

1、volatile保證可見性

1.1、什麼是JMM模型?

要想理解什麼是可見性,首先要先理解JMM

JMM(Java記憶體模型,Java Memory Model)本身是一種抽象的概念,並不真實存在。它描述的是一組規則或規範,通過這組規範,定了程式中各個變數的存取方法。JMM關於同步的規定:
1)執行緒解鎖前,必須把共用變數的值重新整理回主記憶體;
2)執行緒加鎖前,必須讀取主記憶體的最新值到自己的工作記憶體;
3)加鎖解鎖是同一把鎖;

由於JVM執行程式的實體是執行緒,建立每個執行緒時,JMM會為其建立一個工作記憶體(有些地方稱為棧空間),工作記憶體是每個執行緒的私有資料區域。

Java記憶體模型規定所有變數都儲存在主記憶體,主記憶體是共用記憶體區域,所有執行緒都可以存取。

但執行緒對變數的操作(讀取、賦值等)必須在工作記憶體中進行。因此首先要將變數從主記憶體拷貝到自己的工作記憶體,然後對變數進行操作,操作完成後再將變數寫會主記憶體中。

看了上面對JMM的介紹,可能還是優點懵,接下來用一個賣票系統來進行舉例:

1)如下圖,此時賣票系統後端只剩下1張票,並已讀入主記憶體中:ticketNum=1。
2)此時網路上有多個使用者都在搶票,那麼此時就有多個執行緒同時都在進行買票服務,假設此時有3個執行緒都讀入了目前的票數:ticketNum=1,那麼接著就會買票。
3)假設執行緒1先搶佔到cpu的資源,先買好票,並在自己的工作記憶體中將ticketNum的值改為0:ticketNum=0,然後再寫回到主記憶體中。

此時,執行緒1的使用者已經買到票了,那麼執行緒2,執行緒3此時應該不能再繼續買票了,因此需要系統通知執行緒2,執行緒3,ticketNum此時已經等於0了:ticketNum=0。如果有這樣的通知操作,你就可以理解為就具有可見性

在這裡插入圖片描述

通過上面對JMM的介紹和舉例,可以簡單總結下。

JMM記憶體模型的可見性是指,多執行緒存取主記憶體的某一個資源時,如果某一個執行緒在自己的工作記憶體中修改了該資源,並寫回主記憶體,那麼JMM記憶體模型應該要通知其他執行緒來從新獲取最新的資源,來保證最新資源的可見性

1.2、volatile保證可見性的程式碼驗證

在1.1中,已經基本理解了可見性的含義,接下來用程式碼來驗證一下,volatile確實可以保證可見性。

1.2.1、無可見性程式碼驗證

首先先驗證下,不使用volatile,是不是就是沒有可見性。

package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{
    int number = 0;

    public void add10() {
        this.number += 10;
    }}public class VolatileVisibilityDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 啟動一個執行緒修改myData的number,將number的值加10
        new Thread(
                () -> {
                    System.out.println("執行緒" + Thread.currentThread().getName()+"\t 正在執行");
                    try{
                        TimeUnit.SECONDS.sleep(3);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    myData.add10();
                    System.out.println("執行緒" + Thread.currentThread().getName()+"\t 更新後,number的值為" + myData.number);
                }
        ).start();

        // 看一下主執行緒能否保持可見性
        while (myData.number == 0) {
            // 當上面的執行緒將number加10後,如果有可見性的話,那麼就會跳出迴圈;
            // 如果沒有可見性的話,就會一直在迴圈裡執行
        }

        System.out.println("具有可見性!");
    }}

執行結果如下圖,可以看到雖然執行緒0已經將number的值改為了10,但是主執行緒還是在迴圈中,因為此時number不具有可見性,系統不會主動通知。
在這裡插入圖片描述

1.2.1、volatile保證可見性驗證

在上面程式碼的第7行給變數number新增volatile後再次測試,如下圖,此時主執行緒成功退出了迴圈,因為JMM主動通知了主執行緒更新number的值了,number已經不為0了。
在這裡插入圖片描述

2、volatile不保證原子性

2.1 什麼是原子性?

理解了上面說的可見性之後,再來理解下什麼叫原子性

原子性是指不可分隔,完整性,即某個執行緒正在做某個業務時,中間不能被分割。要麼同時成功,要麼同時失敗。

還是有點抽象,接下來舉個例子。

如下圖,建立了一個測試原子性的類:TestPragma。在add方法中將n加1,通過檢視編譯後的程式碼可以看到,n++被拆分為3個指令進行執行。

因此可能存線上程1正在執行第1個指令,緊接著執行緒2也正在執行第1個指令,這樣當執行緒1和執行緒2都執行完3個指令之後,很容易理解,此時n的值只加了1,而實際是有2個執行緒加了2次,因此這種情況就是不保證原子性。
在這裡插入圖片描述

2.2 不保證原子性的程式碼驗證

在2.1中已經進行了舉例,可能存在2個執行緒執行n++的操作,但是最終n的值卻只加了1的情況,接下來對這種情況再用程式碼進行演示下。

首先給MyData類新增一個add方法

package com.koping.test;class MyData {
    volatile int number = 0;

    public void add() {
        number++;
    }}

然後建立測試原子性的類:TestPragmaDemo。測試下20個執行緒給number各加1000次之後,number的值是否是20000。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 啟動20個執行緒,每個執行緒將myData的number值加1000次,那麼理論上number值最終是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程式執行時,模型會有主執行緒和守護執行緒。如果超過2個,那就說明上面的20個執行緒還有沒執行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此時number的實際值是:" + myData.number);

    }}

執行結果如下圖,最終number的值僅為18410。
可以看到即使加了volatile,依然不保證有原子性。
在這裡插入圖片描述

2.3 volatile不保證原子性的解決方法

上面介紹並證明了volatile不保證原子性,那如果希望保證原子性,怎麼辦呢?以下提供了2種方法

2.3.1 方法1:使用synchronized

方法1是在add方法上新增synchronized,這樣每次只有1個執行緒能執行add方法。

結果如下圖,最終確實可以使number的值為20000,保證了原子性。

但是,實際業務邏輯方法中不可能只有只有number++這1行程式碼,上面可能還有n行程式碼邏輯。現在為了保證number的值是20000,就把整個方法都加鎖了(其實另外那n行程式碼,完全可以由多執行緒同時執行的)。所以就優點殺雞用牛刀,高射炮打蚊子,小題大做了。

package com.koping.test;class MyData {
    volatile int number = 0;

    public synchronized void add() {
      // 在n++上面可能還有n行程式碼進行邏輯處理
        number++;
    }}

在這裡插入圖片描述

2.3.2 方法1:使用JUC包下的AtomicInteger

給MyData新曾一個原子整型型別的變數num,初始值為0。

package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData {
    volatile int number = 0;

    volatile AtomicInteger num = new AtomicInteger();

    public void add() {
        // 在n++上面可能還有n行程式碼進行邏輯處理
        number++;
        num.getAndIncrement();
    }}

讓num也同步加20000次。結果如下圖,可以看到,使用原子整型的num可以保證原子性,也就是number++的時候不會被搶斷。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 啟動20個執行緒,每個執行緒將myData的number值加1000次,那麼理論上number值最終是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程式執行時,模型會有主執行緒和守護執行緒。如果超過2個,那就說明上面的20個執行緒還有沒執行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此時number的實際值是:" + myData.number);
        System.out.println("num值加了20000次,此時number的實際值是:" + myData.num);

    }}

在這裡插入圖片描述

3、volatile禁止指令重排

3.1 什麼是指令重排?

在第2節中理解了什麼是原子性,現在要理解下什麼是指令重排?

計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排
原始碼–>編譯器優化重排–>指令並行重排–>記憶體系統重排–>最終執行指令

處理器在進行重排時,必須要考慮指令之間的資料依賴性

單執行緒環境中,可以確保最終執行結果和程式碼順序執行的結果一致。

但是多執行緒環境中,執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒使用的變數能否保持一致性是無法確定的,結果無法預測

看了上面的文字性表達,然後看一個很簡單的例子。
比如下面的mySort方法,在系統指令重排後,可能存在以下3種語句的執行情況:
1)1234
2)2134
3)1324
以上這3種重排結果,對最後程式的結果都不會有影響,也考慮了指令之間的資料依賴性。

public void mySort() {
    int x = 1;  // 語句1
    int y = 2;  // 語句2
    x = x + 3;  // 語句3
    y = x * x;  // 語句4}

3.2 單執行緒單例模式

看完指令重排的簡單介紹後,然後來看下單例模式的程式碼。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 執行構造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        // 單執行緒測試
        System.out.println("單執行緒的情況測試開始");
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println("單執行緒的情況測試結束\n");
    }}

首先是在單執行緒情況下進行測試,結果如下圖。可以看到,構造方法只執行了一次,是沒有問題的。
在這裡插入圖片描述

3.3 多執行緒單例模式

接下來在多執行緒情況下進行測試,程式碼如下。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 執行構造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }

        // DCL(Double Check Lock雙端檢索機制)//        if (instance == null) {//            synchronized (SingletonDemo.class) {//                if (instance == null) {//                    instance = new SingletonDemo();//                }//            }//        }
        return instance;
    }

    public static void main(String[] args) {
        // 單執行緒測試//        System.out.println("單執行緒的情況測試開始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("單執行緒的情況測試結束\n");

        // 多執行緒測試
        System.out.println("多執行緒的情況測試開始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}

在多執行緒情況下的執行結果如下圖。可以看到,多執行緒情況下,出現了構造方法執行了2次的情況。
在這裡插入圖片描述

3.4 多執行緒單例模式改進:DCL

在3.3中的多執行緒單裡模式下,構造方法執行了兩次,因此需要進行改進,這裡使用雙端檢鎖機制:Double Check Lock, DCL。即加鎖之前和之後都進行檢查。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 執行構造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {//        if (instance == null) {//            instance = new SingletonDemo();//        }

        // DCL(Double Check Lock雙端檢鎖機制)
        if (instance == null) {  // a行
            synchronized (SingletonDemo.class) {
                if (instance == null) {  // b行
                    instance = new SingletonDemo();  // c行
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        // 單執行緒測試//        System.out.println("單執行緒的情況測試開始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("單執行緒的情況測試結束\n");

        // 多執行緒測試
        System.out.println("多執行緒的情況測試開始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}

在多次執行後,可以看到,在多執行緒情況下,此時構造方法也只執行1次了。
在這裡插入圖片描述

3.5 多執行緒單例模式改進,DCL版存在的問題

需要注意的是3.4中的DCL版的單例模式依然不是100%準確的!!!

是不是不太明白為什麼3.4DCL版單例模式不是100%準確的原因
是不是不太明白在3.1講完指令重排的簡單理解後,為什麼突然要講多執行緒的單例模式

因為3.4DCL版單例模式可能會由於指令重排而導致問題,雖然該問題出現的可能性可能是千萬分之一,但是該程式碼依然不是100%準確的。如果要保證100%準確,那麼需要新增volatile關鍵字,新增volatile可以禁止指令重排

接下來分析下,為什麼3.4DCL版單例模式不是100%準確?

檢視instance = new SingletonDemo();編譯後的指令,可以分為以下3步:
1)分配物件記憶體空間:memory = allocate();
2)初始化物件:instance(memory);
3)設定instance指向分配的記憶體地址:instance = memory;

由於步驟2和步驟3不存在資料依賴關係,因此可能出現執行132步驟的情況。
比如執行緒1執行了步驟13,還沒有執行步驟2,此時instance!=null,但是物件還沒有初始化完成;
如果此時執行緒2搶佔到cpu,然後發現instance!=null,然後直接返回使用,就會發現instance為空,就會出現異常

這就是指令重排可能導致的問題,因此要想保證程式100%正確就需要加volatile禁止指令重排。

3.6 volatile保證禁止指令重排的原理

在3.1中簡單介紹了下執行重排的含義,然後通過3.2-3.5,藉助單例模式來舉例說明多執行緒情況下,為什麼要使用volatile的原因,因為可能存在指令重排導致程式異常

接下來就介紹下volatile能保證禁止指令重排的原理。

首先要了解一個概念:記憶體屏障(Memory Barrier),又稱為記憶體柵欄。它是一個CPU指令,有2個作用:
1)保證特定操作的執行順序
2)保證某些變數的記憶體可見性

由於編譯器和處理器都能執行指令重排。如果在指令之間插入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說,通過插入記憶體屏障,禁止在記憶體屏障前後的指令執行重排需優化

記憶體屏障的另一個作用是強制刷出各種CPU的快取資料,因此任何CPU上的執行緒都能讀取到這些資料的最新版本

在這裡插入圖片描述

推薦學習:《》

以上就是Java基礎之volatile詳解的詳細內容,更多請關注TW511.COM其它相關文章!