volatile詳解


1、概念

volatile是Java中的關鍵字,用來修飾會被不同線程訪問和修改的變量。JMM(Java內存模型)是圍繞並發過程中如何處理可見性、原子性和有序性這3個特征建立起來的,而volatile可以保證其中的兩個特性。

2、Java內存模型的3個特性

1)可見性

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

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

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

2)原子性

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

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

3)有序性

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

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

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

1)可見性驗證

import java.util.concurrent.TimeUnit;

class T
{
    static class Data{
//       volatile   int number =0;
        int number =0;
        public void add()
        {
            this.number = number +1;
        }
    }
    // 啟動兩個線程,一個work線程,一個main線程,work線程修改number值后,查看main線程的number
    private static void testVolatile() {
        Data myData = new Data();

        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);
    }
    public static void main(String[] args) {
        testVolatile();
    }
}

輸出如下,會發現main線程死循環,說明main線程的值一直是0:

workThread     come in
workThread     update number value :1

修改volatile int number = 0,即在number前加關鍵字volatile,重新運行,main線程獲取結果為1,循環結束,輸入如下:

workThread     come in
workThread     update number value :1
main     mission is over
main     mission is over,main get number is:1

2)不保證原子性驗證

import java.util.concurrent.TimeUnit;

class T
{
    static class Data{
       volatile   int number =0;
        public void add()
        {
            this.number = number +1;
        }
    }
    private static void testAtomic() throws InterruptedException {
        Data myData = new Data();

        for (int i = 0; i < 10; i++) {
            new Thread(() ->{
                for (int j = 0; j < 1000; j++) {
                    myData.add();
                }
            },"addPlusThread:"+ i).start();
        }
        //等待上邊20個線程結束后(預計5秒肯定結束了),在main線程中獲取最后的number
        TimeUnit.SECONDS.sleep(10);
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println("final value:"+myData.number);
    }
    public static void main(String[] args) throws InterruptedException {
        testAtomic();
    }
}

運行程序后發現輸出值,並不一定是期望值10000,往往是比10000小的數值。

原因是i++在轉化為字節碼指令的時候是4條指令。

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

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

如何解決原子性問題呢?加 synchronized 或者直接使用 Automic 原子類。

3)禁止指令重排驗證

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

處理器在進行重排序時必須要考慮指令之間的數據依賴性,我們叫做 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;
    }
}

因為有指令重排序的存在,雙端檢索機制也不一定是線程安全的。因為instance = new Singleton(); 初始化對象的過程其實並不是一個原子的操作,它會分為三部分執行,

  • 給 instance 分配內存
  • 調用 instance 的構造函數來初始化對象
  • 將 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 修飾的共享變量進行寫操作時會多出第二行匯編代碼,該句代碼的意思是對原值加零,其中相加指令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 變量通常能夠減少同步的性能開銷。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM