《Java併發編程實踐》五(4):java內存模型(終結篇)

到此爲止,我們基本已經覆蓋了java併發編程涉及的所有技術主題,包括安全發佈、同步機制。這些技術之所以這樣設計,都與Java內存模型(Java Memory Model, JMM)有關。我們編程時不會直接與java內存模型打交道,但是理解它對能讓我們更加得心應手地使用之前學習到的各種技術。這一章,就讓我們揭開java內存模型的神祕面紗。

內存模型

假設有一個線程執行了一句代碼:aVariable = 3,內存模型要解決一個問題:在何種情況下,一個線程(包括其他線程)讀取aVariable時能得到值——3。這看起來是一個很傻的問題,但是如果缺少同步機制,其他線程可能不會立即(甚至永遠不會)讀到其他線程寫入的值。

示例

我們先通過一段示例代碼,來展示這個問題。

public class PossibleReordering {
	static int x = 0, y = 0;
	static int a = 0, b = 0;
	
	public static void main(String[] args) hrows InterruptedException {
		Thread one = new Thread(new Runnable() {
			public void run() {
				a = 1;
				x = b;
			}
		});
		Thread other = new Thread(new Runnable() {
			public void run() {
				b = 1;
				y = a;
			}
		});
		one.start(); other.start();
		one.join(); other.join();
		System.out.println("( "+ x + "," + y + ")");
	}
}

PossibleReordering沒有采用任何同步措施,打印結果是靜態變量x,y的最終值,從線程調度可能性出發,我們很容易推斷出(x,y)可能的結果有:

  • (1,0):線程other先於one運行;
  • (0,1):線程other晚於one運行
  • (1,1):線程other和one交叉運行。

但實際上(x,y)還可能是(0,0),它產生的根源有兩個,一是指令重排,線程one的代碼在編譯器階段經過指令重排後執行的可能是x=b;a=1,線程other也類似;二是內存可見性問題,線程one雖然執行了a=1,但是線程other讀取的卻還是0,反過來對變量b也一樣。

硬件內存模型

如果從更底層CPU執行的角度,來分析上面的問題,原因如下:

  • 編譯器可能出於優化目的,生成指令的順序與源代碼的順序不一致,或者乾脆將值暫存在寄存器而不是主內存;
  • 處理器可能亂序、並行執行指令;
  • 處理器先將變量寫入緩存(L1,L2),然後再提交到主內存,產生兩個後果:
    • 處理器暫存於寄存器或緩存的值,對其它處理器是不可見的;
    • 代碼寫入多個變量的順序和提交主內存的順序並不保證一致;
  • 多處理器架構下,每個處理器都有自己的寄存器、緩存

編譯器的的代碼優化看起來是編譯時靜態優化,旦也是基於CPU的架構和特性而進行的。

上面的解釋展示了代碼執行的不可預測性,這樣我們就能理解,一個線程寫入的值,另外一個線程爲什麼會看不到;又或者,一個線程內存操作的順序,在另外一個線程看來是完全不同的。但是一個完全不可預測的平臺是無法使用的,每個硬件平臺會一套相關管理機制,我們可以稱之爲內存模型:

所謂內存模型,是指該平臺在內存的可見性、一致性方面,提供什麼保證(不提供什麼保證),以及程序可以使用哪些特殊指令以獲得額外的保證。

Java內存模型(JMM)

由於不同處理器架構的內存模型存在差異,Java內存模型彌合了這種差異性,提供了一個更加抽象和豐富的內存模型。

JMM是圍繞特定行爲的內存可見性來制定規則的,它定義了一種叫做”happens-before"(先行發生)的行爲之間的偏序關係,兩個行爲A和B,如果A happens-before B,說明只要A在時間上在B之前,那麼A的結果(這裏特指A修改內存)對B可見。如果對同一個數據的讀、寫行爲之間沒有happens-before關係,那麼我們認爲存在“數據競爭”,一個正確同步程序是沒有數據競爭的,它展現出程序語義層面的順序一致性

JMM的happens-before規則如下:

  • 程序次序規則(Program order rule):單個線程內,按程序執行順序,每個行爲happens-before後面執行的行爲;
  • 監視器鎖規則(Monitor Lock rule):對同一個監視器鎖,解鎖操作happens-before加鎖操作;
  • Volatile變量規則(Volatile variable rule):對一個Volatile變量,寫操作happens-before讀操作;
  • 線程開始規則(Thread start rule):Thread.start操作happens-before該線程內的所有操作;
  • 線程終結規則(Thread termination rule):線程內的所有行爲happens-before其他線程檢測到線程已經終結,該檢測行爲可以是Thread.join返回,或Thread.isAlive返回true;
  • 中斷規則(Interruption rule):一個線程調用目標線程的interrupt happens-before 目標線程檢測到interrupt狀態,檢測行爲可以是捕獲InterruptedException,或Thread.interrupted();
  • Finalizer規則:對象構造方法的結束happens-before對象finalizer的開始;
  • 傳遞性(Transitivity):如果A happens-before B, B happens-before C,那麼A happens-before C。

程序次序規則是最基本的規則,它爲單線程程序提供了順序一致性的運行環境,這樣一來,我們在編寫不涉及跨線程共享數據的代碼時,完全不需要考慮可見性問題。規則裏只包含了監視器鎖,而沒有涉及ReentrantLock等同步器,因爲這是JVM層面的規則,JVM不會涉及ReentrantLock這樣高級同步技術,實際上ReentrantLock內部通過Volatile變量規則、線程中斷規則也建立起了類似監視器鎖的happens-before規則。最後傳遞性是非常重要的,它隱含的意思是如果A happens-before B,那麼所有A可見的狀態,對B都可見,它把所有規則串聯起來,可在程序全局建立起某種順序一致性保證。

編譯器和JVM會維護這些規則,在必要時會禁止某些優化,以保證操作之間的happens-before關係。

大多數程序員完全不瞭解JMM的情況下也能正常的編寫程序,因爲JMM總是在合理的場景下,提供了恰當的保證,使得編碼難度大大降低。

這裏介紹的規則是面向開發者的JMM規則,瞭解JMM如何實現這些規則需要深入到JVM指令層面,本書沒有涉及這個領域。

捎帶同步策略(Piggybacking)

利用JMM的happens-before規則的傳遞性,我們可以利用現有的同步策略來捎帶(Piggybacking)一些額外的同步數據。 假設操作A happens-before B,那麼線程1執行A之前修改了數據X,那麼線程2在執行B之後能夠讀取到X的最新值;換句話說,我們不必對X施加額外的同步機制,就完成了數據同步。

基於AbstractQueuedSynchronizer的同步器利用這個技巧來攜帶更多的狀態字段,比如ReentrantLock管理額外的字段owner(當前持有鎖的線程)。該策略對代碼順序的細節十分敏感,因此是一種踩鋼絲一般的同步技巧,不建議在項目代碼裏直接使用。

java併發庫裏有些類利用Piggybacking技術實現了自身接口方法之間的happens-before規約,我們可以利用這些規約來安全地同步數據。比如BlockingQueue的enqueue操作happens-before dequeue操作,線程之前可以利用該特性來傳遞數據對象:線程A將對象入隊列,線程B從隊列取出數據,該數據是同步的(只要線程A後續不修改數據)。

其他實現了happens-before規則的類包括:

  • 將一個對象放入線程安全集合 happens-before 將該對象從集合中取出來;
  • CountDownLatch.countDown操作 happens-before 其他線程從await方法返回;
  • Semaphore.release操作 happens-before 從Semaphore.acquire返回;
  • Future對應的任務所有行爲 happens-before 從Future.get返回;
  • 提交一個Callable或Runnable到Executor happens-before task開始執行;
  • 線程到達CyclicBarrier或Exchanger happens-before 其他線程從Barrier或Exchanger恢復運行。

應該說,Java的這些同步器,都提供了happens-before語義,正因爲如此,只要我們以正確的方式使用這些同步器,不需要特別考慮數據同步問題。

對象發佈

第三章展示了安全發佈對象的技術,這些技術的根源是JMM;不安全發佈的根本原因是發佈對象和訪問對象之間缺少happens-before關係。

不安全發佈

先看一段不安全發佈對象的代碼:

@NotThreadSafe
public class UnsafeLazyInitialization {

	private static Resource resource;
	
	public static Resource getInstance() {
		if (resource == null)
			resource = new Resource(); // unsafe publication
		return resource;
	}
}

初始化一個對象涉及寫變量:對象字段的賦值,而發佈對象也涉及寫變量:對象引用賦值;如果不能保證“發佈對象引用 happens-before 其他線程讀該引用”,那麼其他線程看到的那兩次寫變量操作順序是不確定的,也即有可能看到一個未初始化完成的對象。

所以上面這段延遲初始化單例對象的代碼,乍一看只存在創建多個Resource實例的競爭條件,但實際上更嚴重、更詭異的情況是某個線程看到一個未初始化完成的Resource對象。

安全發佈

雖然安全發佈技術源自JMM規則,但是我們應該避免在這個層面解決問題,happens-before關係就像是併發編程領域的“彙編語言”,我們應該用“高級語言”來解決問題。所以我們應該使用線程安全集合(BlockingQueue)、Volatile變量、鎖來實現安全發佈。

安全的單例發佈

有時候我們期望單例對象能夠延遲初始化以提升性能,前面UnsafeLazyInitialization示例展示了錯誤的延遲初始化;只要將靜態變量用synchronized保護就可以fix該錯誤:

@ThreadSafe
public class SafeLazyInitialization {
	private static Resource resource;
	public synchronized static Resource getInstance() {
		if (resource == null)
			resource = new Resource();
		return resource;
	}
}

雖然每次調用getInstance都要經歷加鎖&解鎖,由於這個方法很簡單,而且java監視器鎖的性能很高;所以只要線程競爭不是特別嚴重,SafeLazyInitialization是一個能滿足需求的解決方案。

靜態初始化器

靜態初始化是由JVM在類初始化的時候執行的,發生在類加載之後,類第一次被使用之前。JVM在加載類的時候會加鎖,而每個使用該類的線程至少會獲取該鎖一次(以確保類已經加載)。這樣一來,靜態初始化行爲happens before任意線程行爲。所以通過靜態初始化器來初始化對象,不需要任意額外的同步機制(當然,如果對象是可變的,後續的修改仍需要同步):

@ThreadSafe
public class EagerInitialization {

	private static Resource resource = new Resource();
	
	public static Resource getResource() { 
		return resource; 
	}
}

上面的EagerInitialization展示了通過靜態初始化實現單例的技術,對象的創建時間會比上一種方式要早:當EagerInitialization類第一次被訪問的時候。如果期望對象仍然在getResource第一次被調用時創建,可以使用一個靜態內部類來包裹初始化。

@ThreadSafe
public class ResourceFactory {
	private static class ResourceHolder {
		public static Resource resource = new Resource();
	}
	public static Resource getResource() {
		return ResourceHolder.resource;
	}
}

雙重檢查加鎖(Double-checked locking)

在早期的JVM,鎖的性能代價十分昂貴,於是一些聰明的開發者設計了很多提高併發性能的技巧,有很多實際上無用且危險的,雙重檢查加鎖(DCL)就是此類。

@NotThreadSafe
public class DoubleCheckedLocking {

	private static Resource resource;
	
	public static Resource getInstance() {
		if (resource == null) {
			synchronized (DoubleCheckedLocking.class) {
				if (resource == null)
					resource = new Resource();
			}
		}
		return resource;
	}
}

DCL真正的問題是,只看到同步問題的一個方面:沒有鎖的保護,其他線程可能看到一個過期的引用值(null),於是當讀到null值時,再加鎖來補償這一風險。但忽視了另一個更嚴重的問題:由於亂序,其他線程可能看到一個初始化未完成對象的引用;將變量resource加上volatile關鍵能解決第二個問題。不過從根本上,DCL設計的動機已經不再成立,所以該方案應當被拋棄,靜態初始化纔是最佳選擇。

總結

java內存模型規定了多線程行爲之間的可見性規則;每條規則定義了某種內存操作、同步操作之間存在的可見性關係,叫做happens-before。 行爲A happens-before B,意味着只要A在時間上發生在B之前,A的內存操作結果對B都可見。遵循可見性規則,在多線程共享數據時,我們可以建立起相關操作序列的順序一致性。可見性規則是比較底層(基於內存操作)的規則,理解這個規則很重要,但實際工作中我們應該使用更高層的同步機制,比如同步器、併發集合,它們工作在JMM之上,爲我們提供了更便利(基於API接口)的可見性保證。

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