對volatile的理解--從JMM以及單例模式剖析

y浴血發表於2021-07-05

請談談你對volatile的理解

1.volitale是Java虛擬機器提供的一種輕量級的同步機制

三大特性1.1保證可見性 1.2不保證原子性 1.3禁止指令重排

首先保證可見性

1.1 可見性

概念:當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看到修改的值

package com.yuxue.juc.volatileTest;

/**
 * 1驗證volatile的可見性
 * 1.1 如果int num = 0,number變數沒有新增volatile關鍵字修飾
 * 1.2 新增了volatile,可以解決可見性
 */
class VolatileDemo1 {

    //自定義的類
    public static class MyTest{
        //類的內部成員變數num
        public int num = 0;
        //numTo60 方法,讓num值為60
        public void numTo60(){
            num = 60;
        }
    }

    public static void main(String[] args) {

        MyTest myTest = new MyTest();
        //第一個執行緒
        new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + "\t come in");
                Thread.sleep(3000);
                myTest.numTo60();
                System.out.println(Thread.currentThread().getName() + "\t update value:" + myTest.num);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } ,"thread1").start();;

      	//主執行緒判斷num值
        while (myTest.num == 0){
            //如果myData的num一直為零,main執行緒一直在這裡迴圈
        }
        System.out.println(Thread.currentThread().getName() + "\t mission is over, num value is " + myTest.num);
    }
}

如上程式碼是沒有保證可見性的,可見性存在於JMM當中即java記憶體模型當中的,可見性主要是指當一個執行緒改變其內部的工作記憶體當中的變數後,其他執行緒是否可以觀察到,因為不同的執行緒件無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,因為此處沒有新增volatile指令,導致其中thread1對num值變數進行更改時,main執行緒無法感知到num值發生更改,導致在while處無限迴圈,讀不到新的num值,會發生死迴圈

image-20210704181437439

此時修改類中程式碼為

/**
* volatile可以保證可見性,及時通知其他執行緒,主實體記憶體的值已經被修改
*/
public static class MyTest{
  //類的內部成員變數num
  public volatile int num = 0;
  //numTo60 方法,讓num值為60
  public void numTo60(){
    num = 60;
  }
}

此時volatile就可以保證記憶體的可見性,此時執行程式碼就可以發現

image-20210704181621394

1.2 不保證原子性

原子性概念:不可分割、完整性,即某個執行緒正在做某個具體業務時,中間不可以被加塞或者被分割,需要整體完整,要麼同時成功,要麼同時失敗

類程式碼為:

//自定義的類
public static class MyTest {
  //類的內部成員變數num
  public volatile int num = 0;

  public void numPlusPlus() {
    num++;
  }
}

主方法為

public static void main(String[] args) {
        MyTest myTest = new MyTest();
        /**
         * 10個執行緒建立出來,每個執行緒執行2000次num++操作
         * 我們知道,在位元組碼及底層,i++被抽象為三個操作
         * 即先取值,再自加,再賦值操作
         */
        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 2000; j++) {
                    myTest.numPlusPlus();
                }
            }, "Thread" + i).start();
        }
        //這裡規定執行緒數大於2,一般有GC執行緒以及main主執行緒
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t finally num value is " + myTest.num);
    }

程式碼如上所示,如果volatile保證原子性,那麼10個執行緒分別執行自加2000次操作,那麼最終結果一定是20000,但是執行三次結果如下

//第一次
main	 finally num value is 19003
//第二次
main	 finally num value is 18694
//第三次
main	 finally num value is 19552

可以發現,我們num的值每次都不相同,且最後的值都沒有達到20000,這是為什麼呢?

為什麼會出現這種情況?

首先,我們要考慮到這種情況,假如執行緒A執行到第11行即myTest.numPlusPlus();方法時

執行緒進入方法執行numPlusPlus方法後,num的值不管是多少,執行緒A將num的值首先初始化為0(假如主存中num的值為0),之後num的值自增為1,之後執行緒A掛起,執行緒B此時也將主存中的num值讀到自己的工作記憶體中值為0,之後num的值自增1,之後執行緒B掛起,執行緒A繼續執行將num的值寫回主存,但是因為volatile關鍵字保證可見性,但是在很短的時間內,執行緒B也將num的值寫回主存,此時num的值就少加了一次,所以最後總數基本上少於20000

如何解決

但是JUC有執行緒的原子類為AtomicInteger類,此時,將類程式碼更改為

public static class MyTest {
  //類的內部成員變數num
  public volatile int num = 0;
  AtomicInteger atomicInteger = new AtomicInteger();

  //numTo60 方法,讓num值為60
  public void numTo60() {
    num = 60;
  }

  public void numPlusPlus() {
    num++;
  }
  public void myAtomPlus(){
    atomicInteger.getAndIncrement();
  }
}

共同測試num和atomicInteger,此時執行主函式,三次結果為

//第一次
main	 finally num value is 19217
main	 finally atomicInteger value is 20000
//第二次
main	 finally num value is 19605
main	 finally atomicInteger value is 20000
//第三次
main	 finally num value is 18614
main	 finally atomicInteger value is 20000

我們發現volatile關鍵字並沒有保證我們的變數的原子性,但是JUC內部的AtomicInteger類保證了我們變數相關的原子性,AtomicInteger底層用到了CAS,CAS不瞭解的話可以參考這篇文章

1.3 禁止指令重排

有序性的概念:在計算機執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排,一般分以下三種

image-20210704235910636

單執行緒環境裡面確保程式最終執行結果和程式碼順序執行的結果一致。

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

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

重排程式碼例項: 宣告變數: int a,b,x,y=0

執行緒A 執行緒B
x=a; y=b;
b=1; a=2;
執行結果 x=0,y=0

如果編譯器對這段程式程式碼執行重排優化後,可能出現如下情況:

執行緒A 執行緒B
b=1; a=2;
x=a; y=b;
執行結果 x=2,y=1

這個結果說明在多執行緒環境下,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的

volatile實現禁止指令重排,從而避免了多執行緒環境下程式出現亂序執行的現象

記憶體屏障(Memory Barrier)又稱記憶體柵欄,是一個CPU指令,他的作用有兩個:

  1. 保證特定操作的執行順序
  2. 保證某些變數的記憶體可見性(利用該特性實現volatile的記憶體可見性)

由於編譯器和處理器都能執行指令重排優化。如果在之間插入一條Memory Barrier則會告訴編譯器和CPU, 不管什麼指令都不能和這條Memory Barrier指令重排順序,也就是說通過插入記憶體屏障禁止在記憶體屏障前後的指令執行重排序優化。記憶體屏障另外一個作用是強制刷出各種CPU的快取資料,因此任何CPU上的執行緒都能讀 取到這些資料的最新版本

image-20210705000533490

2.JMM(java記憶體模型)

為什麼提到JMM?JMM當中規定了可見性、原子性、以及有序性的問題,在多執行緒中只要保證了以上問題的正確性,那麼基本上不會發生多執行緒當中存在資料安全問題

JMM(Java Memory Model)本身是一種抽象的概念,並不真實存在,他描述的時一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。

JMM關於同步的規定:

  1. 執行緒解鎖前,必須把共享變數的值重新整理回主記憶體
  2. 執行緒加鎖前,必須讀取主記憶體的最新值到自己的工作記憶體
  3. 加鎖解鎖時同一把鎖

由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有的成為棧空間),工作記憶體是每個執行緒的私有資料區域,而java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是貢獻記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先概要將變數從主記憶體拷貝到自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體中儲存著主記憶體的變數副本拷貝,因此不同的執行緒件無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,期間要訪問過程如下圖:

image-20210704235759883

JMM的三大特性

2.1可見性

2.2原子性

2.3有序性

所以JMM當中的2.1和2.3在volatile當中都有很好的體現,volatile關鍵字並不能保證多執行緒當中的原子性,但是volatile是輕量級的同步機制,不想synchronized鎖一樣粒度太大

3.你在那些地方用過volatile?結合實際談論一下?

當普通單例模式在多執行緒情況下:

/**
 * 普通單例模式
 * */
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(getInstance() == getInstance());
    // System.out.println(getInstance() == getInstance());
    // System.out.println(getInstance() == getInstance());
    //併發多執行緒後,構造方法會在一些情況下執行多次
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, "Thread " + i).start();
        }
    }
}

此時會出現兩個執行緒執行了SingletonDemo的構造方法

image-20210705133847185

此時就違反了單例模式的規定,其構造方法在一些情況下會被執行多次

解決方式:

  1. 單例模式DCL程式碼

DCL (Double Check Lock雙端檢鎖機制)在加鎖前和加鎖後都進行一次判斷

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

不僅兩次判空讓程式執行更有效率,同時對程式碼塊加鎖,保證了執行緒的安全性

但是!還存在問題!

什麼問題?

大部分執行結果構造方法只會被執行一次,但指令重排機制會讓程式很小的機率出現構造方法被執行多次

DCL(雙端檢鎖)機制不一定執行緒安全,原因時有指令重排的存在,加入volatile可以禁止指令重排

原因是在某一個執行緒執行到第一次檢測,讀取到instance不為null時,instance的引用物件可能沒有完成初始化。instance=new SingleDemo();可以被分為一下三步(虛擬碼):

memory = allocate();//1.分配物件記憶體空間
instance(memory); //2.初始化物件
instance = memory; //3.設定instance執行剛分配的記憶體地址,此時instance!=null

步驟2和步驟3不存在資料依賴關係,而且無論重排前還是重排後程式的執行結果在單執行緒中並沒有改變,因此這種重排優化時允許的

所以如果3步驟提前於步驟2,但是instance還沒有初始化完成指令重排只會保證序列語義的執行的一致性(單執行緒),但並不關心多執行緒間的語義一致性。

所以當一條執行緒訪問instance不為null時,由於instance示例未必已初始化完成,也就造成了執行緒安全問題。

此時加上volatile後就不會出現執行緒安全問題

private static volatile SingletonDemo instance = null;

因為volatile禁止了指令重排序的問題

相關文章