(點選上方公眾號,可快速關註)
來源:ImportNew – paddx
更新
請參考來自 Jean-philippe Bempel 的評論。他提到了一個真實因 JVM 最佳化導致死鎖的例子。我盡可能多地寫部落格的原因之一是一旦自己理解錯了,可以從社群中學到很多。謝謝!
什麼是 Volatile 變數?
Volatile 是 Java 中的一個關鍵字。你不能將它設定為變數或者方法名,句號。
認真點,別開玩笑,什麼是 Volatile 變數?我們應該什麼時候使用它?
哈哈,對不起,沒法提供幫助。
volatile 關鍵字的典型使用場景是在多執行緒環境下,多個執行緒共享變數,由於這些變數會快取在 CPU 的快取中,為了避免出現記憶體一致性錯誤而採用 volatile 關鍵字。
考慮下麵這個生產者/消費者的例子,我們每次生成/消費一個元素:
public class ProducerConsumer {
private String value = “”;
private boolean hasValue = false;
public void produce(String value) {
while (hasValue) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(“Producing ” + value + ” as the next consumable”);
this.value = value;
hasValue = true;
}
public String consume() {
while (!hasValue) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String value = this.value;
hasValue = false;
System.out.println(“Consumed ” + value);
return value;
}
}
在上面的類中,produce 方法透過儲存引數來生成一個新的值,然後將 hasValue 設定為 true。while 迴圈檢測標識變數(hasValue)是否 true,true 表示一個新的值沒有被消費,要求當前執行緒睡眠(sleep),該睡眠一直迴圈直到標識變數 hasValue 變為 false,只有在新的值被 consume 方法消費完成後才能變為 false。如果沒有有效的新值,consume 方法要求當前睡眠,當一個 produce 方法生成一個新值時,睡眠迴圈終止,並改變標識變數的值。
現在想象有兩個執行緒在使用這個類的物件,一個生成值(寫執行緒),另個一個消費值(讀執行緒)。透過下麵的測試來解釋這種方式:
public class ProducerConsumerTest {
public void testProduceConsume() throws InterruptedException {
ProducerConsumer producerConsumer = new ProducerConsumer();
List
values = Arrays.asList(“1”, “2”, “3”, “4”, “5”, “6”, “7”, “8”, “9”, “10”, “11”, “12”, “13”);
Thread writerThread = new Thread(() -> values.stream()
.forEach(producerConsumer::produce));
Thread readerThread = new Thread(() -> {
for (int i = 0; i > values.size(); i++) {
producerConsumer.consume();
}
});
writerThread.start();
readerThread.start();
writerThread.join();
readerThread.join();
}
}
這個例子大部分時候都能輸出期望的結果,但是也有很大機率會出現死鎖!
怎麼會?
我們先簡單討論一下計算機的結構。
我們都知道計算機是由記憶體單元和 CPU (還有許多其他部分)組成。主記憶體就是程式指令、變數、資料儲存的地方。程式執行期間,為了獲得更好的效能,CPU 可能會將變數複製到自己的記憶體中(即所謂的 CPU 快取)。由於現代計算機有多個 CPU,同樣也存在多個 CPU 快取。
在多執行緒環境下,有可能多個執行緒同時執行,每個執行緒使用不同的 CPU(雖然這完全依賴於底層的作業系統),每個 CPU 都從主記憶體中複製變數到它自己的快取中。當一個執行緒訪問這些變數時,是直接訪問快取中的副本,而不是真正訪問主記憶體中的變數。
現在,假設在我們的測試中有兩個執行緒執行在不同的 CPU 上,並且其中的有一個快取了標識變數(或者兩個都快取了)。現在考慮如下的執行順序
1、寫執行緒生成一個值,並將 hasValue 設定為 true。但是隻更新快取中的值,而不是主記憶體。
2、讀執行緒嘗試消費一個值,但是它的快取副本中 hasValue 被設定為 false,所以即使寫執行緒生產了一個新的值,也不能被消費,因為讀執行緒無法跳出睡眠迴圈(hasValue 的值為 false)。
3、因為讀執行緒不能消費新生成的值,所以寫執行緒也不能繼續,因為標識變數沒有設定回 false,因此寫執行緒阻塞在睡眠迴圈中。
4、這樣,就產生了死鎖!
這種情況只有在 hasValue 同步到所有快取才能改變,這完全依賴於底層的作業系統。
那怎麼解決這個問題? volatile 怎麼會適合這個例子?
如果我們將 hasValue 標示為 volatile,我就能確定這種死鎖就不會再發生。
private volatile boolean hasValue = false;
volatile 變數強制執行緒每次讀取的時候都直接從主記憶體中讀取,同時,每次寫 volatile 變數的時候也要立即掃清主記憶體中的值。如果執行緒決定快取變數,就需要每次讀寫的時候都與主記憶體進行同步。
做這個改變之後,我們再來考慮前面導致死鎖的執行步驟
1、寫執行緒生成一個值,並將 hasValue 設定為 true,這次直接更新主記憶體中的值(即使這個變數被快取了)。
2、讀執行緒嘗試消費一個值,先檢查 hasValue 的值,每次讀取都強制直接從主記憶體中獲取值,所以能獲取到寫執行緒改變後的值。
3、讀執行緒消費完生成的值後,重新設定標識變數的值,這個新的值也會同步到主記憶體(如果這個值被快取了,快取的副本也會更新)。
4、寫執行緒獲每次都是從主記憶體中取這個改變了的值,這樣就能繼續生成新的值。
現在,大家都很幸福了^_^ !
我知道了,強制執行緒直接從記憶體中讀寫執行緒,這是 Volatile 所能做全部的事情嗎?
實際上,它還有更多的功能。訪問一個 volatile 變數會在陳述句間建立 happens-before 關係。
什麼是 happens-before 關係?
happens-before 關係是程式陳述句之間的排序保證,這能確保任何記憶體的寫,對其他陳述句都是可見的。
這與 Volatile 是怎麼關聯的?
當寫一個 volatile 變數時,隨後對該變數讀時會建立一個 happens-before 關係。所以,所有在 volatile 變數寫操作之前完成的寫操作,將會對隨後該 volatile 變數讀操作之後的所有陳述句可見。
嗯…,好吧…,我有點明白了,但是可能透過一個例子會更清楚。
好,對這個模糊的概念我表示很抱歉。考慮下麵這個例子:
// Definition: Some variables
// 變數定義
private int first = 1;
private int second = 2;
private int third = 3;
private volatile boolean hasValue = false;
// First Snippet: A sequence of write operations being executed by Thread 1
//片段 1:執行緒 1 順序的寫操作
first = 5;
second = 6;
third = 7;
hasValue = true;
// Second Snippet: A sequence of read operations being executed by Thread 2
//片段 2:執行緒 2 順序的讀操作
System.out.println(“Flag is set to : ” + hasValue);
System.out.println(“First: ” + first); // will print 5 列印 5
System.out.println(“Second: ” + second); // will print 6 列印 6
System.out.println(“Third: ” + third); // will print 7 列印 7
我們假設上面的兩個程式碼片段有由兩個執行緒執行:執行緒 1 和執行緒 2。當第一個執行緒改變 hasValue 的值時,它不僅僅是掃清這個改變的值到主存,也會引起前面三個值的寫(之前任何的寫操作)掃清到主存。結果,當第二個執行緒訪問這三個變數的時候,就可以訪問到被執行緒 1 寫入的值,即使這些變數之前被快取(這些快取的副本都會被更新)。
這就是為什麼我們不需要像第一個示例一樣將變數標示為 volatile 。因為我們的寫操作在訪問 hasValue 之前,讀操作在 hasValue 的讀之後,它會自動與主記憶體同步。
還有另一個有趣的結論。JVM 因它的程式最佳化機制而聞名。有時對程式陳述句的重排序可以大幅度提高效能,並且不會改變程式的輸出結果。例如,它可能會修改如陳述句的順序:
first = 5;
second = 6;
third = 7;
為:
second = 6;
third = 7;
first = 5;
但是,當多條陳述句涉及到對 volatile 變數的訪問時,它永遠不會將 volatile 變數前的寫陳述句放在 volatile 變數之後,意思就是,它永遠不會轉換下列順序:
first = 5; // write before volatile write //volatile 寫之前的寫
second = 6; // write before volatile write //volatile 寫之前的寫
third = 7; // write before volatile write //volatile 寫之前的寫
hasValue = true;
為:
first = 5;
second = 6;
hasValue = true;
third = 7; // Order changed to appear after volatile write! This will never happen!
third = 7; // 順序發生了改變,出現在了 volatile 寫之後。這永遠不會發生。
即使從程式的正確性的角度來說,上面兩種情況是相等的。但請註意,JVM 仍然允許對前三個變數的寫操作進行重排序,只要它們都出現在 volatile 寫之前即可。
類似的,JVM 也不會將 volatile 變數讀之後的讀操作重排序到 volatile 變數之前。意思就是說,下麵的順序:
System.out.println(“Flag is set to : ” + hasValue); // volatile read //volatile 讀
System.out.println(“First: ” + first); // Read after volatile read // volatile 讀之後的讀
System.out.println(“Second: ” + second); // Read after volatile read// volatile 讀之後的讀
System.out.println(“Third: ” + third); // Read after volatile read// volatile 讀之後的讀
JVM 永遠不會轉換為如下的順序:
System.out.println(“First: ” + first); // Read before volatile read! Will never happen! //volatile 讀之前的讀!永遠不可能出現!
System.out.println(“Fiag is set to : ” + hasValue); // volatile read //volatile 讀
System.out.println(“Second: ” + second);
System.out.println(“Third: ” + third);
但是,JVM 也有可能會對最後的三個讀操作重排序,只要它們在 volatile 變數讀之後即可。
我感覺 Volatile 變數會對效能有一定的影響。
你的感覺是對的,因為 volatile 變數強制訪問主存,而訪問主存肯定被訪問 CPU 快取慢。同時,它還防止 JVM 對程式的最佳化,這也會降低效能。
我們總能用 Volatile 變數來維護多執行緒之間的資料一致性嗎?
非常不幸,這是不行的。當多個執行緒讀寫同一個變數時,僅僅靠 volatile 是不足以保證一致性的,考慮下麵這個 UnsafeCounter 類:
public class UnsafeCounter {
private volatile int counter;
public void inc() {
counter++;
}
public void dec() {
counter–;
}
public int get() {
return counter;
}
}
測試如下:
public class UnsafeCounterTest {
public void testUnsafeCounter() throws InterruptedException {
UnsafeCounter unsafeCounter = new UnsafeCounter();
Thread first = new Thread(() -> {
for (int i = 0; i < 5; i++) {
unsafeCounter.inc();
}
});
Thread second = new Thread(() -> {
for (int i = 0; i < 5; i++) {
unsafeCounter.dec();
}
});
first.start();
second.start();
first.join();
second.join();
System.out.println(“Current counter value: ” + unsafeCounter.get());
}
}
這段程式碼具有非常好的自說明性。一個執行緒增加計數器,另一個執行緒將計數器減少同樣次數。執行這個測試,期望的結果是計數器的值為 0,但這無法得到保證。大部分時候是 0,但有的時候是 -1, -2, 1, 2 等,任何位於[-5, 5]之間的整數都有可能。
為什麼會發生這種情況?這是因為對計數器的遞增和遞減操作都不是原子的——它們不是一次完成的。這兩種操作都由多個步驟組成,這些步驟可能相互交叉。你可以認為遞增操作如下:
-
讀取計數器的值。
-
加 1。
-
將新的值寫回計數器。
遞減操作的過程如下:
-
讀取計數器的值。
-
減 1。
-
將新的值寫回計數器。
現在我們考慮一下如下的執行步驟
第一個執行緒從主存中讀取計數器的值,初始值是 0,然後加 1。
第二個執行緒也從主存中讀取計數器的值,它讀取到的值也是 0,然後進行減 1 操作。
第一執行緒將新的計數器的值寫回記憶體,將值設定為 1。
第二個執行緒也將新的值寫回記憶體,將值設定為 -1。
怎麼防止這類事件的發生?
使用同步:
public class SynchronizedCounter {
private int counter;
public synchronized void inc() {
counter++;
}
public synchronized void dec() {
counter–;
}
public synchronized int get() {
return counter;
}
}
或者使用 AtomicInteger:
public class AtomicCounter {
private AtomicInteger atomicInteger = new AtomicInteger();
public void inc() {
atomicInteger.incrementAndGet();
}
public void dec() {
atomicInteger.decrementAndGet();
}
public int get() {
return atomicInteger.intValue();
}
}
我個人的選擇是使用 AtomicInteger,因為 synchronized 只允許一個執行緒訪問 inc/get/get 方法,對效能影響較大。
我註意到採用 Synchronized 的版本並沒有將計數器標識為 volatile,難道這意味著……?
對的。使用 synchronized 關鍵字也會在陳述句之間建立 happens-before 關係。進入一個同步方法或塊時,會將之前的陳述句和該方法或塊內部的陳述句建立 happens-before 關係。
檢視完整的建立 happens-before 關係的情況串列,請檢視這裡。
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/package-summary.html#MemoryVisibility
關於一開始提到的 volatile, 這些是所有我想說的。所有的例子都上傳到了我的 github 倉庫。
https://github.com/sayembd/JavaSamples/commit/d1f72ee8bf76f69740b6d22e35d8e1431f279afb
看完本文有收穫?請轉發分享給更多人
關註「ImportNew」,提升Java技能