Java中volatile關鍵字的作用

由一段代碼引出的問題

我們先來看這樣一段簡單的代碼:

public class VolatileThread implements Runnable{

    private boolean flag = true;

    public boolean isFlag() {
        return flag;
    }
    
    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    @Override
    public void run() {
        System.out.println("子線程開始執行...");
        while(flag){
        
        }
        System.out.println("子線程執行結束...");
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        VolatileThread volatileThread = new VolatileThread();
        Thread t1 = new Thread(volatileThread);
        // 啓動t1線程,此時VolatileThread中的flag=true
        t1.start();
        // 讓Main線程休眠一會
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();     
        }
        // 將flag修改爲false
        volatileThread.setFlag(false);
        System.out.println("flag改爲false");
        // 獲取此時flag的值
        System.out.println("獲取此時flag的值爲:" + volatileThread.isFlag());
    }
}

在執行Main之前我們先來簡單分析一下,首先創建了一條新的線程去執行VolatileThread中的run()方法,由於flag默認爲true,所以會一直執行while(flag){ }循環,在Main線程休眠3秒後,將flag的值更改爲false。此時理論上當t1再次獲取flag時拿到的應該時false,然後會跳出while循環,打印"子線程執行結束…",然後程序結束。
但是事實真的像我們分析的這樣嗎,來執行一下Main,結果如下:
在這裏插入圖片描述
很奇怪的是,明明flag已經被修改成了false,並且在輸出語句中也證明了這一點,爲什麼程序卻一直在運行呢?也就說明它依然在while中循環沒有跳出來。想要弄清楚這一點,我們有必要先從JMM(Java內存模型)說起。

理解JMM(Java內存模型)

首先要說明的是Java內存模型(即Java Memory Model,簡稱JMM)和JVM內存區域劃分(程序計數器、Java虛擬機棧、本地方法棧、Java堆、方法區等)是不同的兩個概念。Java內存區域本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。
由於JVM運行java程序實際上是靠一條條線程來完成的,因此每條線程線程在啓動時,JVM都會爲其創建一個私有的工作空間,我們稱其爲本地工作內存,每條線程的本地工作內存都是相對獨立的,其他線程無法訪問。其實這很好理解,因爲每條線程都有自己的職責,比如主線程負責執行我們寫的代碼,GC線程負責垃圾回收等等。試想如果每條線程之間都可以任意的訪問其他線程的數據,是不是非常容易引起線程的安全性問題,所以說每條線程都存在這樣一個本地工作內存。
但是反過來說,凡事不能太極端,如果每條線程都完全獨立於其他線程,那麼所有線程間就也無法一起協同工作了。因此又需要一個媒介,將不同的線程聯繫起來讓它們之間保持通信。類似於相親,兩個互相不認識的男女,它們之間無法進行通信,但是要想取得聯繫,就必須通過媒婆來傳話,媒婆就是這兩個相對隔離的人之間的媒介。由此,就引出了線程間進行通信的媒介–主內存,主內存是共享數據區域,也就說每條線程都可以訪問主內存中的數據。通過這種媒介的方式,雖然線程間不能直接訪問對方工作內存中的數據,但是它們可以通過共同操作共享內存,從而間接的完成線程間的通信。

主內存:主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量)。此外主內存中還包括共享的類信息、常量、靜態變量。由於是共享數據區域,多條線程對同一個變量進行訪問、修改可能會發現線程安全問題。

本地工作內存:主要存儲當前方法所有的本地變量信息,如果線程需要操作主內存的數據,還會將主內存的數據拷貝一份到本地工作內存。每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作內存中創建屬於當前線程的本地變量。注意由於工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,因此存儲在工作內存的數據不存在線程安全問題。

用下面這幅圖來描述各條線程間的本地工作內存呢和主內存之間的關係:
在這裏插入圖片描述

理解線程間的可見性

線程是不允許直接操作(主要是指寫操作)主內存中的數據的,線程若想操作主內存的數據,必須要先將主內存中的數據讀取到自己的本地工作內存中,然後拷貝一個副本,對這個副本進行操作,然後再寫回主內存中。

另外需要注意的是,線程讀取共享數據的時候,也不是每次都從主內從中進行讀取,因爲從主內存中讀取數據肯定要比從自己的工作內存中讀取效率低。所以線程會基於操作系統的優化算法,前幾次會嘗試從主內存中進行讀取,並拷貝副本到工作內存中,當從主內存中讀取多次後發現總是和工作內存中的副本數據一樣時,它之後每次便會優先從選擇本地工作內存讀取,不確定何時再到主內存中讀取。基於上述分析,這種機制會造成一些問題:

  • 如果線程A在本地工作內存中對之前讀進來的數據進行了更新,並且把線程A的副本更新成了最新值,但是還差最後一步將副本刷新到主內存沒完成的時候,此時線程B主內存讀取數據,那麼此時線程B讀取的依然還是舊的數據(即使線程A確實已經完成了對數據的更新操作),因爲線程B是看不見線程A中的數據的。
  • 像上面所說的,如果線程B之前嘗試從主內存中讀取數據發現總是和副本的一致,那麼接下來線程B將會一直讀取自己本地工作內存中的副本。即使之後線程A將最新的數據刷新到了主內存,由於線程B一直在讀取自己之前讀進來的副本,那麼主內存中的最新數據線程B依然是看不見的,因爲並沒人通知它主內存已經更新成了最新值。

上邊所描述的這些,總結成三個字就是:可見性。線程的可見性問題,正是由於java內存模型的機制而引發的。

瞭解了這些,我們現在回過頭了再看最開始代碼中的問題,就非常容易理解是如何產生的了。我們知道成員變量(存在於堆中)是全局共享的變量,因此在VolatileThread中,flag存在於共享數據區域即主內存。接下來我們來分析VolatileThreadMain,當執行到t1.start()時,線程t1會去執行run方法,進入while循環判斷flag時,會先將flag讀取進thread自己的本地工作內存並保存一個副本。然後就是不斷的判斷flag然後執行while循環。
我們在VolatileThreadMain中讓Main線程休眠了3s,這3s看似不長,但是對於線程thread來說,它要做的循環次數要數以萬計,這麼多次循環判斷flag中,flag都沒有發生改變,這也就導致了我上面所說的,後邊它會優先從自己的副本中讀取flag(大家可以自行嘗試一下,如果不加休眠,程序是很快就會停下的,就是因爲前幾次其實線程thread還是會去主內存中讀取數據)。即使後邊主線程將主內存中的共享數據flag修改成了false,線程thread也不會從主內存讀取了,這也就是造成程序一直停止不了的原因。

使用volatile來解決可見性的問題

對於上述可見性問題,java給出瞭解決辦法,使用volatile關鍵字,volatile的功能有兩個:保證可見性和禁止指令重排序。

1.保證可見性
保證被volatile修飾的共享變量對所有線程總是可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。它是基於內存屏障(想詳細瞭解的話參見文末鏈接)實現了以下兩點:

  • 線程讀取共享數據時,每次都必須從主內存中讀取,不允許從本地工作內存的副本中讀取。
  • 線程更新共享數據時,只要更新完成必須強制刷新到主內存中。

在我們的代碼中,出現問題的根本原因是線程thread每次沒有從主內存中讀取最新的flag值,而是從本地工作內存中的副本中讀取,才導致程序一直處於循環狀態中停不下來。所以當我們用volatile修飾共享變量flag後,就能保證主線程修改flag後,線程thread會立即得知結果。大家可以自己嘗試一下,這裏不再演示。

2.禁止指令重排序
volatile除了可以保證可見性外,還可以禁止指令的重排序。
什麼是指令重排?我們通過volatile的典型應用–單例模式(懶漢式)的代碼來簡單描述一下指令重排:
在這裏插入圖片描述
圖中線程A先進入到圖示位置執行instance實例的創建,執行後cpu將線程A掛起然後讓線程B執行,此時線程B要執行的是判斷instance實例是否爲null。按照正常思路來說,既然線程A已經完成了new Singleton()創建實例,那麼此時instance肯定不爲null,於是直接return instance實例。但事實真的如此嗎?
在我上一篇java 線程安全問題以及使用synchronized解決線程安全問題文章中提到了,當CPU調度一條線程執行任務時,至少要執行一條計算機指令,但是一行java代碼並不一定就是一條計算指令,它被編譯器編譯後,可能會得到多條JVM指令。就比如說Java中最常見的創建實例 instance = new Singleton(),它會被編譯成如下的指令:

memory =allocate();    //1:分配對象的內存空間 
ctorInstance(memory);  //2:初始化對象 
instance =memory;     //3:設置instance指向剛分配的內存地址 

但是這些指令的執行順序並非是一成不變的,有可能會經過JVM和CPU的優化,從而使得最終CPU執行這3條指令的順序可能如下:

memory =allocate();    //1:分配對象的內存空間 
instance =memory;     //3:設置instance指向剛分配的內存地址 
ctorInstance(memory);  //2:初始化對象 

剛纔我們說到線程A執行創建實例時CPU將其掛起,但是並沒說CPU是執行完這3條執行後將線程A掛起,這也就意味着線程A可能在執行完1、3後被掛起。這時問題就來了,由於指令的重排序導致實例變量instace在未真正的初始化對象之前就已經指向了一塊已經分配好空間的內存,那麼此時如果線程B去判斷if(instance == null),結果肯定是false,然後就會把沒有實例化的instance進行返回,當線程B拿到這個未實例化的instance去調用它裏邊的方法時,就會發生NullPointException。

而volatile的作用之一就是禁止這樣的指令重排序優化,從而保證了指令的執行順序。這也是爲什麼在懶漢式單例模式中必須使用volatile修飾實例變量的原因,正確代碼如下:

public class Singleton {

    // 私有化構造函數
    private Singleton() {
    }

	// 使用volatile修飾實例變量,禁止指令重排序
    private volatile static Singleton instance = null;

    public static Singleton getInstance() {
        if (instance == null) { // 雙重檢測
            synchronized (Singleton.class) { // 同步鎖
                if (instance == null) { // 雙重檢測機制
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile和synchronized的區別

修飾位置的區別:

  • volatile:只能修飾共享變量(即成員變量),不可修飾方法和方法中的局部變量。
  • synchronized:可以修飾在代碼塊、方法、靜態方法

我們知道在多線程中有三個特性,分別是:原子性、可見性、和有序性

  • 被volatile修飾的變量能保證在執行時的可見性和有序性,但是無法保證操作的原子性。比如在我上一篇java 線程安全問題以及使用synchronized解決線程安全問題文章中舉的多線程賣票的例子,即使我們給成員變量(即當前剩餘的票數)加上了volatile但是不使用synchronized進行同步的話,依然會產生線程的安全問題。
  • 使用synchronized進行同步後,可以直接保證同步塊內的原子性可見性(當線程申請鎖時會將工作內存的變量值置爲失效然後從主內存讀取一份;當線程釋放鎖時會將工作內存的值寫入到主內存中),也可以間接保證有序性從單線程的角度看,指令重排並不會影響最終的結果,而synchronized進行同步時正是讓一個線程執行其他線程阻塞,這就類似於單線程執行,即synchronized不會直接保證有序性,它僅是通過原子操作來間接保證有序性。常見的例子就是設計模式–單例模式中第二版和第三版,第二版就是將可能引發有序性問題的代碼全部使用synchronized進行了同步,因此避免了有序性帶來的問題,而第三版反之,所以需要對共享變量進行volatile修飾來保證有序性。

本文參考自:

1.《Java多線程編程核心技術》
2. 全面理解Java內存模型(JMM)及volatile關鍵字

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