Volatile的概述
我們都知道Volatile可以保證可見性、有序性、但是不保證原子性。
volatile 關鍵字在多執行緒程式設計中解決了幾個關鍵問題,主要包括:
記憶體可見性: 在沒有 volatile 的情況下,每個執行緒可能只會看到自己工作記憶體中的變數副本,而不是主記憶體中的最新值。這是因為現代處理器爲了效能,可能會將頻繁訪問的變數快取線上程的本地快取中。當一個執行緒修改了某個變數,如果不使用 volatile,其他執行緒可能不會立即看到這個變化,直到它們自己的快取被重新整理或者無效化。volatile 解決了這個問題,它強制每次讀取和寫入操作都直接從主記憶體中進行,確保了所有執行緒看到的都是最新的值。
指令重排序: 爲了提高執行效率,編譯器和處理器可能會重新排列指令的執行順序,只要這種重排不會影響到單執行緒下的程式結果。但在多執行緒環境中,這種重排可能會導致不一致性和錯誤的行為。volatile 變數可以防止與之關聯的讀寫操作被重排序,從而維護了程式碼的順序性,這對於依賴於特定操作順序的程式邏輯非常重要。
快取一致性: 在多處理器系統中,每個處理器都有自己的快取。如果沒有適當的機制,處理器之間的快取可能會變得不一致。volatile 變數透過確保所有處理器在讀取或寫入時都直接與主記憶體互動,從而維持了快取的一致性。
不保證原子性:JMM 定義了原子性操作的概念,要求對於多執行緒併發修改的變數,必須使用同步機制來保證原子性。例如,即使一個變數是 volatile 的,對它的複合操作(如 i++ 或 x = y + z)在多執行緒環境下仍然可能產生競態條件,因為這些操作通常涉及到讀取、計算和寫回多個步驟,而這些步驟可能會被其他執行緒的操作打斷。
可見性
JMM 中有一個主記憶體和工作記憶體的概念。主記憶體是所有執行緒共享的記憶體區域,工作記憶體是每個執行緒獨立的記憶體區域。當一個執行緒修改了主記憶體中的變數時,這個修改對其他執行緒是立即可見的。Volatile 關鍵字正是利用了 JMM 的這一特性,確保變數的可見性。
禁止指令重排序
JMM 對指令重排序做了限制,要求處理器按照順序執行指令。但是爲了提高執行效率,編譯器和處理器可能會對指令進行重排序。在這種情況下,Volatile 關鍵字就派上用場了。它會禁止編譯器和處理器對程式碼進行指令重排序最佳化,確保程式碼有序執行。
不保證原子性
什麼是原子性和原子操作
在程式設計中,具備原子性的操作被稱為原子操作。原子操作是指一系列的操作,要麼全部發生,要麼全部不發生,不會出現執行一半就終止的情況。
比如轉賬行為就是一個原子操作,該過程包含扣除餘額、銀行系統生成轉賬記錄、對方餘額增加等一系列操作。雖然整個過程包含多個操作,但由於這一系列操作被合併成一個原子操作,所以它們要麼全部執行成功,要麼全部不執行,不會出現執行一半的情況。比如我的餘額已經扣除,但是對方的餘額卻不增加,這種情況是不會出現的,所以說轉賬行為是具備原子性的。而具有原子性的原子操作,天然具備執行緒安全的特性。
下面我們舉一個不具備原子性的例子,比如 i++ 這一行程式碼在 CPU 中執行時,可能會從一行程式碼變為以下的 3 個指令:
第一個步驟是讀取;
第二個步驟是增加;
第三個步驟是儲存。
這就說明 i++ 是不具備原子性的,同時也證明了 i++ 不是執行緒安全的,下面簡單的描述下,如何發生的執行緒不安全問題,如下所示:
根據箭頭指向依次看,執行緒 1 首先拿到 i=1 的結果,然後進行 i+1 操作,但假設此時 i+1 的結果還沒有來得及被儲存下來,執行緒 1 就被切換走了,於是 CPU 開始執行執行緒 2,它所做的事情和執行緒 1 是一樣的 i++ 操作,但此時我們想一下,它拿到的 i 是多少?實際上和執行緒 1 拿到的 i 結果一樣,同樣是 1,為什麼呢?因為執行緒 1 雖然對 i 進行了 +1 操作,但結果沒有儲存,所以執行緒 2 看不到修改後的結果。
然後假設等執行緒 2 對 i 進行 +1 操作後,又切換到執行緒 1,讓執行緒 1 完成未完成的操作,即將 i+1 的結果 2 儲存下來,然後又切換到執行緒 2 完成 i=2 的儲存操作,雖然兩個執行緒都執行了對 i 進行 +1 的操作,但結果卻最終儲存了 i=2,而不是我們期望的 i=3,這樣就發生了執行緒安全問題,導致資料結果錯誤,這也是最典型的執行緒安全問題。
Java 中的原子操作有哪些
在瞭解了原子操作的特性之後,讓我們來看一下 Java 中有哪些操作是具備原子性的。Java 中的以下幾種操作是具備原子性的,屬於原子操作:
除了 long 和 double 之外的基本型別(int、byte、boolean、short、char、float)的讀/寫操作,都天然的具備原子性;
所有引用 reference 的讀/寫操作;
加了 volatile 後,所有變數的讀/寫操作(包含 long 和 double)。這也就意味著 long 和 double 加了 volatile 關鍵字之後,對它們的讀寫操作同樣具備原子性;
在 java.concurrent.Atomic 包中的一部分類的一部分方法是具備原子性的,比如 AtomicInteger 的 incrementAndGet 方法。
long 和 double 的原子性
在前面,我們講述了 long 和 double 和其他的基本型別不太一樣,好像不具備原子性,這是什麼原因造成的呢? 官方文件對於上述問題的描述,如下所示:
Non-Atomic Treatment of double and long
For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
Writes and reads of volatile long and double values are always atomic.
Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.
從 JVM 規範中我們可以知道,long 和 double 的值需要佔用 64 位的記憶體空間,而對於 64 位值的寫入,可以分為兩個 32 位的操作來進行。
這樣一來,本來是一個整體的賦值操作,就可能被拆分為低 32 位和高 32 位的兩個操作。如果在這兩個操作之間發生了其他執行緒對這個值的讀操作,就可能會讀到一個錯誤、不完整的值。
JVM 的開發者可以自由選擇是否把 64 位的 long 和 double 的讀寫操作作為原子操作去實現,並且規範推薦 JVM 將其實現為原子操作。當然,JVM 的開發者也有權利不這麼做,這同樣是符合規範的。
規範同樣規定,如果使用 volatile 修飾了 long 和 double,那麼其讀寫操作就必須具備原子性了。同時,規範鼓勵程式設計師使用 volatile 關鍵字對這個問題加以控制,由於規範規定了對於 volatile long 和 volatile double 而言,JVM 必須保證其讀寫操作的原子性,所以加了 volatile 之後,對於程式設計師而言,就可以確保程式正確。
實際開發中
此時,你可能會有疑問,比如,如果之前對於上述問題不是很瞭解,在開發過程中沒有給 long 和 double 加 volatile,好像也沒有出現過問題?而且,在以後的開發過程中,是不是必須給 long 和 double 加 volatile 纔是安全的?
其實在實際開發中,讀取到“半個變數”的情況非常罕見,這個情況在目前主流的 Java 虛擬機器中不會出現。因為 JVM 規範雖然不強制虛擬機器把 long 和 double 的變數寫操作實現為原子操作,但它其實是“強烈建議”虛擬機器去把該操作作為原子操作來實現的。
而在目前各種平臺下的主流虛擬機器的實現中,幾乎都會把 64 位資料的讀寫操作作為原子操作來對待,因此我們在編寫程式碼時一般不需要爲了避免讀到“半個變數”而把 long 和 double 宣告為 volatile 的。
在程式設計領域裏,原子性意味著“一組操作要麼全都操作成功,要麼全都失敗,不能只操作成功其中的一部分”。
JMM 定義了原子性操作的概念,要求對於多執行緒併發修改的變數,必須使用同步機制來保證原子性。Volatile 關鍵字雖然不能保證原子性,但是Volatile可以保證單次讀、單次寫的原子性。其實也不是Volatile來保證的,而是單個原子操作本來就具備原子性
可見性
可見性問題主要是指一個執行緒修改了共享變數值,而另一個執行緒卻看不到。引起可見性問題的主要原因是每個執行緒擁有自己的一個快取記憶體區——執行緒工作記憶體。volatile關鍵字能有效的解決這個問題。
public class VolatileTest { int a = 1; int b = 2; public void change(){ a = 3; b = a; } public void print(){ System.out.println("b="+b+";a="+a); } public static void main(String[] args) { while (true){ final VolatileTest test = new VolatileTest(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test.change(); } }).start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test.print(); } }).start(); } } }
直觀上說,這段程式碼的結果只可能有兩種:b=3;a=3 或 b=2;a=1。不過執行上面的程式碼(可能時間上要長一點),你會發現除了上兩種結果之外,還出現了另外兩種結果:
...... b=2;a=1 b=2;a=1 b=3;a=3 b=3;a=3 b=3;a=1 // 這裏 b=3;a=3 b=2;a=1 b=3;a=3 b=3;a=3 b=2;a=3 // 這裏 ......
為什麼會出現b=2;a=3和b=3;a=1這種結果呢? 正常情況下,如果先執行change方法,再執行print方法,輸出結果應該為b=3;a=3。相反,如果先執行的print方法,再執行change方法,結果應該是 b=2;a=1。那b=3;a=1的結果是怎麼出來的? 原因就是第一個執行緒將值a=3修改後,但是對第二個執行緒是不可見的,所以纔出現這一結果。如果將a和b都改成volatile型別的變數再執行,則再也不會出現b=2;a=3和b=3;a=1的結果了。
禁止指令重排序
從一個最經典的例子來分析重排序問題。大家應該都很熟悉單例模式的實現,而在併發環境下的單例實現方式,我們通常可以採用雙重檢查加鎖(DCL)的方式來實現。其原始碼如下:
public class Singleton { public static volatile Singleton singleton; /** * 建構函式私有,禁止外部例項化 */ private Singleton() {}; public static Singleton getInstance() { if (singleton == null) { synchronized (singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
現在我們分析一下為什麼要在變數singleton之間加上volatile關鍵字。要理解這個問題,先要了解物件的構造過程,例項化一個物件其實可以分為三個步驟:
分配記憶體空間。
初始化物件。
將記憶體空間的地址賦值給對應的引用。
但是由於作業系統可以對指令進行重排序,所以上面的過程也可能會變成如下過程:
分配記憶體空間。
將記憶體空間的地址賦值給對應的引用。
初始化物件
如果是這個流程,多執行緒環境下就可能將一個未初始化的物件引用暴露出來,從而導致不可預料的結果。因此,爲了防止這個過程的重排序,我們需要將變數設定為volatile型別的變數。
保證原子性:單次讀/寫
volatile不能保證完全的原子性,只能保證單次讀/單次寫操作具有原子性。
問題1: i++為什麼不能保證原子性?
對於原子性,需要強調一點,也是大家容易誤解的一點:對volatile變數的單次讀/寫操作可以保證原子性的,如long和double型別變數,但是並不能保證i++這種操作的原子性,因為本質上i++是讀、寫兩次操作。
現在我們就透過下列程式來演示一下這個問題:
import java.util.concurrent.atomic.AtomicInteger; class shareDate implements Runnable { volatile int a; AtomicInteger realA = new AtomicInteger(); @Override public void run() { for (int i = 0; i < 1000; i++) { a++; realA.incrementAndGet(); } } } public class VolatileExample1 { public static void main(String[] args) throws InterruptedException { shareDate r = new shareDate(); Thread thread1 = new Thread(r); Thread thread2 = new Thread(r); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(r.a + "----" + r.realA.get()); } }
大家可能會誤認為對變數a加上關鍵字volatile後,這段程式就是執行緒安全的。大家可以嘗試執行上面的程式。下面是我本地執行的結果:1919 可能每個人執行的結果不相同。不過應該能看出,volatile是無法保證原子性的(否則結果應該是2000)。原因也很簡單,a++其實是一個複合操作,包括三步驟:
讀取a的值。
對a加1。
將a的值寫回記憶體。
volatile是無法保證這三個操作是具有原子性的,證明了 volatile 不能保證原子性。我們可以透過AtomicInteger或者Synchronized來保證+1操作的原子性。
問題2:單次讀/單次寫場景的案例
如果某個共享變數自始至終只是被各個執行緒所賦值或讀取,而沒有其他的操作(比如讀取並在此基礎上進行修改這樣的複合操作)的話,那麼我們就可以使用 volatile 來代替 synchronized 或者代替原子類,因為賦值操作自身是具有原子性的,volatile 同時又保證了可見性,這就足以保證執行緒安全了。
一個比較典型的場景就是布林標記位的場景,例如 volatile boolean flag。因為通常情況下,boolean 型別的標記位是會被直接賦值的,此時不會存在複合操作(如 a++),只存在單一操作,就是去改變 flag 的值,而一旦 flag 被 volatile 修飾之後,就可以保證可見性了,那麼這個 flag 就可以當作一個標記位,此時它的值一旦發生變化,所有執行緒都可以立刻看到,所以這裏就很適合運用 volatile 了。
public class YesVolatile1 implements Runnable { volatile boolean done = false; AtomicInteger realA = new AtomicInteger(); public static void main(String[] args) throws InterruptedException { Runnable r = new YesVolatile1(); Thread thread1 = new Thread(r); Thread thread2 = new Thread(r); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(((YesVolatile1) r).done); System.out.println(((YesVolatile1) r).realA.get()); } @Override public void run() { for (int i = 0; i < 1000; i++) { setDone(); realA.incrementAndGet(); } } private void setDone() { done = true; } }
在 1000 次迴圈的操作過程中呼叫的是 setDone() 方法,而這個 setDone() 方法就是把 done 這個變數設定為 true,而不是根據它原來的值再做判斷,例如原來是 false,就設定成 true,或者原來是 true,就設定成 false,這些複雜的判斷是沒有的,setDone() 方法直接就把變數 done 的值設定為 true。那麼這段程式碼最終執行的結果如下:
true 2000
無論執行多少次,控制檯都會列印出 true 和 2000,列印出的 2000 已經印證出確實是執行了 2000 次操作,而最終的 true 結果證明了,在這種場景下,volatile 起到了保證執行緒安全的作用。
第二個例子區別於第一個例子最大的不同點就在於,第一個例子的操作是 a++,這是個複合操作,不具備原子性,而在本例中的操作僅僅是把 done 設定為 true,這樣的賦值操作本身就是具備原子性的,所以在這個例子中,它是適合運用 volatile 的。
Volatile的實現原理
volatile 的 happens-before 規則
happens-before 規則中有一條是 volatile 變數規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。
//假設執行緒A執行writer方法,執行緒B執行reader方法 class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; // 1 執行緒A修改共享變數 flag = true; // 2 執行緒A寫volatile變數 } public void reader() { if (flag) { // 3 執行緒B讀同一個volatile變數 int i = a; // 4 執行緒B讀共享變數 …… } } }
根據 happens-before 規則,上面過程會建立 3 類 happens-before 關係。
根據程式次序規則:1 happens-before 2 且 3 happens-before 4。
根據 volatile 規則:2 happens-before 3。
根據 happens-before 的傳遞性規則:1 happens-before 4。
因為以上規則,當執行緒 A 將 volatile 變數 flag 更改為 true 後,執行緒 B 能夠迅速感知。
volatile 禁止重排序
爲了效能最佳化,JMM 在不改變正確語義的前提下,會允許編譯器和處理器對指令序列進行重排序。JMM 提供了記憶體屏障阻止這種重排序。
Java 編譯器會在生成指令系列時在適當的位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。
JMM 會針對編譯器制定 volatile 重排序規則表。
能否指令重排 | 第二個操作 | ||
---|---|---|---|
第一個操作 | 普通讀/寫 | volatile讀 | volatile寫 |
普通讀/寫 | 禁止重排序 | ||
volatile讀 | 禁止重排序 | 禁止重排序 | 禁止重排序 |
volatile寫 | 禁止重排序 | 禁止重排序 |
爲了實現 volatile 記憶體語義時,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。
對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎是不可能的,為此,JMM 採取了保守的策略。
在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。
在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。
在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。
在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。
volatile 寫是在前面和後面分別插入記憶體屏障,而 volatile 讀操作是在後麵插入兩個記憶體屏障。
記憶體屏障 | 說明 |
---|---|
StoreStore 屏障 | 禁止上面的普通寫和下面的 volatile 寫重排序。 |
StoreLoad 屏障 | 防止上面的 volatile 寫與下面可能有的 volatile 讀/寫重排序。 |
LoadLoad 屏障 | 禁止下面所有的普通讀操作和上面的 volatile 讀重排序。 |
LoadStore 屏障 | 禁止下面所有的普通寫操作和上面的 volatile 讀重排序。 |
記憶體屏障(Memory Barrier)
記憶體屏障,又稱記憶體柵欄,是一個 CPU 指令。
在程式執行時,爲了提高執行效能,編譯器和處理器會對指令進行重排序,JMM 爲了保證在不同的編譯器和 CPU 上有相同的結果,透過插入特定型別的記憶體屏障來禁止特定型別的編譯器重排序和處理器重排序,插入一條記憶體屏障會告訴編譯器和 CPU:不管什麼指令都不能和這條 Memory Barrier 指令重排序。
Java 透過插入特定型別的 Memory Barriers 來確保指令不會被重排序。 對於 Volatile 寫操作,JVM 會在寫操作後面插入一個 Write Barrier,來禁止寫操作與其後麵的操作重排序;對於 Volatile 讀操作,JVM 會在讀操作前面插入一個 Read Barrier,來禁止讀操作與其前面的操作重排序。這樣就保證了 Volatile 寫/讀操作的有序性。
記憶體屏障的底層實現
記憶體屏障是一種處理器指令,它能影響之前和之後的指令順序。這種機制能確保特殊的記憶體順序一致性。記憶體屏障同時也是一種硬體或軟體機制,用於控制指令的執行順序和記憶體的訪問順序。具體實現方式可能有所不同,常見的記憶體屏障實現方式有兩種。
CPU 層面的記憶體屏障:CPU 提供了指令級別的記憶體屏障指令,例如 x86 架構中的 MFENCE(記憶體柵欄)指令。這些指令可以確保所屬執行緒的指令按照嚴格的順序執行,保證資料的一致性。
編譯器層面的記憶體屏障:編譯器可以透過指令重排序和程式碼最佳化來提高程式的效能。爲了保證 Volatile 關鍵字的語義,編譯器會在相應的程式碼位置插入記憶體屏障,以確保指令的執行順序和記憶體的訪問順序。
快取一致性協議(MESI)
除了 Memory Barrier 外,Volatile 關鍵字還利用快取一致性協議,如 MESI、修改、獨佔、共享、無效,來保證多個執行緒之間的可見性。
當一個執行緒修改了一個被 Volatile 修飾的變數時,會立即將修改的值重新整理到主記憶體。同時,利用快取一致性協議,強制把其他處理器的快取行狀態設定為無效。這樣,當其他處理器再次需要讀取這個變數的時候,因為在它的快取裡面這個變數已經是無效狀態,它會從主記憶體重新讀取,這樣就保證了可見性。
寫一段簡單的 Java 程式碼,宣告一個 volatile 變數,並賦值。
public class Test { private volatile int a; public void update() { a = 1; } public static void main(String[] args) { Test test = new Test(); test.update(); } }
透過 hsdis 和 jitwatch 工具可以得到編譯後的彙編程式碼:
...... 0x0000000002951563: and $0xffffffffffffff87,%rdi 0x0000000002951567: je 0x00000000029515f8 0x000000000295156d: test $0x7,%rdi 0x0000000002951574: jne 0x00000000029515bd 0x0000000002951576: test $0x300,%rdi 0x000000000295157d: jne 0x000000000295159c 0x000000000295157f: and $0x37f,%rax 0x0000000002951586: mov %rax,%rdi 0x0000000002951589: or %r15,%rdi 0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修飾的共享變數進行寫操作的時候會多出 lock 字首的指令 0x0000000002951591: jne 0x0000000002951a15 0x0000000002951597: jmpq 0x00000000029515f8 0x000000000295159c: mov 0x8(%rdx),%edi 0x000000000295159f: shl $0x3,%rdi 0x00000000029515a3: mov 0xa8(%rdi),%rdi 0x00000000029515aa: or %r15,%rdi ......
lock 字首的指令在多核處理器下會引發兩件事情:
將當前處理器快取行的資料寫回到系統記憶體。(從cpu寫到記憶體中)
寫回記憶體的操作會使在其他 CPU 裡快取了該記憶體地址的資料無效。
爲了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2 或其他)後再進行操作,但操作完不知道何時會寫到記憶體。
如果對宣告了 volatile 的變數進行寫操作,JVM 就會向處理器傳送一條 lock 字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。
爲了保證各個處理器的快取是一致的,實現了快取一致性協議(MESI),每個處理器透過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。
所有多核處理器下還會完成:當處理器發現本地快取失效後,就會從記憶體中重讀該變數資料,即可以獲取當前最新值。
volatile 變數透過這樣的機制就使得每個執行緒都能獲得該變數的最新值。
lock 指令
在 Pentium 和早期的 IA-32 處理器中,lock 字首會使處理器執行當前指令時產生一個 LOCK# 訊號,會對匯流排進行鎖定,其它 CPU 對記憶體的讀寫請求都會被阻塞,直到鎖釋放。 後來的處理器,加鎖操作是由快取記憶體鎖代替匯流排鎖來處理。 因為鎖匯流排的開銷比較大,鎖匯流排期間其他 CPU 沒法訪問記憶體。 這種場景多快取的資料一致透過快取一致性協議(MESI)來保證。
鎖字首(Lock Prefix)
在Pentium及早期的IA-32處理器中,lock字首用於將一個指令變成一個原子操作。當CPU遇到帶有lock字首的指令時,它會發出LOCK#訊號,這個訊號會鎖住前端匯流排(Front Side Bus, FSB),阻止其他處理器對共享資源的訪問,直到該指令完成。這在單處理器系統中沒有太大的效能影響,但在多處理器系統中,鎖住匯流排會導致顯著的延遲,因為所有其他處理器必須等待匯流排解鎖才能繼續它們的操作。
快取記憶體鎖(Cache Locking)
隨著處理器技術的發展,爲了減少鎖匯流排帶來的效能損失,後來的處理器開始使用快取鎖來替代匯流排鎖。在這種情況下,當一個處理器執行帶lock字首的指令時,它不是鎖住整個匯流排,而是鎖住相關快取行中的資料。這樣,即使其他處理器可以繼續訪問匯流排,它們也不能修改被鎖定的快取行,直到鎖被釋放。這種方法大大減少了對匯流排的獨佔時間,從而提高了多處理器系統的整體效能。
MESI協議
MESI(Modified, Exclusive, Shared, Invalid)是一種常用的快取一致性協議,用於管理多處理器系統中多個快取之間的資料一致性。在MESI協議下,每個快取行的狀態可以是M(修改)、E(獨佔)、S(共享)或I(無效)。當一個處理器想要修改一個處於共享狀態的快取行時,它會發起一個“快取行獨佔”請求,這會導致其他處理器的相應快取行狀態變為無效,從而確保資料的一致性。
數據結構
Volatile 關鍵字在 JVM 層面主要涉及到兩個數據結構:物件頭 ( Object Header) 和執行緒本地儲存區 (Thread Local Area)。
對於每個 Java 物件,包括陣列,它們在記憶體的佈局當中都包含了一個物件頭。物件頭中包含了物件的 Hashcode、GC 分代年齡等資訊,對於例項物件,還包含了指向其類後設資料的指標以及鎖資訊。當物件被 Volatile 變數引用,鎖資訊會被更新,從而實現記憶體語義。
對於 volatile 變數,Java 記憶體模型提供了以下記憶體語義:
可見性:對於 volatile 變數的寫操作,對其他執行緒是立即可見的。
禁止重排序:volatile 變數的寫操作和讀操作不能被編譯器或處理器重排序。
在上述句子中,鎖資訊的更新是實現 volatile 變數可見性的關鍵。當物件被 volatile 變數引用時,鎖資訊會被更新,這意味著其他執行緒在訪問該物件時,必須重新獲取鎖。因此,當一個執行緒修改了 volatile 變數的值,那麼其他執行緒在讀取該 volatile 變數的值時,可以看到最新的值。
class VolatileDemo { private volatile int number = 0; public void write() { number = 1; } public void read() { int value = number; } public static void main(String[] args) { VolatileDemo demo = new VolatileDemo(); // 執行緒 1 Thread thread1 = new Thread(() -> { demo.write(); }); // 執行緒 2 Thread thread2 = new Thread(() -> { demo.read(); }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(demo.number); } }
在上述程式碼中,number 變數被定義為 volatile 變數。執行緒 1 修改了 number 變數的值,執行緒 2 讀取 number 變數的值。根據 volatile 變數的記憶體語義,執行緒 2 在讀取 number 變數的值時,可以看到最新的值,即 1。
執行緒本地儲存區是每個執行緒在 JVM 內部開闢的一塊區域。這塊區域包含了執行緒的堆疊資訊,以及執行緒對特定變數的 Lock 定位資訊。當執行緒對 Volatile 變數解鎖,JVM 會清空這個區域內對應的 Lock 資訊,從而使下次讀取時可以直接從主記憶體獲取資料,實現同步。
這種機制保證了 Volatile 變數的修改對所有執行緒立即可見,以及 Volatile 變數單次讀、單次寫操作的原子性。
Volatile 的使用場景
通常情況下,volatile 可以用來修飾 boolean 型別的標記位,因為對於標記位來講,直接的賦值操作本身就是具備原子性的,再加上 volatile 保證了可見性,那麼就是執行緒安全的了。
而對於會被多個執行緒同時操作的計數器 Counter 的場景,這種場景的一個典型特點就是,它不僅僅是一個簡單的賦值操作,而是需要先讀取當前的值,然後在此基礎上進行一定的修改,再把它給賦值回去。這樣一來,我們的 volatile 就不足以保證這種情況的執行緒安全了。我們需要使用原子類來保證執行緒安全。
所以使用 volatile 必須具備的條件
對變數的寫操作不依賴於當前值。
該變數沒有包含在具有其他變數的不變式中。
只有在狀態真正獨立於程式內其他內容時才能使用 volatile。
模式1:狀態標誌
也許實現 volatile 變數的規範使用僅僅是使用一個布林狀態標誌,用於指示發生了一個重要的一次性事件,例如完成初始化或請求停機。
volatile boolean shutdownRequested; ...... public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } }
模式2:一次性安全釋出(one-time safe publication)
缺乏同步會導致無法實現可見性,這使得確定何時寫入物件引用而不是原始值變得更加困難。在缺乏同步的情況下,可能會遇到某個物件引用的更新值(由另一個執行緒寫入)和該物件狀態的舊值同時存在。(這就是造成著名的雙重檢查鎖定(double-checked-locking)問題的根源,其中物件引用在沒有同步的情況下進行讀操作,產生的問題是您可能會看到一個更新的引用,但是仍然會透過該引用看到不完全構造的物件)。
public class BackgroundFloobleLoader { public volatile Flooble theFlooble; public void initInBackground() { // do lots of stuff theFlooble = new Flooble(); // this is the only write to theFlooble } } public class SomeOtherClass { public void doWork() { while (true) { // do some stuff... // use the Flooble, but only if it is ready if (floobleLoader.theFlooble != null) doSomething(floobleLoader.theFlooble); } } }
模式3:獨立觀察(independent observation)
安全使用 volatile 的另一種簡單模式是定期 釋出 觀察結果供程式內部使用。例如,假設有一種環境感測器能夠感覺環境溫度。一個後臺執行緒可能會每隔幾秒讀取一次該感測器,並更新包含當前文件的 volatile 變數。然後,其他執行緒可以讀取這個變數,從而隨時能夠看到最新的溫度值。
public class UserManager { public volatile String lastUser; public boolean authenticate(String user, String password) { boolean valid = passwordIsValid(user, password); if (valid) { User u = new User(); activeUsers.add(u); lastUser = user; } return valid; } }
模式4:volatile bean 模式
在 volatile bean 模式中,JavaBean 的所有資料成員都是 volatile 型別的,並且 getter 和 setter 方法必須非常普通 —— 除了獲取或設定相應的屬性外,不能包含任何邏輯。此外,對於物件引用的資料成員,引用的物件必須是有效不可變的。(這將禁止具有陣列值的屬性,因為當陣列引用被宣告為 volatile 時,只有引用而不是陣列本身具有 volatile 語義)。對於任何 volatile 變數,不變式或約束都不能包含 JavaBean 屬性。
@ThreadSafe public class Person { private volatile String firstName; private volatile String lastName; private volatile int age; public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setAge(int age) { this.age = age; } }
模式5:開銷較低的讀-寫鎖策略
volatile 的功能還不足以實現計數器。因為 ++x 實際上是三種操作(讀、新增、儲存)的簡單組合,如果多個執行緒湊巧試圖同時對 volatile 計數器執行增量操作,那麼它的更新值有可能會丟失。 如果讀操作遠遠超過寫操作,可以結合使用內部鎖和 volatile 變數來減少公共程式碼路徑的開銷。 安全的計數器使用 synchronized 確保增量操作是原子的,並使用 volatile 保證當前結果的可見性。如果更新不頻繁的話,該方法可實現更好的效能,因為讀路徑的開銷僅僅涉及 volatile 讀操作,這通常要優於一個無競爭的鎖獲取的開銷。
public class CheesyCounter { private volatile int value; public int getValue() { return value; } public synchronized int increment() { return value++; } }
getValue()
方法沒有被同步。在沒有volatile
的情況下,如果一個執行緒修改了value
,即使是在increment()
方法內部,其他執行緒呼叫getValue()
方法時可能不會立即看到這個修改,因為getValue()
方法沒有synchronized
修飾,所以它不會受到synchronized
方法記憶體屏障的影響。
模式6:雙重檢查(double-checked)
就是我們上文舉的例子。
單例模式的一種實現方式,但很多人會忽略 volatile 關鍵字,因為沒有該關鍵字,程式也可以很好的執行,只不過程式碼的穩定性總不是 100%,說不定在未來的某個時刻,隱藏的 bug 就出來了。
class Singleton { private volatile static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { syschronized(Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
模式7:定時任務排程
假設我們需要實現一個定時任務排程器,其內部需要用到一個 flag 變數作為是否執行任務的標誌。由於 flag 的值由多個執行緒設定和修改,因此需要確保其先行發生關係。
public class TimerScheduler { private volatile boolean flag; private Runnable task; public TimerScheduler(Runnable task) { this.task = task; } public void setFlag(boolean flag) { this.flag = flag; } public void run() { if (flag) { task.run(); } } }
在示例裡,我們將 flag 變數宣告為 Volatile,確保其他執行緒能夠立即看到它的最新值。setFlag 方法用來設定 flag 變數的值,run 方法用於執行任務。
模式8:延遲初始化
假設我們需要延遲初始化某個類,可以使用 Volatile 關鍵字確保執行緒安全。
public class DelayedInitialization { private volatile boolean initialized; private Runnable initTask; public DelayedInitialization(Runnable initTask) { this.initTask = initTask; } public void init() { if (!initialized) { synchronized (this) { if (!initialized) { initTask.run(); initialized = true; } } } } }
我們將 initialized 變數宣告為Volatile,確保其他執行緒能夠立即看到其最新值。使用 synchronized 關鍵字確保執行緒安全,在 init 方法中僅初始化一次。