前言
熟悉 Java 併發編程的都知道,JMM(Java 內存模型) 中的 happen-before(簡稱 hb)規則,該規則定義了 Java 多線程操做的有序性和可見性,防止了編譯器重排序對程序結果的影響。java
按照官方的說法:編程
當一個變量被多個線程讀取而且至少被一個線程寫入時,若是讀操做和寫操做沒有 HB 關係,則會產生數據競爭問題。小程序
要想保證
操做 B
的線程看到操做 A
的結果(不管A
和B
是否在一個線程),那麼在A
和B
之間必須知足 HB 原則,若是沒有,將有可能致使重排序。緩存
當缺乏 HB 關係時,就可能出現重排序問題。安全
HB 有哪些規則?
這個你們都很是熟悉了應該,大部分書籍和文章都會介紹,這裏稍微回顧一下:多線程
- 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操做先行發生於書寫在後面的操做;
- 鎖定規則:在監視器鎖上的解鎖操做必須在同一個監視器上的加鎖操做以前執行。
- volatile變量規則:對一個變量的寫操做先行發生於後面對這個變量的讀操做;
- 傳遞規則:若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C;
- 線程啓動規則:Thread對象的start()方法先行發生於此線程的每個動做;
- 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
- 線程終結規則:線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
- 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始;
其中,傳遞規則我加粗了,這個規則相當重要。如何熟練的使用傳遞規則是實現同步的關鍵。併發
而後,再換個角度解釋 HB:當一個操做 A HB 操做 B,那麼,操做 A 對共享變量的操做結果對操做 B 都是可見的。app
同時,若是 操做 B HB 操做 C,那麼,操做 A 對共享變量的操做結果對操做 B 都是可見的。ui
而實現可見性的原理則是 cache protocol 和 memory barrier。經過緩存一致性協議和內存屏障實現可見性。spa
如何實現同步?
在 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 修飾也是能夠的。
咱們分析一下,樓主畫了一個圖:
咱們分析這個圖:
- 首先,紅色和黃色表示不一樣的線程操做。
- 紅色線程對 num 變量作 ++,而後修改了 volatile 變量,這個是符合
程序次序規則的
。也就是 1 HB 2. - 紅色線程對 volatile 的寫 HB 黃色線程對 volatile 的讀,也就是 2 HB 3.
- 黃色線程讀取 volatile 變量,而後對 num 變量作 ++,符合
程序次序規則
,也就是 3 HB 4. - 根據
傳遞性規則
,1 確定 HB 4. 因此,1 的修改對 4來講都是可見的。
注意: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 規則的組合,能夠實現對普通共享變量的正確使用。