陷阱共享變數需要適當的同步
考慮這個例子:
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (!stop) {
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
tt.stop = true; // Tell child thread to stop.
}
}
該程式的目的是啟動一個執行緒,讓它執行 1000 毫秒,然後通過設定 stop
標誌使其停止。
它會按預期工作嗎?
可能是,可能不是。
當 main
方法返回時,應用程式不一定會停止。如果已建立另一個執行緒,並且該執行緒尚未標記為守護程式執行緒,則應用程式將在主執行緒結束後繼續執行。在此示例中,這意味著應用程式將繼續執行,直到子執行緒結束。當 tt.stop
設定為 true
時,應該會發生這種情況。
但事實並非如此。實際上,子執行緒在觀察到值為 true
的 stop
後會停止。那會發生嗎?可能是,可能不是。
Java 語言規範保證執行緒中的記憶體讀取和寫入對於該執行緒是可見的,根據原始碼中的語句順序。但是,通常,當一個執行緒寫入而另一個執行緒(隨後)讀取時,不保證這一點。為了獲得有保證的可見性,需要在寫入和後續讀取之間存在一系列先發生關係。在上面的示例中,沒有用於更新 stop
標誌的鏈,因此無法保證子執行緒將 stop
更改為 true
。
(作者注意:Java 記憶體模型應該有一個單獨的主題,以深入瞭解技術細節。)
我們如何解決這個問題?
在這種情況下,有兩種簡單的方法可以確保 stop
更新可見:
-
宣稱
stop
為volatile
; 即private volatile boolean stop = false;
對於
volatile
變數,JLS 指定在一個執行緒的寫入和第二個執行緒的後一個讀取之間存在一個先發生的關係。 -
使用互斥鎖進行同步,如下所示:
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (true) {
synchronize (this) {
if (stop) {
break;
}
}
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
synchronize (tt) {
tt.stop = true; // Tell child thread to stop.
}
}
}
除了確儲存在互斥之外,JLS 還指定在一個執行緒中釋放互斥鎖與在第二個執行緒中獲得相同的互斥鎖之間存在先發生關係。
但是不是分配原子?
是的!
但是,這一事實並不意味著更新的效果將同時顯示在所有執行緒上。只有恰當的先發生過關係鏈才能保證這一點。
他們為什麼這麼做?
在 Java 中進行多執行緒程式設計的程式設計師第一次發現記憶體模型具有挑戰性。程式的行為方式不直觀,因為自然的期望是寫入是統一可見的。那麼 Java 設計師為什麼要這樣設計記憶體模型呢?
它實際上歸結為效能和易用性之間的折衷(對程式設計師而言)。
現代計算機體系結構由具有單獨暫存器組的多個處理器(核)組成。主儲存器可供所有處理器或處理器組訪問。現代計算機硬體的另一個特性是訪問暫存器的訪問速度通常比訪問主儲存器快幾個數量級。隨著核心數量的增加,很容易看出對主儲存器的讀寫可能成為系統的主要效能瓶頸。
通過在處理器核和主儲存器之間實現一個或多個級別的儲存器快取記憶體來解決這種不匹配。每個核心通過其快取訪問儲存器單元。通常,只有在存在快取記憶體未命中時才會發生主儲存器讀取,而只有在需要重新整理快取記憶體行時才會發生主儲存器寫入。對於每個核心的工作記憶體位置都適合其快取的應用程式,核心速度不再受主記憶體速度/頻寬的限制。
但是當多個核心讀寫共享變數時,這給我們帶來了新的問題。最新版本的變數可能位於一個核心的快取中。除非該核心將快取記憶體行重新整理到主儲存器,並且其他核心使其舊版本的快取記憶體副本無效,否則其中一些核心可能會看到該變數的陳舊版本。但是,如果每次有快取記憶體寫入(以防萬一存在另一個核的讀取)時,快取記憶體被重新整理到記憶體中,這將不必要地消耗主儲存器頻寬。
硬體指令集級別使用的標準解決方案是提供快取記憶體失效和快取記憶體直寫的指令,並將其留給編譯器決定何時使用它們。
回到 Java。記憶體模型的設計使得 Java 編譯器不需要在不需要它們的情況下發出快取失效和直寫指令。假設程式設計師將使用適當的同步機制(例如原始互斥體,volatile
,更高階別的併發類等)來指示它需要記憶體可見性。在沒有事先發生關係的情況下,Java 編譯器可以自由地假設不需要快取操作(或類似)。
這對於多執行緒應用程式具有顯著的效能優勢,但缺點是編寫正確的多執行緒應用程式並不是一件簡單的事情。程式設計師也必須瞭解他或她在做什麼。
為什麼我不能重現這個?
有很多原因導致像這樣的問題難以重現:
-
如上所述,不正確處理記憶體可見性問題的後果通常是編譯後的應用程式無法正確處理記憶體快取。但是,正如我們上面提到的,記憶體快取無論如何都經常被重新整理。
-
更改硬體平臺時,記憶體快取的特徵可能會更改。如果你的應用程式未正確同步,這可能會導致不同的行為。
-
你可能正在觀察偶然同步的影響。例如,如果新增跟蹤,則通常會在 I / O 流的幕後發生一些同步,從而導致快取重新整理。因此,新增跟蹤圖通常會導致應用程式的行為不同。
-
在偵錯程式下執行應用程式會導致 JIT 編譯器對其進行不同的編譯。斷點和單步踩踏加劇了這種情況。這些效果通常會改變應用程式的行為方式。
這些因素導致同步不充分的錯誤特別難以解決。