Java 併發編程(二)對象的可見性

        要編寫正確的併發程序,關鍵問題在於:在訪問共享的可變狀態時需要進行正確的管理。

        在第一部分,我們介紹瞭如果通過同步來避免多個線程在同一時刻訪問相同的數據,而這節,我們將介紹如何共享和發佈對象,從而使他們能夠安全的由多個線程同時訪問。這兩部分形成了構建線程安全類以及通過 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++)操作的原子性。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章