天天看點

volatile關鍵字

談談你對 volatile 的了解?

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

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

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

文章收錄在 GitHub

JavaKeeper ,包含 N 線網際網路開發必備技能兵器譜

之前算是比較詳細的介紹了

Java 記憶體模型

——JMM, JMM是圍繞着并發過程中如何處理可見性、原子性和有序性這 3 個 特征建立起來的,而 volatile 可以保證其中的兩個特性,下面具體探讨下這個面試必問的關鍵字。

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 操作
  • putfield

    iadd

    後的操作寫回主記憶體

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

volatile關鍵字

如何解決原子性問題呢?

synchronized

或者直接使用

Automic

原子類。

3.1.3 禁止指令重排驗證

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

volatile關鍵字

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

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會做什麼事情,還是用上邊的單例模式,可以看到

volatile關鍵字

(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。

6. volatile 性能

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

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

很難做出準确、全面的評價,例如 “X 總是比 Y 快”,尤其是對 JVM 内在的操作而言。(例如,某些情況下 JVM 也許能夠完全删除鎖機制,這使得我們難以抽象地比較

volatile

synchronized

的開銷。)就是說,在目前大多數的處理器架構上,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