面試必問的 volatile,你真的會了嗎

談談你對 volatile 的理解?

你知道 volatile 底層的實現機制嗎?

volatile 變量和 atomic 變量有什麼不同?

volatile 的使用場景,你能舉兩個例子嗎?

文章收錄在 GitHub JavaKeeper ,包含 N 線互聯網開發必備技能兵器譜

之前算是比較詳細的介紹了 Java 內存模型——JMM, JMM是圍繞着併發過程中如何處理可見性、原子性和有序性這 3 個 特徵建立起來的,而 volatile 可以保證其中的兩個特性,下面具體探討下這個面試必問的關鍵字。

img

1. 概念

volatile 是 Java 中的關鍵字,是一個變量修飾符,用來修飾會被不同線程訪問和修改的變量。


2. Java 內存模型 3 個特性

2.1 可見性

可見性是一種複雜的屬性,因爲可見性中的錯誤總是會違揹我們的直覺。通常,我們無法確保執行讀操作的線程能適時地看到其他線程寫入的值,有時甚至是根本不可能的事情。爲了確保多個線程之間對內存寫入操作的可見性,必須使用同步機制。

可見性,是指線程之間的可見性,一個線程修改的狀態對另一個線程是可見的。也就是一個線程修改的結果。另一個線程馬上就能看到。

在 Java 中 volatile、synchronized 和 final 都可以實現可見性。

2.2 原子性

原子性指的是某個線程正在執行某個操作時,中間不可以被加塞或分割,要麼整體成功,要麼整體失敗。比如 a=0;(a非long和double類型) 這個操作是不可分割的,那麼我們說這個操作是原子操作。再比如:a++; 這個操作實際是a = a + 1;是可分割的,所以他不是一個原子操作。非原子操作都會存在線程安全問題,需要我們使用同步技術(sychronized)來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。Java的 concurrent 包下提供了一些原子類,AtomicInteger、AtomicLong、AtomicReference等。

在 Java 中 synchronized 和在 lock、unlock 中操作保證原子性。

2.3 有序性

Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 是因爲其本身包含“禁止指令重排序”的語義,synchronized 是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個對象鎖的兩個同步塊只能串行執行。


3. volatile 是 Java 虛擬機提供的輕量級的同步機制

  • 保證可見性
  • 不保證原子性
  • 禁止指令重排(保證有序性)

3.1 空說無憑,代碼驗證

3.1.1 可見性驗證

class MyData {
    int number = 0;
    public void add() {
        this.number = number + 1;
    }
}

   // 啓動兩個線程,一個work線程,一個main線程,work線程修改number值後,查看main線程的number
   private static void testVolatile() {
        MyData myData = new MyData();
     
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"\t come in");
            try {
                TimeUnit.SECONDS.sleep(2);
                myData.add();
                System.out.println(Thread.currentThread().getName()+"\t update number value :"+myData.number);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "workThread").start();

        //第2個線程,main線程
        while (myData.number == 0){
            //main線程還在找0
        }
        System.out.println(Thread.currentThread().getName()+"\t mission is over");      
        System.out.println(Thread.currentThread().getName()+"\t mission is over,main get number is:"+myData.number);
    }
}

運行 testVolatile() 方法,輸出如下,會發現在 main 線程死循環,說明 main 線程的值一直是 0

workThread	 execute
workThread	 update number value :1

修改 volatile int number = 0,,在 number 前加關鍵字 volatile,重新運行,main 線程獲取結果爲 1

workThread	 execute
workThread	 update number value :1
main	 execute over,main get number is:1

3.1.2 不保證原子性驗證

class MyData {
    volatile int number = 0;
    public void add() {
        this.number = number + 1;
    }
}

private static void testAtomic() throws InterruptedException {
  MyData myData = new MyData();

  for (int i = 0; i < 10; i++) {
    new Thread(() ->{
      for (int j = 0; j < 1000; j++) {
        myData.addPlusPlus();
      }
    },"addPlusThread:"+ i).start();
  }


  //等待上邊20個線程結束後(預計5秒肯定結束了),在main線程中獲取最後的number
  TimeUnit.SECONDS.sleep(5);
  while (Thread.activeCount() > 2){
    Thread.yield();
  }
  System.out.println("final value:"+myData.number);
}

運行 testAtomic 發現最後的輸出值,並不一定是期望的值 10000,往往是比 10000 小的數值。

final value:9856

爲什麼會這樣呢,因爲 i++ 在轉化爲字節碼指令的時候是4條指令

  • getfield 獲取原始值
  • iconst_1 將值入棧
  • iadd 進行加 1 操作
  • putfieldiadd 後的操作寫回主內存

這樣在運行時候就會存在多線程競爭問題,可能會出現了丟失寫值的情況。

如何解決原子性問題呢?

synchronized 或者直接使用 Automic 原子類。

3.1.3 禁止指令重排驗證

計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排,一般分爲以下 3 種

img

處理器在進行重排序時必須要考慮指令之間的數據依賴性,我們叫做 as-if-serial 語義

單線程環境裏確保程序最終執行結果和代碼順序執行的結果一致;但是多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測。

我們往往用下面的代碼驗證 volatile 禁止指令重排,如果多線程環境下,`最後的輸出結果不一定是我們想象到的 2,這時就要把兩個變量都設置爲 volatile。

public class ReSortSeqDemo {

    int a = 0;
    boolean flag = false;

    public void mehtod1(){
        a = 1;
        flag = true;
    }

    public void method2(){
        if(flag){
            a = a +1;
            System.out.println("reorder value: "+a);
        }
    }
}

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

還有一個我們最常見的多線程環境中 DCL(double-checked locking) 版本的單例模式中,就是使用了 volatile 禁止指令重排的特性。

public class Singleton {

    private static volatile Singleton instance;
  
    private Singleton(){}
    // DCL
    public static Singleton getInstance(){
        if(instance ==null){   //第一次檢查
            synchronized (Singleton.class){
                if(instance == null){   //第二次檢查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

因爲有指令重排序的存在,雙端檢索機制也不一定是線程安全的。

why ?

Because: instance = new Singleton(); 初始化對象的過程其實並不是一個原子的操作,它會分爲三部分執行,

  1. 給 instance 分配內存
  2. 調用 instance 的構造函數來初始化對象
  3. 將 instance 對象指向分配的內存空間(執行完這步 instance 就爲非 null 了)

步驟 2 和 3 不存在數據依賴關係,如果虛擬機存在指令重排序優化,則步驟 2和 3 的順序是無法確定的。如果A線程率先進入同步代碼塊並先執行了 3 而沒有執行 2,此時因爲 instance 已經非 null。這時候線程 B 在第一次檢查的時候,會發現 instance 已經是 非null 了,就將其返回使用,但是此時 instance 實際上還未初始化,自然就會出錯。所以我們要限制實例對象的指令重排,用 volatile 修飾(JDK 5 之前使用了 volatile 的雙檢鎖是有問題的)。


4. 原理

volatile 可以保證線程可見性且提供了一定的有序性,但是無法保證原子性。在 JVM 底層是基於內存屏障實現的。

  • 當對非 volatile 變量進行讀寫的時候,每個線程先從內存拷貝變量到 CPU 緩存中。如果計算機有多個CPU,每個線程可能在不同的 CPU 上被處理,這意味着每個線程可以拷貝到不同的 CPU cache 中
  • 而聲明變量是 volatile 的,JVM 保證了每次讀變量都從內存中讀,跳過 CPU cache 這一步,所以就不會有可見性問題
    • 對 volatile 變量進行寫操作時,會在寫操作後加一條 store 屏障指令,將工作內存中的共享變量刷新回主內存;
    • 對 volatile 變量進行讀操作時,會在寫操作後加一條 load 屏障指令,從主內存中讀取共享變量;

通過 hsdis 工具獲取 JIT 編譯器生成的彙編指令來看看對 volatile 進行寫操作CPU會做什麼事情,還是用上邊的單例模式,可以看到

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-jU26rOEO-1585104961191)(https://i.loli.net/2020/03/23/dP4EVrexioGlc9m.png)]

(PS:具體的彙編指令對我這個 Javaer 太南了,但是 JVM 字節碼我們可以認識,putstatic 的含義是給一個靜態變量設置值,那這裏的 putstatic instance ,而且是第 17 行代碼,更加確定是給 instance 賦值了。果然像各種資料裏說的,找到了 lock add1 據說還得翻閱。這裏可以看下這兩篇 https://www.jianshu.com/p/6ab7c3db13c3 、 https://www.cnblogs.com/xrq730/p/7048693.html )

有 volatile 修飾的共享變量進行寫操作時會多出第二行彙編代碼,該句代碼的意思是對原值加零,其中相加指令addl前有 lock 修飾。通過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多核處理器下會引發兩件事情:

  • 將當前處理器緩存行的數據寫回到系統內存
  • 這個寫回內存的操作會引起在其他CPU裏緩存了該內存地址的數據無效

正是 lock 實現了 volatile 的「防止指令重排」「內存可見」的特性


5. 使用場景

您只能在有限的一些情形下使用 volatile 變量替代鎖。要使 volatile 變量提供理想的線程安全,必須同時滿足下面兩個條件:

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

其實就是在需要保證原子性的場景,不要使用 volatile。


5. volatile 性能

volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因爲它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。

引用《正確使用 volaitle 變量》一文中的話:

很難做出準確、全面的評價,例如 “X 總是比 Y 快”,尤其是對 JVM 內在的操作而言。(例如,某些情況下 JVM 也許能夠完全刪除鎖機制,這使得我們難以抽象地比較 volatilesynchronized 的開銷。)就是說,在目前大多數的處理器架構上,volatile 讀操作開銷非常低 —— 幾乎和非 volatile 讀操作一樣。而 volatile 寫操作的開銷要比非 volatile 寫操作多很多,因爲要保證可見性需要實現內存界定(Memory Fence),即便如此,volatile 的總開銷仍然要比鎖獲取低。

volatile 操作不會像鎖一樣造成阻塞,因此,在能夠安全使用 volatile 的情況下,volatile 可以提供一些優於鎖的可伸縮特性。如果讀操作的次數要遠遠超過寫操作,與鎖相比,volatile 變量通常能夠減少同步的性能開銷。

參考

《深入理解Java虛擬機》
http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
https://juejin.im/post/5dbfa0aa51882538ce1a4ebc
《正確使用 Volatile 變量》https://www.ibm.com/developerworks/cn/java/j-jtp06197.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章