在 Java 併發編程中,有 3 個最常用的關鍵字:synchronized、ReentrantLock 和 volatile。
雖然 volatile 並不像其他兩個關鍵字一樣,能保證線程安全,但 volatile 也是併發編程中最常見的關鍵字之一。例如,單例模式、CopyOnWriteArrayList 和 ConcurrentHashMap 中都離不開 volatile。
那麼,問題來了,我們知道 synchronized 底層是通過監視器 Monitor 實現的,ReentrantLock 底層是通過 AQS 的 CAS 實現的,那 volatile 的底層是如何實現的?
1.volatile 作用
在瞭解 volatile 的底層實現之前,我們需要先了解 volatile 的作用,因爲 volatile 的底層實現和它的作用息息相關。
volatile 作用有兩個:保證內存可見性和有序性(禁止指令重排序)。
1.1 內存可見性
說到內存可見性問題就不得不提 Java 內存模型,Java 內存模型(Java Memory Model)簡稱爲 JMM,主要是用來屏蔽不同硬件和操作系統的內存訪問差異的,因爲在不同的硬件和不同的操作系統下,內存的訪問是有一定的差異得,這種差異會導致相同的代碼在不同的硬件和不同的操作系統下有着不一樣的行爲,而 Java 內存模型就是解決這個差異,統一相同代碼在不同硬件和不同操作系統下的差異的。
Java 內存模型規定:所有的變量(實例變量和靜態變量)都必須存儲在主內存中,每個線程也會有自己的工作內存,線程的工作內存保存了該線程用到的變量和主內存的副本拷貝,線程對變量的操作都在工作內存中進行。線程不能直接讀寫主內存中的變量,如下圖所示:
然而,Java 內存模型會帶來一個新的問題,那就是內存可見性問題,也就是當某個線程修改了主內存中共享變量的值之後,其他線程不能感知到此值被修改了,它會一直使用自己工作內存中的“舊值”,這樣程序的執行結果就不符合我們的預期了,這就是內存可見性問題,我們用以下代碼來演示一下這個問題:
private static boolean flag = false;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
}
System.out.println("終止執行");
}
});
t1.start();
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("設置 flag=true");
flag = true;
}
});
t2.start();
}
以上代碼我們預期的結果是,在線程 1 執行了 1s 之後,線程 2 將 flag 變量修改爲 true,之後線程 1 終止執行,然而,因爲線程 1 感知不到 flag 變量發生了修改,也就是內存可見性問題,所以會導致線程 1 會永遠的執行下去,最終我們看到的結果是這樣的:
如何解決以上問題呢?只需要給變量 flag 加上 volatile 修飾即可,具體的實現代碼如下:
private volatile static boolean flag = false;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
}
System.out.println("終止執行");
}
});
t1.start();
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("設置 flag=true");
flag = true;
}
});
t2.start();
}
以上程序的執行結果如下圖所示:
1.2 有序性
有序性也叫做禁止指令重排序。
指令重排序是指編譯器或 CPU 爲了優化程序的執行性能,而對指令進行重新排序的一種手段。
指令重排序的實現初衷是好的,但是在多線程執行中,如果執行了指令重排序可能會導致程序執行出錯。指令重排序最典型的一個問題就發生在單例模式中,比如以下問題代碼:
public class Singleton {
private Singleton() {}
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) { // ①
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // ②
}
}
}
return instance;
}
}
以上問題發生在代碼 ② 這一行“instance = new Singleton();”,這行代碼看似只是一個創建對象的過程,然而它的實際執行卻分爲以下 3 步:
- 創建內存空間。
- 在內存空間中初始化對象 Singleton。
- 將內存地址賦值給 instance 對象(執行了此步驟,instance 就不等於 null 了)。
如果此變量不加 volatile,那麼線程 1 在執行到上述代碼的第 ② 處時就可能會執行指令重排序,將原本是 1、2、3 的執行順序,重排爲 1、3、2。但是特殊情況下,線程 1 在執行完第 3 步之後,如果來了線程 2 執行到上述代碼的第 ① 處,判斷 instance 對象已經不爲 null,但此時線程 1 還未將對象實例化完,那麼線程 2 將會得到一個被實例化“一半”的對象,從而導致程序執行出錯,這就是爲什麼要給私有變量添加 volatile 的原因了。
要使以上單例模式變爲線程安全的程序,需要給 instance 變量添加 volatile 修飾,它的最終實現代碼如下:
public class Singleton {
private Singleton() {}
// 使用 volatile 禁止指令重排序
private static volatile Singleton instance = null; // 【主要是此行代碼發生了變化】
public static Singleton getInstance() {
if (instance == null) { // ①
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // ②
}
}
}
return instance;
}
}
2.volatile 實現原理
volatile 實現原理和它的作用有關,我們首先先來看它的內存可見性。
2.1 內存可見性實現原理
volatile 內存可見性主要通過 lock 前綴指令實現的,它會鎖定當前內存區域的緩存(緩存行),並且立即將當前緩存行數據寫入主內存(耗時非常短),回寫主內存的時候會通過 MESI 協議使其他線程緩存了該變量的地址失效,從而導致其他線程需要重新去主內存中重新讀取數據到其工作線程中。
什麼 MESI 協議?
MESI 協議,全稱爲 Modified, Exclusive, Shared, Invalid,是一種高速緩存一致性協議。它是爲了解決多處理器(CPU)在併發環境下,多個 CPU 緩存不一致問題而提出的。
MESI 協議定義了高速緩存中數據的四種狀態:
- Modified(M):表示緩存行已經被修改,但還沒有被寫回主存儲器。在這種狀態下,只有一個 CPU 能獨佔這個修改狀態。
- Exclusive(E):表示緩存行與主存儲器相同,並且是主存儲器的唯一拷貝。這種狀態下,只有一個 CPU 能獨佔這個狀態。
- Shared(S):表示此高速緩存行可能存儲在計算機的其他高速緩存中,並且與主存儲器匹配。在這種狀態下,各個 CPU 可以併發的對這個數據進行讀取,但都不能進行寫操作。
- Invalid(I):表示此緩存行無效或已過期,不能使用。
MESI 協議的主要用途是確保在多個 CPU 共享內存時,各個 CPU 的緩存數據能夠保持一致性。當某個 CPU 對共享數據進行修改時,它會將這個數據的狀態從 S(共享)或 E(獨佔)狀態轉變爲 M(修改)狀態,並等待適當的時機將這個修改寫回主存儲器。同時,它會向其他 CPU 廣播一個“無效消息”,使得其他 CPU 將自己緩存中對應的數據狀態轉變爲I(無效)狀態,從而在下次訪問這個數據時能夠從主存儲器或其他 CPU 的緩存中重新獲取正確的數據。
這種協議可以確保在多處理器環境中,各個 CPU 的緩存數據能夠正確、一致地反映主存儲器中的數據狀態,從而避免由於緩存不一致導致的數據錯誤或程序異常。
2.2 有序性實現原理
volatile 的有序性是通過插入內存屏障(Memory Barrier),在內存屏障前後禁止重排序優化,以此實現有序性的。
什麼是內存屏障?
內存屏障(Memory Barrier 或 Memory Fence)是一種硬件級別的同步操作,它強制處理器按照特定順序執行內存訪問操作,確保內存操作的順序性,阻止編譯器和 CPU 對內存操作進行不必要的重排序。內存屏障可以確保跨越屏障的讀寫操作不會交叉進行,以此維持程序的內存一致性模型。
在 Java 內存模型(JMM)中,volatile 關鍵字用於修飾變量時,能夠保證該變量的可見性和有序性。關於有序性,volatile 通過內存屏障的插入來實現:
- 寫內存屏障(Store Barrier / Write Barrier): 當線程寫入 volatile 變量時,JMM 會在寫操作前插入 StoreStore 屏障,確保在這次寫操作之前的所有普通寫操作都已完成。接着在寫操作後插入 StoreLoad 屏障,強制所有後來的讀寫操作都在此次寫操作完成之後執行,這就確保了其他線程能立即看到 volatile 變量的最新值。
- 讀內存屏障(Load Barrier / Read Barrier): 當線程讀取 volatile 變量時,JMM 會在讀操作前插入 LoadLoad 屏障,確保在此次讀操作之前的所有讀操作都已完成。而在讀操作後插入 LoadStore 屏障,防止在此次讀操作之後的寫操作被重排序到讀操作之前,這樣就確保了對 volatile 變量的讀取總是能看到之前對同一變量或其他相關變量的寫入結果。
通過這種方式,volatile 關鍵字有效地實現了內存操作的順序性,從而保證了多線程環境下對 volatile 變量的操作遵循 happens-before 原則,確保了併發編程的正確性。
2.3 簡單回答
因爲內存屏障的作用既能保證內存可見性,同時又能禁止指令重排序。因此你也可以籠統的回答 volatile 是通過內存屏障實現的。但是,回答的越細,面試的成績越高,面試的通過率也就越高。
課後思考
什麼是 happens-before 原則?除了 synchronized、ReentrantLock 和 volatile 之外,併發編程中還有哪些常見的關鍵字呢?它們背後的實現原理又是什麼呢?
本文已收錄到我的面試小站 www.javacn.site,其中包含的內容有:Redis、JVM、併發、併發、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、設計模式、消息隊列等模塊。