前言
熟悉Java 並發編程的都知道,JMM(Java 內存模型) 中的happen-before(簡稱hb)規則,該規則定義了Java 多線程操作的有序性和可見性,防止了編譯器重排序對程序結果的影響。
Java語言中有一個“先行發生”(happen―before)的規則,它是Java內存模型中定義的兩項操作之間的偏序關係,如果操作A先行發生於操作B,其意思就是說,在發生操作B之前,操作A產生的影響都能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等,它與時間上的先後發生基本沒有太大關係。這個原則特別重要,它是判斷數據是否存在競爭、線程是否安全的主要依據。
按照官方的說法:
當一個變量被多個線程讀取並且至少被一個線程寫入時,如果讀操作和寫操作沒有HB 關係,則會產生數據競爭問題。
要想保證操作B 的線程看到操作A 的結果(無論A 和B 是否在一個線程),那麼在A 和B 之間必須滿足HB 原則,如果沒有,將有可能導致重排序。
當缺少HB 關係時,就可能出現重排序問題。
HB 有哪些規則?
這個大家都非常熟悉了應該,大部分書籍和文章都會介紹,這裡稍微回顧一下:
其中,傳遞規則我加粗了,這個規則至關重要。如何熟練的使用傳遞規則是實現同步的關鍵。
然後,再換個角度解釋HB:當一個操作A HB 操作B,那麼,操作A 對共享變量的操作結果對操作B 都是可見的。
同時,如果操作B HB 操作C,那麼,操作A 對共享變量的操作結果對操作B 都是可見的。
而實現可見性的原理則是cache protocol 和memory barrier。通過緩存一致性協議和內存屏障實現可見性。
如何實現同步?
在Doug Lea 著作《Java Concurrency in Practice》中,有下面的描述:
書中提到:通過組合hb 的一些規則,可以實現對某個未被鎖保護變量的可見性。
但由於這個技術對語句的順序很敏感,因此容易出錯。
樓主接下來,將演示如何通過volatile 規則和程序次序規則實現對一個變量同步。
來一個熟悉的例子:
class ThreadPrintDemo { static int num = 0; static volatile boolean flag = false; public static void main(String[] args) { Thread t1 = new Thread(() -> { for (; 100 > num; ) { if (!flag && (num == 0 || ++num % 2 == 0)) { System.out.println(num); flag = true; } } } ); Thread t2 = new Thread(() -> { for (; 100 > num; ) { if (flag && (++num % 2 != 0)) { System.out.println(num); flag = false; } } } ); t1.start(); t2.start(); }}這段代碼的作用是兩個線程間隔打印出0 - 100 的數字。
熟悉並發編程的同學肯定要說了,這個num 變量沒有使用volatile,會有可見性問題,即:t1 線程更新了num,t2 線程無法感知。
哈哈,樓主剛開始也是這麼認為的,但最近通過研究HB 規則,我發現,去掉num 的volatile 修飾也是可以的。
我們分析一下,樓主畫了一個圖:
我們分析這個圖:
注意: HB 規則保證上一個操作的結果對下一個操作都是可見的。
所以,上面的小程序中,線程A 對num 的修改,線程B 是完全感知的―― 即使num 沒有使用volatile 修飾。
這樣,我們就借助HB 原則實現了對一個變量的同步操作,也就是在多線程環境中,保證了並發修改共享變量的安全性。並且沒有對這個變量使用Java 的原語:volatile 和synchronized 和CAS(假設算的話)。
這可能看起來不安全(實際上安全),也好像不太容易理解。因為這一切都是HB 底層的cache protocol 和memory barrier 實現的。
其他規則實現同步
利用線程終結規則實現:
static int a = 1; public static void main(String[] args) { Thread tb = new Thread(() -> { a = 2; }); Thread ta = new Thread(() -> { try { tb.join(); } catch (InterruptedException e) { //NO } System.out.println(a); }); ta.start(); tb.start(); }利用線程start 規則實現:
static int a = 1; public static void main(String[] args) { Thread tb = new Thread(() -> { System.out.println(a); }); Thread ta = new Thread(() -> { tb.start(); a = 2; }); ta.start(); }這兩個操作,也可以保證變量a 的可見性。
確實有點顛覆之前的觀念。之前的觀念中,如果一個變量沒有被volatile 修飾或final 修飾,那麼他在多線程下的讀寫肯定是不安全的―― 因為會有緩存,導致讀取到的不是最新的。
然而,通過借助HB,我們可以實現。
總結
雖然本文標題是通過happen-before 實現對共享變量的同步操作,但主要目的還是更深刻的理解happen-before,理解他的happen-before 概念其實就是保證多線程環境中,上一個操作對下一個操作的有序性和操作結果的可見性。
同時,通過靈活的使用傳遞性規則,再對規則進行組合,就可以將兩個線程進行同步―― 實現指定的共享變量不使用原語也可以保證可見性。雖然這好像不是很易讀,但也是一種嘗試。
關於如何組合使用規則實現同步,Doug Lea 在JUC 中給出了實踐。
例如老版本的FutureTask 的內部類Sync(已消失),通過tryReleaseShared 方法修改volatile 變量,tryAcquireShared 讀取volatile 變量,這是利用了volatile 規則;
通過在tryReleaseShared 之前設置非volatile 的result 變量,然後在tryAcquireShared 之後讀取result 變量,這是利用了程序次序規則。
從而保證result 變量的可見性。和我們的第一個例子類似:利用程序次序規則和volatile 規則實現普通變量可見性。
而Doug Lea 自己也說了,這個“借助”技術非常容易出錯,要謹慎使用。但在某些情況下,這種“借助”是非常合理的。
實際上,BlockingQueue 也是“借助”了happen-before 的規則。還記得unlock 規則嗎?當unlock 發生後,內部元素一定是可見的。
而類庫中還有其他的操作也“借助”了happen-before 原則:並發容器,CountDownLatch,Semaphore,Future,Executor,CyclicBarrier,Exchanger 等。
總而言之,言而總之:
happen-before 原則是JMM 的核心所在,只有滿足了hb 原則才能保證有序性和可見性,否則編譯器將會對代碼重排序。 hb 甚至將lock 和volatile 也定義了規則。
通過適當的對hb 規則的組合,可以實現對普通共享變量的正確使用。
好了,以上就是這篇文章的全部內容了,希望本文的內容對大家的學習或者工作具有一定的參考學習價值,如果有疑問大家可以留言交流,謝謝大家對武林網的支持。