學習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關系)?!癆ctionA 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
假設有以下代碼與執行線程:
那么,線程2中b的值會是1嗎?來分析下。假設執行軌跡為以下所示:
線程1 線程2p1: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 線程2p1: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實現吧:
01 public?E set(int?index, E element) {
02 ????final?ReentrantLock lock =?this.lock;
05 ????????????Object[] elements = getArray();
06 ????????????Object oldValue = elements[index];
08 ????????if?(oldValue != element) {
09 ????????????int?len = elements.length;
10 ????????????Object[] newElements = Arrays.copyOf(elements, len);
11 ????????????newElements[index] = element;
12 ????????????setArray(newElements);
14 ????????????// Not quite a no-op; ensures volatile write semantics
15 ????????????setArray(elements);
17 ????????return?(E)oldValue;
有意思的地方是else里的setArray(elements)調用,看看setArray做了什么:
1 final?void?setArray(Object[] a) {
一個簡單的賦值,array是volatile類型。elements是從getArray()方法取過來的,getArray()實現如下:
1 final?Object[] getArray() {
也很簡單,直接返回array。取得array,又重新賦值給array,有甚意義?setArray(elements)上有條簡單的注釋,但可能不是太容易明白。正如前文提到的那條javadoc上的規定,放入一個元素到并發集合與從并發集合中取元素之間要有hb關系。set是放入,get是取(取還有其他方法),怎么才能使得set與get之間有hb關系,set方法的最后有unlock操作,如果get里有對這個鎖的lock操作,那么就好滿足了,但是get并沒有加鎖:
1 public?E get(int?index) {
2 ????return?(E)(getArray()[index]);
但是get里調用了getArray,getArray里有讀volatile的操作,只需要set走任意代碼路徑都能遇到寫volatile操作就能滿足條件了,這里主要就是if…else…分支,if里有個setArray操作,如果只是從單線程角度來說,else里的setArray(elements)是沒有必要的,但是為了使得走else這個代碼路徑時也有寫volatile變量操作,就需要加一個setArray(elements)調用。
最后,以FutureTask結尾,這應該是個比較有名的例子了,隨提一下。提交任務給線程池,我們可以通過FutureTask來獲取線程的運行結果。絕大部分時候,將結果寫入FutureTask的線程和讀取結果的不會是同一個線程。寫入結果的代碼如下:
03 ????????int?s = getState();
06 ????????if?(s == CANCELLED) {
07 ????????????// aggressively release to set runner to null,
08 ????????????// in case we are racing with a cancel request
09 ????????????// that will try to interrupt runner
10 ????????????releaseShared(0);
13 ????????if?(compareAndSetState(s, RAN)) {
14 ????????????result = v;
15 ????????????releaseShared(0);
獲取結果的代碼如下:
1 V innerGet(long?nanosTimeout)?throws?InterruptedException, ExecutionException, TimeoutException {
2 ????if?(!tryAcquireSharedNanos(0, nanosTimeout))
3 ????????throw?new?TimeoutException();
4 ????if?(getState() == CANCELLED)
5 ????????throw?new?CancellationException();
6 ????if?(exception !=?null)
7 ????????throw?new?ExecutionException(exception);
結果就是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規則,文中還出現了許多名詞沒有做更多介紹,為啥沒介紹?介紹開來就是一本書啦,他們就是《Java Memory Model》、《Java Concurrency in Practice》、《Concurrent Programming in Java: Design Principles and Patterns》等,這些書里找定義與解釋吧。
原創文章,轉載請注明: ?轉載自并發編程網 – ifeve.com本文鏈接地址: ?happens-before俗解
總結
以上是生活随笔 為你收集整理的happens-before俗解 的全部內容,希望文章能夠幫你解決所遇到的問題。
如果覺得生活随笔 網站內容還不錯,歡迎將生活随笔 推薦給好友。