天天看點

happens-before概要俗解

學習Java并發,到後面總會接觸到happens-before偏序關系。初接觸玩意兒簡直就是不知所雲,下面是經過一段時間折騰後個人對此的一點淺薄了解,希望對初接觸的人有幫助。如有不正确之處,歡迎指正。

synchronized、大部分鎖,衆所周知的一個功能就是使多個線程互斥/串行的(共享鎖允許多個線程同時通路,如讀鎖)通路臨界區,但他們的第二個功能 —— 保證變量的可見性 —— 常被遺忘。

為什麼存在可見性問題?簡單介紹下。相對于記憶體,CPU的速度是極高的,如果CPU需要存取資料時都直接與記憶體打交道,在存取過程中,CPU将一直空閑,這是一種極大的浪費,媽媽說,浪費是不好的,是以,現代的CPU裡都有很多寄存器,多級cache,他們比記憶體的存取速度高多了。某個線程執行時,記憶體中的一份資料,會存在于該線程的工作存儲中(working memory,是cache和寄存器的一個抽象,這個解釋源于《Concurrent Programming in Java: Design Principles and Patterns, Second Edition》§2.2.7,原文:Every thread is defined to have a working memory (an abstraction of caches and registers) in which to store values. 有不少人覺得working memory是記憶體的某個部分,這可能是有些譯作将working memory譯為工作記憶體的緣故,為避免混淆,這裡稱其為工作存儲,每個線程都有自己的工作存儲),并在某個特定時候回寫到記憶體。單線程時,這沒有問題,如果是多線程要同時通路同一個變量呢?記憶體中一個變量會存在于多個工作存儲中,線程1修改了變量a的值什麼時候對線程2可見?此外,編譯器或運作時為了效率可以在允許的時候對指令進行重排序,重排序後的執行順序就與代碼不一緻了,這樣線程2讀取某個變量的時候線程1可能還沒有進行寫入操作呢,雖然代碼順序上寫操作是在前面的。這就是可見性問題的由來。

我們無法枚舉所有的場景來規定某個線程修改的變量何時對另一個線程可見。但可以制定一些通用的規則,這就是happens-before。它是一個偏序關系,Java記憶體模型中定義了許多Action,有些Action之間存在happens-before關系(并不是所有Action兩兩之間都有happens-before關系)。“ActionA happens-before ActionB”這樣的描述很擾亂視線,是不是?OK,換個描述,如果ActionA happens-before ActionB,我們可以記作hb(ActionA,ActionB)或者記作ActionA < ActionB,這貨在這裡已經不是小于号了,它是偏序關系,是不是隐約有些離散數學的味道,不喜歡?嗯,我也不喜歡,so,下面都用hb(ActionA,ActionB)這種方式來表述。

從Java記憶體模型中取兩條happens-before關系來瞅瞅:

  • An unlock on a monitor happens-before every subsequent lock on that monitor.
  • A write to a volatile field happens-before every subsequent read of that volatile.

“對一個monitor的解鎖操作happens-before後續對同一個monitor的加鎖操作”、“對某個volatile字段的寫操作happens-before後續對同一個volatile字段的讀操作”……莫名其妙、不知所雲、不能了解……就是這個心情。是不是說解鎖操作要先于鎖定操作發生?這有違正常啊。确實不是這麼了解的。happens-before規則不是描述實際操作的先後順序,它是用來描述可見性的一種規則,下面我給上述兩條規則換個說法:

  • 如果線程1解鎖了monitor a,接着線程2鎖定了a,那麼,線程1解鎖a之前的寫操作都對線程2可見(線程1和線程2可以是同一個線程)。
  • 如果線程1寫入了volatile變量v(這裡和後續的“變量”都指的是對象的字段、類字段和數組元素),接着線程2讀取了v,那麼,線程1寫入v及之前的寫操作都對線程2可見(線程1和線程2可以是同一個線程)。

是不是很簡單,瞬間覺得這篇文章弱爆了,說了那麼多,其實就是在說“如果hb(a,b),那麼a及之前的寫操作在另一個線程t1進行了b操作時都對t1可見(同一個線程就不會有可見性問題,下面不再重複了)”。雖然弱爆了,但還得有始有終,是不是,繼續來,再看兩條happens-before規則:

  • All actions in a thread happen-before any other thread successfully returns from a join() on that thread.
  • Each action in a thread happens-before every subsequent action in that thread.

通俗版:

  • 線程t1寫入的所有變量(所有action都與那個join有hb關系,當然也包括線程t1終止前的最後一個action了,最後一個action及之前的所有寫入操作,是以是所有變量),在任意其它線程t2調用t1.join()成功傳回後,都對t2可見。
  • 線程中上一個動作及之前的所有寫操作在該線程執行下一個動作時對該線程可見(也就是說,同一個線程中前面的所有寫操作對後面的操作可見)

大緻都是這個樣子的解釋。

happens-before關系有個很重要的性質,就是傳遞性,即,如果hb(a,b),hb(b,c),則有hb(a,c)。

Java記憶體模型中隻是列出了幾種比較基本的hb規則,在Java語言層面,又衍生了許多其他happens-before規則,如ReentrantLock的unlock與lock操作,又如AbstractQueuedSynchronizer的release與acquire,setState與getState等等。

接下來用hb規則分析兩個實際的可見性例子。

  • 看個CopyOnWriteArrayList的例子,代碼中的list對象是CopyOnWriteArrayList類型,a是個靜态變量,初始值為0

假設有以下代碼與執行線程:

線程1 線程2

1

a = 

1

;

2

list.set(

1

,

"t"

);

1

list.get(

);

2

int

b = a;

那麼,線程2中b的值會是1嗎?來分析下。假設執行軌迹為以下所示:

線程1					線程2
	p1:a = 1			
	p2:list.set(1,"t")		
						p3:list.get(2)
						p4:int b = a;
      

p1,p2是同一個線程中的,p3,p4是同一個線程中的,是以有hb(p1,p2),hb(p3,p4),要使得p1中的指派操作對p4可見,那麼隻需要有hb(p1,p4),前面說過,hb關系具有傳遞性,那麼若有hb(p2,p3)就能得到hb(p1,p4),p2,p3是不是存在hb關系?翻翻javaapi,發現有如下描述:

Actions in a thread prior to placing an object into any concurrent collection happen-before actions subsequent to the access or removal of that element from the collection in another thread.

p2是放入一個元素到并發集合中,p3是從并發集合中取,符合上述描述,是以有hb(p2,p3).也就是說,在這樣一種執行軌迹下,可以保證線程2中的b的值是1.如果是下面這樣的執行軌迹呢?

線程1					線程2
	p1:a = 1				
	        				p3:list.get(2)
	p2:list.set(1,"t")				
						p4:int b = a;
      

依然有hb(p1,p2),hb(p3,p4),但是沒有了hb(p2,p3),得不到hb(p1,p4),雖然線程1給a指派操作在執行順序上是先于線程2讀取a的,但jmm不保證最後b的值是1.這不是說一定不是1,隻是不能保證。如果程式裡沒有采取手段(如加鎖等)排除類似這樣的執行軌迹,那麼是無法保證b取到1的。像這樣的程式,就是沒有正确同步的,存在着資料争用(data race)。

既然提到了CopyOnWriteArrayList,那麼順便看下其set實作吧:

public E set(int index, E element) {
	final ReentrantLock lock = this.lock;
	lock.lock();
	try {
			Object[] elements = getArray();
			Object oldValue = elements[index];

		if (oldValue != element) {
			int len = elements.length;
			Object[] newElements = Arrays.copyOf(elements, len);
			newElements[index] = element;
			setArray(newElements);
		} else {
			// Not quite a no-op; ensures volatile write semantics
			setArray(elements);
		}
		return (E)oldValue;
	} finally {
		lock.unlock();
	}
}
           

有意思的地方是else裡的setArray(elements)調用,看看setArray做了什麼:

final void setArray(Object[] a) {
	array = a;
}
           

一個簡單的指派,array是volatile類型。elements是從getArray()方法取過來的,getArray()實作如下:

final Object[] getArray() {
	return array;
}
           

也很簡單,直接傳回array。取得array,又重新指派給array,有甚意義?setArray(elements)上有條簡單的注釋,但可能不是太容易明白。正如前文提到的那條javadoc上的規定,放入一個元素到并發集合與從并發集合中取元素之間要有hb關系。set是放入,get是取(取還有其他方法),怎麼才能使得set與get之間有hb關系,set方法的最後有unlock操作,如果get裡有對這個鎖的lock操作,那麼就好滿足了,但是get并沒有加鎖:

public E get(int index) {
	return (E)(getArray()[index]);
}
           

但是get裡調用了getArray,getArray裡有讀volatile的操作,隻需要set走任意代碼路徑都能遇到寫volatile操作就能滿足條件了,這裡主要就是if…else…分支,if裡有個setArray操作,如果隻是從單線程角度來說,else裡的setArray(elements)是沒有必要的,但是為了使得走else這個代碼路徑時也有寫volatile變量操作,就需要加一個setArray(elements)調用。

最後,以FutureTask結尾,這應該是個比較有名的例子了,随提一下。送出任務給線程池,我們可以通過FutureTask來擷取線程的運作結果。絕大部分時候,将結果寫入FutureTask的線程和讀取結果的不會是同一個線程。寫入結果的代碼如下:

void innerSet(V v) {
	for (;;) {
		int s = getState();
		if (s == RAN)
			return;
		if (s == CANCELLED) {
			// aggressively release to set runner to null,
			// in case we are racing with a cancel request
			// that will try to interrupt runner
			releaseShared(0);
			return;
		}
		if (compareAndSetState(s, RAN)) {
			result = v;
			releaseShared(0);
			done();
			return;
		}
	}
}
           

擷取結果的代碼如下:

V innerGet(long nanosTimeout) throws InterruptedException, ExecutionException, TimeoutException {
	if (!tryAcquireSharedNanos(0, nanosTimeout))
		throw new TimeoutException();
	if (getState() == CANCELLED)
		throw new CancellationException();
	if (exception != null)
		throw new ExecutionException(exception);
	return result;
}
           

結果就是result變量,但result不是volatile變量,而這裡有沒有加鎖操作,那麼怎麼保證寫入到result的值對讀取result的線程可見?這裡是經過精心設計的,因為讀寫volatile的開銷很小,但畢竟還是存在開銷的,且作為一個基礎類庫,追求最後一點性能也不為過,因為無法預知所有可能的使用場景。這裡主要利用了AbstractQueuedSynchronizer中的releaseShared與tryAcquireSharedNanos存在hb關系。

線程1:			線程2:
		p1:result = v;
		p2:releaseShared(0);
					p3:tryAcquireSharedNanos(0, nanosTimeout)
					p4:return result;
      

正如前面分析的那樣,在這個執行軌迹中,有hb(p1,p2),hb(p3,p4)且有hb(p2,p3),所有有hb(p1,p4),是以,即使result是普通變量,p1中的寫操作也是對p4可見的。但,會不會存在這樣的軌迹呢:

線程1:				線程2:
		p1:result = v;			
		              			p3:tryAcquireSharedNanos(0, nanosTimeout)
		p2:releaseShared(0);
						p4:return result;
      

這也是一個關鍵點所在,這種情況是決計不會發生的。因為如果沒有p2操作,那麼p3在執行tryAcquireSharedNanos時會一直被阻塞,直到releaseShared操作執行了或超過了nanosTimeout逾時時間或被中斷抛出InterruptedException,若是releaseShared執行了,則就變成了第一個軌迹,若是逾時,那麼傳回值是false,代碼邏輯中就直接抛出了異常,不會去取result了,是以,這個地方設計的很精巧。這就是所謂的“捎帶同步(piggybacking on synchronization)”,即,沒有特意為result變量的讀寫設定同步,而是利用了其他同步動作時“捎帶”的效果。但在我們自己寫代碼時,應該盡可能避免這樣的做法,因為,不好了解,對編碼人員要求高,維護難度大。

-------------------------------------------------------------------------------------

以下是完整hb規則:

重排序在多線程環境下出現的機率還是挺高的,在關鍵字上有volatile和synchronized可以禁用重排序,除此之外還有一些規則,也正是這些規則,使得我們在平時的程式設計工作中沒有感受到重排序的壞處。

  • 程式次序規則(Program Order Rule):在一個線程内,按照代碼順序,書寫在前面的操作先行發生于書寫在後面的操作。準确地說應該是控制流順序而不是代碼順序,因為要考慮分支、循環等結構。
  • 螢幕鎖定規則(Monitor Lock Rule):一個unlock操作先行發生于後面對同一個對象鎖的lock操作。這裡強調的是同一個鎖,而“後面”指的是時間上的先後順序,如發生在其他線程中的lock操作。
  • volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作發生于後面對這個變量的讀操作,這裡的“後面”也指的是時間上的先後順序。
  • 線程啟動規則(Thread Start Rule):Thread獨享的start()方法先行于此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule):線程中的每個操作都先行發生于對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的傳回值檢測到線程已經終止執行。
  • 線程中斷規則(Thread Interruption Rule):對線程interrupte()方法的調用優先于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否已中斷。
  • 對象終結原則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論。

正是以上這些規則保障了happen-before的順序,如果不符合以上規則,那麼在多線程環境下就不能保證執行順序等同于代碼順序,也就是“如果在本線程中觀察,所有的操作都是有序的;如果在一個線程中觀察另外一個線程,則不符合以上規則的都是無序的”,是以,如果我們的多線程程式依賴于代碼書寫順序,那麼就要考慮是否符合以上規則,如果不符合就要通過一些機制使其符合,最常用的就是synchronized、Lock以及volatile修飾符。

本文隻是簡單地解釋了下hb規則,文中還出現了許多名詞沒有做更多介紹,為啥沒介紹?介紹開來就是一本書啦,他們就是《Java Memory Model》、《Java Concurrency in Practice》、《Concurrent Programming in Java: Design Principles and Patterns》等,這些書裡找定義與解釋吧。

繼續閱讀