要編寫正确的并發程式,關鍵問題在于:在通路共享的可變狀态時需要進行正确的管理。
在第一部分,我們介紹了如果通過同步來避免多個線程在同一時刻通路相同的資料,而這節,我們将介紹如何共享和釋出對象,進而使他們能夠安全的由多個線程同時通路。這兩部分形成了建構線程安全類以及通過 java.util.concurrent 類庫來建構并發應用程式的重要基礎。
我們已經知道了同步代碼塊和同步方法可以確定以原子的方式執行操作,但一種常見的誤解是,認為關鍵字 synchronized 隻能用于實作原子性或者确定”臨界區“。其實同步還有另一個重要的方面:記憶體可見性(Memory Visibility)。我們不僅希望防止某個線程正在使用對象而另一個對象正在修改對象狀态, 而且希望確定當一個線程修改了對象狀态後,其他線程能夠看到發生的變化。如果沒有同步,那麼這種情況就無法實作。你可以通過顯示的同步或者類庫中内置的同步來確定對象被安全的釋出。總結來說,就是同步關鍵字可以保證原子通路+記憶體可見。
可見性
在單線程環境中,如果向某個變量寫入值,在沒有其他寫入操作的情況下讀取這個變量,那麼總能得到相同的值。這聽起來很容易能了解。然而,當讀寫操作在不同的線程中執行時,情況卻并非如此。通常,我們無法確定執行讀操作的線程能适時的看到其他線程寫入的值,有時甚至是根本不可能的事情。為了確定多個線程之間對記憶體寫入操作的可見性,必須使用同步機制。
我們通過一個小例子來說明多個線程在沒有同步的情況下共享資料時出現的錯誤。首先看下面這段代碼。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread{
@Override
public void run() {
while(!ready)
Thread.yield();
System.out.print(number);
}
}
public static void main(String[] args) throws InterruptedException{
new ReaderThread().start();
number = 42;
ready = true;
}
}
在代碼中,主線程和讀線程都将通路共享變量 ready 和 number。主線程啟動讀線程,然後将number 設為 42,并将 ready 設為 true 。讀線程一直循環直到發現 ready 的值 變為 true,然後輸出 number 的值。雖然 NoVisibility 看起來會輸出 42,但事實上很可能輸出0,或者根本無法終止。這是因為在代碼中沒有同步機制,無法保證主線程寫入的 ready 值和 number 值對于讀線程來說是可見的。 另一種更奇怪的現象是,NoVisibility 可能會輸出 0 ,因為讀線程可能看到了寫入 ready 的值,卻沒有看到之後寫入 number 的值,這種現象稱為”重排序(Reordering)”。隻要在某個線程中無法檢測到重排序情況,那麼就無法確定線程中的操作将按程式中指定的順序執行。當主線程首先寫入 number ,然後再沒有同步的情況下寫入 ready, 那麼讀線程看到的順序可能與寫入的順序完全相反。
注:在沒有同步的情況下,編譯器、處理器以及運作時等都可能對操作的執行順序進行一些意想不到的調整(重排序)。在缺乏足夠同步的多線程程式中,要相對記憶體操作的執行順序進行判斷,幾乎無法得到正确的結論。重排序看上去似乎是一種失敗的設計,但卻能使 JVM 充分的利用現代多核處理器的強大性能。例如,在缺少同步的情況下,Java 記憶體模型允許編譯器對操作順序進行重排序,并将數值緩存在寄存器中。此外,它還允許 CPU 對操作順序進行重排序,并将數值緩存在處理器特定的緩存中。
失效資料
Novisibility 展示了缺乏同步可能得到一個已經失效的值:失效資料。更糟糕的是,失效值可能不會同時出現:一個線程可能擷取到某個變量的最新值,卻獲得另一個變量的失效值。有時候要確定可見性,僅僅對 set 方法進行同步是不夠的,需要對 get 和 set 方法都需要進行同步。
非原子的64位操作
當線程在沒有同步的情況下讀取變量的時候,可能會得到一個失效值,而不是一個随機值。這種安全性保證稱為最低安全性(out-of-thin-air-safety)。
最低安全性适用于絕大多數變量,但是存在一個例外:非 volatile 類型的64位數值變量(double 和 long)。Java 記憶體模型要求,變量的讀取操作和寫入操作都必須是原子操作,但對于非 volatile 類型的 long 和 double 變量,JVM 允許将64為的讀操作或寫操作分解為兩個32位的操作。當讀取一個非 volatile 類型的 long 變量時,那麼很可能會讀取到某個值的高32位和另一個值的低32位。是以即使不考慮失效資料的問題,在多線程程式中使用共享且可變的long 和 double 等類型的變量也是不安全的。除非用關鍵字 volatile 來聲明它們,或者用鎖保護起來。
注:雖然 JVM 規範并沒有要求64位變量的讀寫為原子操作,但是現在基本上所有的商業虛拟機都将其實作為原子操作。
Volatile 變量
Java 語言提供了一種稍弱的同步機制,即 volatile 變量,用來確定将變量的更新操作通知到其他線程。當把變量聲明為 volatile 之後,虛拟機在運作目前指令的時候,會建立一個記憶體屏障(Memory Barrier 或 Memory Fence),阻止重排序時将後面的指令重排序到記憶體屏障之前的位置。volatile 變量是一種比 synchronized 關鍵字更輕量級的同步機制。
注:隻有一個 CPU 通路記憶體時,并不需要記憶體屏障;但如果有兩個或更多的 CPU 通路同一塊記憶體,且其中一個在觀測另一個,就需要記憶體屏障來保證一緻性了。
雖然 volatile 變量使用十分友善,但也存在着一定的局限性。它通常用來做某個操作完成、發生中斷或者狀态的标志。 雖然 volatile 變量也可以用于表示其他的狀态資訊,但使用時要非常小心。例如, volatile 的語義不足以保證遞增(count++)操作的原子性。