深入瞭解 Java 的 volatile 關鍵字

Howie_Y發表於2019-01-09

今天,來談談 Java 併發程式設計中的一個基礎知識點:volatile 關鍵字 本篇文章主要從可見性,原子性和有序性進行講解

一. 主存與工作記憶體

說 volatile 之前,先來聊聊 Java 的記憶體模型。

在 Java 記憶體模型中,規定了所有的變數都是儲存在主記憶體當中,而每個執行緒都有屬於自己的工作記憶體。執行緒的工作記憶體儲存了被該記憶體使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取,賦值等)都必須在工作記憶體中進行,而不能直接對主存進行操作。並且每個執行緒不能訪問其他執行緒的工作記憶體。

對於單執行緒的程式,這樣的規定沒有任何影響;但是對於多執行緒的程式,便可能導致,某個執行緒已經改變了主記憶體中的變數,而另一個執行緒還在使用其工作記憶體中的變數,因此造成了資料的不一致。

深入瞭解 Java 的 volatile 關鍵字

二. 可見性

volatile 可以保證資料的可見性,前面說到對於多執行緒的程式可能會造成資料不一致,但是當一個變數加上 volatile 之後,便可以保證,其他執行緒讀取到的該變數都是最新值。

這是因為每當對該變數進行寫操作時,都會使得其他執行緒工作變數中的該變數的拷貝失效,而迫使執行緒們都重新去主記憶體讀取

我們來看看例項:

public class TestThread extends Thread {
    private volatile boolean isRunning = true;

    public void setRunning(boolean running) {
        isRunning = running;
    }

    @Override
    public void run() {
        int i = 1;
        while (isRunning) {
            i++;
        }
        System.out.println(i);
    }
}
複製程式碼
public class Test {
    public static void main(String[] args) throws InterruptedException {
        TestThread thread = new TestThread ();
        thread.start();
        Thread.sleep(3000);
        thread.setRunning(false);
    }
}

複製程式碼

當 isRunning 變數沒有新增 volatile 變數時,該程式會發生死迴圈,因為setRunning(false)並沒有影響到 thread 所線上程的工作記憶體(這時該執行緒看到的值仍然是 true)

當我們為變數新增上 volatile 之後,setRunning(false)執行完畢,thread 所線上程的工作記憶體的變數拷貝便就此作廢,必須去主記憶體獲取最新的值,死迴圈也因此不會再發生了

值得注意的是,當我們在迴圈中新增了列印語句,或者 sleep 方法等,這時無論有沒有 volatile,都會停止迴圈,如:

 while (isRunning) {
    i++;
    System.out.println(i);
}
複製程式碼

這是因為,JVM 會盡力保證記憶體的可見性,原本的程式碼中,程式一直處於死迴圈,這時 JVM 沒有辦法強制要求 CPU 分出時間去保證可見性;但是當加上列印語句之後,CPU 便會分出時間去處理這件事情,並保證了可見性;但是,與之不同的是,volatile 是強制保證可見性的。

三. 原子性

volatile 沒有辦法保證操作的原子性的

直接上程式碼:

public class AtomicTest {
    private static volatile int race = 0;

    private static void increase() {
        race++;
    }
    
    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        //等待所有累加執行緒都結束
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(race);
    }
}
複製程式碼

這段程式碼摘自《深入理解 Java 虛擬機器》12 章,裡面的 race 擁有 volatile 關鍵字的加持時,但最終的列印結果仍然經常會小於 10000,這就是因為 volatile 沒有辦法保證操作的原子性。

假設兩個執行緒 1 和 2,它們倆先後讀取了 race 的值(初始值為 0),由於它們都還沒有進行寫操作,因此兩個執行緒這時看到的值都是 0,因此便使得之後兩次自增操作的結果是 1,而不是 2

深入瞭解 Java 的 volatile 關鍵字

剛剛說到 volatile 變數在進行寫操作的時候,會讓其他執行緒對應的工作記憶體中的拷貝失效,使得需要直接去主存中讀取變數,而上例中執行緒 1 在進行寫操作之前,執行緒 2 便已經執行了讀操作,因此沒辦法影響執行緒 2 的讀取,因此也不會更新為最新的資料了

四. 有序性

volatile 可以在一定程度上禁止指令重排序

//x、y為非volatile變數
//flag為volatile變數
 
x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;         //語句4
y = -1;       //語句5
複製程式碼

flag 變數新增上 volatile 關鍵字以後,語句 1,2 不會排在 3 的後面執行,當然 4,5 也不會在 3 的前面執行

但是 1 和 2, 3 和 4 之間的順序沒辦法干預,這也是我們說“一定程度改變”的原因

上個例子:

//執行緒1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//執行緒2:
while(!inited){
  sleep()
}
doSomethingwithconfig(context); //出錯,context 可能還沒有初始化
複製程式碼

面對這樣的例子的時候,如果 inited 是非 volatile 變數,那麼因為重排序的關係,有可能出錯;但是加上 volatile 後便不用擔心了

五. 使用場景

1. 狀態變數:

比如上面給出的可見性的程式碼例子

while (isRunning) {
      i++;
}
複製程式碼

對於這種用於標記狀態的變數,volatile 是非常好用的

2. 雙重檢驗:

最經典的就是單例模式的雙重檢驗實現,如果忘了的剛好複習一下:

public class Singleton {
    private volatile static Singleton singleton;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
複製程式碼

這裡的 volatile,是為了保證 singleton = new Singleton();操作的有序性,因為 singleton = new Singleton(); 並不是原子操作,做了 3 件事

  1. 給 singleton 分配記憶體
  2. 呼叫 Singleton 的建構函式來初始化成員變數
  3. 將 singleton 物件指向分配的記憶體空間(執行完這步 singleton 就為非 null 了)

但是由於重排序的原因,1-2-3 的順序可能變成 1-3-2,如果是後者,在 singleton 變成非 null 時(即第三步),如果第二個執行緒開始進入第一個判斷 if (singleton == null),那麼便會直接返回 true,然而事實上 singleton 還沒有完成初始化

相關文章