volatile 的作用
- 保證變量的內存可見性
- 禁止指令重排序
1.保證此變量對所有的線程的可見性,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內存,其它線程每次使用前立即從主內存刷新。 但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內存來完成。
2.禁止指令重排序優化。有volatile修飾的變量,賦值后多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當于一個內存屏障(指令重排序時不能把后面的指令重排序到內存屏障之前的位置)。
可見性
在理解 volatile 的內存可見性前,我們先來看看這個比較常見的多線程訪問共享變量的例子。
/*** 變量的內存可見性例子*/
public class VolatileExample {/*** main 方法作為一個主線程*/public static void main(String[] args) {MyThread myThread = new MyThread();// 開啟線程myThread.start();// 主線程執行for (; ; ) {if (myThread.isFlag()) {System.out.println("主線程訪問到 flag 變量");}}}}/*** 子線程類*/
class MyThread extends Thread {private boolean flag = false;@Overridepublic void run() {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 修改變量值flag = true;System.out.println("flag = " + flag);}public boolean isFlag() {return flag;}public void setFlag(boolean flag) {this.flag = flag;}
}
執行上面的程序,你會發現,控制臺永遠都不會輸出 “主線程訪問到 flag 變量” 這句話。我們可以看到,子線程執行時已經將 flag 設置成 true,但主線程執行時沒有讀到 flag 的最新值,導致控制臺沒有輸出上面的句子。
那么,我們思考一下為什么會出現這種情況呢?這里我們就要了解一下 Java 內存模型(簡稱 JMM)。
Java 內存模型
JMM(Java Memory Model):Java 內存模型,是 Java 虛擬機規范中所定義的一種內存模型,Java 內存模型是標準化的,屏蔽掉了底層不同計算機的區別。也就是說,JMM 是 JVM 中定義的一種并發編程的底層模型機制。
JMM 定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地內存,本地內存中存儲了該線程以讀/寫共享變量的副本。
JMM 的規定:
- 所有的共享變量都存儲于主內存。這里所說的變量指的是實例變量和類變量,不包含局部變量,因為局部變量是線程私有的,因此不存在競爭問題。
- 每一個線程還存在自己的工作內存,線程的工作內存,保留了被線程使用的變量的工作副本。
- 線程對變量的所有的操作(讀,取)都必須在工作內存中完成,而不能直接讀寫主內存中的變量。
- 不同線程之間也不能直接訪問對方工作內存中的變量,線程間變量的值的傳遞需要通過主內存中轉來完成。
JMM 的抽象示意圖:
然而,JMM 這樣的規定可能會導致線程對共享變量的修改沒有即時更新到主內存,或者線程沒能夠即時將共享變量的最新值同步到工作內存中,從而使得線程在使用共享變量的值時,該值并不是最新的。
正因為 JMM 這樣的機制,就出現了可見性問題。也就是我們上面那個例子出現的問題。
那我們要如何解決可見性問題呢?接下來我們就聊聊內存可見性以及可見性問題的解決方案。
內存可見性
內存可見性是指當一個線程修改了某個變量的值,其它線程總是能知道這個變量變化。也就是說,如果線程 A 修改了共享變量 V 的值,那么線程 B 在使用 V 的值時,能立即讀到 V 的最新值。
可見性問題的解決方案
我們如何保證多線程下共享變量的可見性呢?也就是當一個線程修改了某個值后,對其他線程是可見的。
這里有兩種方案:加鎖 和 使用 volatile 關鍵字。
下面我們使用這兩個方案對上面的例子進行改造。
加鎖
使用 synchronizer 進行加鎖。
/*** main 方法作為一個主線程*/public static void main(String[] args) {MyThread myThread = new MyThread();// 開啟線程myThread.start();// 主線程執行for (; ; ) {synchronized (myThread) {if (myThread.isFlag()) {System.out.println("主線程訪問到 flag 變量");}}}}
這里大家應該有個疑問是,為什么加鎖后就保證了變量的內存可見性了? 因為當一個線程進入 synchronizer 代碼塊后,線程獲取到鎖,會清空本地內存,然后從主內存中拷貝共享變量的最新值到本地內存作為副本,執行代碼,又將修改后的副本值刷新到主內存中,最后線程釋放鎖。
這里除了 synchronizer 外,其它鎖也能保證變量的內存可見性。
使用 volatile 關鍵字
使用 volatile 關鍵字修飾共享變量。
/*** 子線程類*/
class MyThread extends Thread {private volatile boolean flag = false;@Overridepublic void run() {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 修改變量值flag = true;System.out.println("flag = " + flag);}public boolean isFlag() {return flag;}public void setFlag(boolean flag) {this.flag = flag;}
}
使用 volatile 修飾共享變量后,每個線程要操作變量時會從主內存中將變量拷貝到本地內存作為副本,當線程操作變量副本并寫回主內存后,會通過 CPU 總線嗅探機制 告知其他線程該變量副本已經失效,需要重新從主內存中讀取。
volatile 保證了不同線程對共享變量操作的可見性,也就是說一個線程修改了 volatile 修飾的變量,當修改后的變量寫回主內存時,其他線程能立即看到最新值。
接下來我們就聊聊一個比較底層的知識點:總線嗅探機制
。
總線嗅探機制
在現代計算機中,CPU 的速度是極高的,如果 CPU 需要存取數據時都直接與內存打交道,在存取過程中,CPU 將一直空閑,這是一種極大的浪費,所以,為了提高處理速度,CPU 不直接和內存進行通信,而是在 CPU 與內存之間加入很多寄存器,多級緩存,它們比內存的存取速度高得多,這樣就解決了 CPU 運算速度和內存讀取速度不一致問題。
由于 CPU 與內存之間加入了緩存,在進行數據操作時,先將數據從內存拷貝到緩存中,CPU 直接操作的是緩存中的數據。但在多處理器下,將可能導致各自的緩存數據不一致(這也是可見性問題的由來),為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,而嗅探是實現緩存一致性的常見機制。
注意,緩存的一致性問題,不是多處理器導致,而是多緩存導致的。
嗅探機制工作原理:每個處理器通過監聽在總線上傳播的數據來檢查自己的緩存值是不是過期了,如果處理器發現自己緩存行對應的內存地址修改,就會將當前處理器的緩存行設置無效狀態,當處理器對這個數據進行修改操作的時候,會重新從主內存中把數據讀到處理器緩存中。
注意:基于 CPU 緩存一致性協議,JVM 實現了 volatile 的可見性,但由于總線嗅探機制,會不斷的監聽總線,如果大量使用 volatile 會引起總線風暴。所以,volatile 的使用要適合具體場景。
可見性問題小結
上面的例子中,我們看到,使用 volatile 和 synchronized 鎖都可以保證共享變量的可見性。相比 synchronized 而言,volatile 可以看作是一個輕量級鎖,所以使用 volatile 的成本更低,因為它不會引起線程上下文的切換和調度。但 volatile 無法像 synchronized 一樣保證操作的原子性。
下面我們來聊聊 volatile 的原子性問題。
原子性
所謂的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了執行并且不會受到任何因素的干擾而中斷,要么所有的操作都不執行。
在多線程環境下,volatile 關鍵字可以保證共享數據的可見性,但是并不能保證對數據操作的原子性。也就是說,多線程環境下,使用 volatile 修飾的變量是線程不安全的。
要解決這個問題,我們可以使用鎖機制,或者使用原子類(如 AtomicInteger)。
這里特別說一下,對任意單個使用 volatile 修飾的變量的讀 / 寫是具有原子性,但類似于 flag = !flag
這種復合操作不具有原子性。簡單地說就是,單純的賦值操作是原子性的。
禁止指令重排序
什么是重排序?
有序性:即程序執行的順序按照代碼的先后順序執行。
一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。
從 Java 源代碼到最終執行的指令序列,會分別經歷下面三種重排序:
雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那么它靠什么保證的呢?靠的是數據依賴性:
編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。
舉例如下代碼
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三個操作的數據依賴關系如下圖所示
A和C之間存在數據依賴關系,同時B和C之間也存在數據依賴關系。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的結果將會被改變)。但A和B之間沒有數據依賴關系,編譯器和處理器可以重排序A和B之間的執行順序。下圖是該程序的兩種執行順序:
在計算機中,軟件技術和硬件技術有一個共同的目標:在不改變程序執行結果的前提下,盡可能的開發并行度。編譯器和處理器都遵從這一目標。
這里所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果;但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。這是就需要內存屏障來保證可見性了。
為了更好地理解重排序,請看下面的部分示例代碼:
int a = 0;
flag = false;// 線程 A
a = 1; // 1
flag = true; // 2// 線程 B
if (flag) { // 3int i = a; // 4
}
單看上面的程序好像沒有問題,最后 i 的值是 1。但是為了提高性能,編譯器和處理器常常會在不改變數據依賴的情況下對指令做重排序(線程A中的a = 1;
和flag = true
沒有依賴關系)。假設線程 A 在執行時被重排序成先執行代碼 2,再執行代碼 1;而線程 B 在線程 A 執行完代碼 2 后,讀取了 flag 變量。由于條件判斷為真,線程 B 將讀取變量 a。此時,變量 a 還根本沒有被線程 A 寫入,那么 i 最后的值是 0,導致執行結果不正確。那么如何程序執行結果正確呢?這里仍然可以使用 volatile 關鍵字。
這個例子中, 使用 volatile 不僅保證了變量的內存可見性,還禁止了指令的重排序,即保證了 volatile 修飾的變量編譯后的順序與程序的執行順序一樣。那么使用 volatile 修飾 flag 變量后,在線程 A 中,保證了代碼 1 的執行順序一定在代碼 2 之前。
那么,讓我們繼續往下探索, volatile 是如何禁止指令重排序的呢?這里我們將引出一個概念:內存屏障指令
內存屏障指令
java編譯器會在生成指令系列時在適當的位置會插入內存屏障
指令來禁止特定類型的處理器重排序。
內存屏障有兩個作用:
1.阻止屏障兩側的指令重排序;
2.強制寫入緩存中的最新數據更新寫入主內存,讓其他線程可見;讓高速緩存中的數據失效,強制從新從主內存加載數據。
內存屏障分為兩種:Store Barrier 和 Load Barrier 即 寫屏障和讀屏障。
-
寫屏障(Store Barrier):
- 阻止屏障兩側的指令重排序。
- 清空CPU的Store Buffer,將修改刷入主存,并觸發緩存一致性協議(如MESI)廣播
Invalid
信號,使其他核心的緩存行失效。
讀屏障(Load Barrier):
- 阻止屏障兩側的指令重排序。
- 強制CPU處理Invalidate Queue中的失效請求,若緩存行狀態為
Invalid
,則從主存重新加載數據。
補充:
Store Buffer(存儲緩沖區):位于 CPU 核心與 L1 緩存之間,優化寫操作性能。
Invalidate Queue(失效隊列):位于 L1 緩存與總線嗅探單元之間,加速對失效請求的響應。
java的內存屏障通常所謂的四種即StoreStore,StoreLoad,LoadLoad,LoadStore,實際上也是上述兩種的組合,完成一系列的屏障和數據同步功能。
屏障類型 | 核心作用 | 防止的重排序 |
---|---|---|
StoreStore | 保證普通寫優先完成 | 寫-寫重排序 |
StoreLoad | 確保寫操作全局可見(全能屏障) | 寫-讀重排序 |
LoadLoad | 防止讀操作重排序 | 讀-讀重排序 |
LoadStore | 防止讀-寫重排序 | 讀-寫重排序 |
下面我們來看看 volatile 讀 / 寫時是如何插入內存屏障的,見下圖:
**
**
從上圖,我們可以知道 volatile 讀 / 寫插入內存屏障規則:
-
在每個 volatile 寫操作的前后分別插入一個 StoreStore 屏障和一個 StoreLoad 屏障。
作用:禁止普通寫與
volatile
寫重排序;強制刷新寫緩沖區,觸發MESI廣播失效信號。
-
在每個 volatile 讀操作的后面插入 LoadLoad 屏障和 LoadStore 屏障。
作用:處理失效隊列,強制加載最新值;禁止后續普通寫與
volatile
讀重排序。
緩存一致性協議&內存屏障
原理總結:
- 可見性:
通過內存屏障(強制刷緩存/失效緩存)+ MESI 協議(同步緩存狀態)實現
。 - 有序性:
通過內存屏障(禁止重排序)約束指令執行順序
。
二者共同確保 volatile
變量的“可見性”與“有序性”,但不保證原子性(如 i++
需配合鎖或原子類)。
1. 緩存一致性協議(MESI)作用
- 數據一致性:通過總線嗅探和狀態機(Modified/Exclusive/Shared/Invalid)保證多核緩存數據一致。
- 失效觸發:
- 核心A修改共享數據時,通過總線廣播Invalid信號,使其他核心的緩存行失效(狀態置為
Invalid
) - 其他核心讀取失效數據時,強制從主存重新加載最新值
- 核心A修改共享數據時,通過總線廣播Invalid信號,使其他核心的緩存行失效(狀態置為
2. 內存屏障作用
(1) 禁止重排序:確保操作有序性
指令重排序是編譯器和CPU為優化性能對指令執行順序的調整,單線程安全但多線程會導致邏輯錯誤。通過插入屏障指令,防止編譯器和CPU對指令亂序執行。
(2) 強制同步內存:確保可見性與一致性
內存屏障通過強制刷新緩存狀態,解決多核CPU因私有緩存(Store Buffer/Invalidate Queue)導致的數據不一致問題:
刷新寫緩沖區(強制寫操作全局可見)
- 機制:
- 寫屏障(如
StoreStore
/StoreLoad
)清空CPU的Store Buffer,將修改刷入主存,并觸發緩存一致性協議(如MESI)廣播Invalid
信號,使其他核心的緩存行失效。 - 示例:
volatile
寫后插入StoreLoad
屏障,強制寫緩沖區數據刷入主存,確保其他線程立即可見。
- 寫屏障(如
處理失效隊列(強制讀操作加載最新值)
- 機制:
- 讀屏障(如
LoadLoad
/LoadStore
)強制CPU處理Invalidate Queue中的失效請求,若緩存行狀態為Invalid
,則從主存重新加載數據。 - 示例:
volatile
讀前插入LoadLoad
屏障,確保讀取前處理完所有失效請求,避免讀到舊緩存。
- 讀屏障(如
(3) 與緩存一致性協議(MESI)的協同
- MESI協議角色:
通過緩存行狀態(Modified/Exclusive/Shared/Invalid)管理多核數據一致性,但??無法主動保證順序性??。 - 屏障與協議聯動:
- 寫屏障觸發MESI廣播
Invalid
信號,使其他核心緩存失效; - 讀屏障確保失效請求被處理,依賴MESI狀態(如Invalid)重新加載數據。
- 寫屏障觸發MESI廣播
happens-before
什么是happens-before?
一方面,程序員需要JMM提供一個強的內存模型來編寫代碼;另一方面,編譯器和處理器希望JMM對它們的束縛越少越好,這樣它們就可以最可能多的做優化來提高性能,希望的是一個弱的內存模型。
JMM考慮了這兩種需求,并且找到了平衡點,對編譯器和處理器來說,只要不改變程序的執行結果(單線程程序和正確同步了的多線程程序),編譯器和處理器怎么優化都行。
而對于程序員,JMM提供了happens-before規則(JSR-133規范),滿足了程序員的需求——簡單易懂,并且提供了足夠強的內存可見性保證。 換言之,程序員只要遵循happens-before規則,那他寫的程序就能保證在JMM中具有強的內存可見性。
JMM使用happens-before的概念來定制兩個操作之間的執行順序。這兩個操作可以在一個線程以內,也可以是不同的線程之間。因此,JMM可以通過happens-before關系向程序員提供跨線程的內存可見性保證。
happens-before關系的定義如下:
- 如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見(保障可見性),而且第一個操作的執行順序排在第二個操作之前(JMM對程序員做出的邏輯保障,并不是代碼指令真正的執行保障)。
- 即使兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現必須要按照happens-before關系指定的順序來執行。如果重排序之后的執行結果,與按happens-before關系來執行的結果一致,那么JMM也允許這樣的重排序。
因此,
第一條是JMM對程序員做出的邏輯保障;
第二條是JMM對編譯器、處理器進行重排序的約束原則:只有不改變程序的執行結果(不管是單線程還是多線程),編譯器、處理器怎么優化都可以。
happens-before關系本質上和as-if-serial語義是一回事。
as-if-serial語義保證單線程內重排序后的執行結果和程序代碼本身應有的結果是一致的,happens-before關系保證正確同步的多線程程序的執行結果不被重排序改變。
總之,如果操作A happens-before操作B,那么操作A在內存上所做的操作對操作B都是可見的,不管它們在不在一個線程。
天然的happens-before關系
在Java中,有以下天然的happens-before關系:
- 程序順序規則:在一個線程內一段代碼的執行結果是有序的。就是還會指令重排,但是隨便它怎么排,結果是按照我們代碼的順序生成的不會變。
- 監視器鎖規則:就是無論是在單線程環境還是多線程環境,對于同一個鎖來說,一個線程對這個鎖解鎖之后,另一個線程獲取了這個鎖都能看到前一個線程的操作結果。
- volatile 變量規則:就是如果一個線程先去寫一個volatile變量,然后一個線程去讀這個變量,那么這個寫操作的結果一定對讀的這個線程可見。
- 傳遞性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start() 規則:在主線程A執行過程中,啟動子線程B,那么線程A在啟動子線程B之前對共享變量的修改結果對線程B可見。
- join() 規則:在主線程A執行過程中,子線程B終止,那么線程B在終止之前對共享變量的修改結果在線程A中可見。也稱線程join()規則。
從 happens-before 的 volatile 變量規則可知,如果線程 A 寫入了 volatile 修飾的變量 V,接著線程 B 讀取了變量 V,那么,線程 A 寫入變量 V 及之前的寫操作都對線程 B 可見。
這里特別說明一下,happens-before 規則不是描述實際操作的先后順序,它是用來描述可見性的一種規則
(前一個操作的結果對后續操作是可見的)。