Java-如何不使用-volatile-和鎖實現共享變數的同步操作
from: http://thinkinjava.cn/2018/06/Java-%E5%A6%82%E4%BD%95%E4%B8%8D%E4%BD%BF%E7%94%A8-volatile-%E5%92%8C%E9%94%81%E5%AE%9E%E7%8E%B0%E5%85%B1%E4%BA%AB%E5%8F%98%E9%87%8F%E7%9A%84%E5%90%8C%E6%AD%A5%E6%93%8D%E4%BD%9C/
前言
熟悉 Java 併發程式設計的都知道,JMM(Java 記憶體模型) 中的 happen-before(簡稱 hb)規則,該規則定義了 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 都是可見的。
同時,如果 操作 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 修飾也是可以的。
我們分析一下,樓主畫了一個圖:
我們分析這個圖:
- 首先,紅色和黃色表示不同的執行緒操作。
- 紅色執行緒對 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 實現的。
其他規則實現同步
1.用執行緒終結規則實現:
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(); }
2.用執行緒 start 規則實現:
static int a = 1; public static void main(String[] args) { Thread tb = new Thread(() -> { System.out.println(a); }); Thread ta = new Thread(() -> { a = 2; tb.start(); }); 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 規則的組合,可以實現對普通共享變數的正確使用。