你知道Java是如何解決可見性和有序性問題的嗎?
Java 記憶體模型這個概念,在職場的很多面試中都會考核到,是一個熱門的考點,也是一個人併發水平的具體體現。原因是當併發程式出問題時,需要一行一行地檢查程式碼,這個時候,只有掌握 Java 記憶體模型,才能慧眼如炬地發現問題。
什麼是 Java 記憶體模型?
你已經知道,導致可見性的原因是快取,導致有序性的原因是編譯最佳化,那解決可見性、有序性最直接的辦法就是禁用快取和編譯最佳化,但是這樣問題雖然解決了,我們程式的效能可就堪憂了。
合理的方案應該是 按需禁用快取以及編譯最佳化。 那麼,如何做到“按需禁用”呢?對於併發程式,何時禁用快取以及編譯最佳化只有程式設計師知道,那所謂“按需禁用”其實就是指按照程式設計師的要求來禁用。所以,為了解決可見性和有序性問題,只需要提供給程式設計師按需禁用快取和編譯最佳化的方法即可。
Java 記憶體模型是個很複雜的規範,可以從不同的視角來解讀,站在我們這些程式設計師的視角,本質上可以理解為,Java 記憶體模型規範了 JVM 如何提供按需禁用快取和編譯最佳化的方法。具體來說,這些方法包括
volatile
、
synchronized
和
final
三個關鍵字,以及六項
Happens-Before
規則,這也正是本期的重點內容。
使用 volatile 的困惑
volatile 關鍵字並不是 Java 語言的特產,古老的 C 語言裡也有,它最原始的意義就是禁用 CPU 快取。
例如,我們宣告一個 volatile 變數
volatile int x = 0
,它表達的是:告訴編譯器,對這個變數的讀寫,不能使用 CPU 快取,必須從記憶體中讀取或者寫入。這個語義看上去相當明確,但是在實際使用的時候卻會帶來困惑。
例如下面的示例程式碼,假設執行緒 A 執行
writer()
方法,按照 volatile 語義,會把變數 “v=true” 寫入記憶體;假設執行緒 B 執行
reader()
方法,同樣按照 volatile 語義,執行緒 B 會從記憶體中讀取變數 v,如果執行緒 B 看到 “v == true” 時,那麼執行緒 B 看到的變數 x 是多少呢?
直覺上看,應該是 42,那實際應該是多少呢?這個要看 Java 的版本,如果在低於 1.5 版本上執行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上執行,x 就是等於 42。
分析一下,為什麼 1.5 以前的版本會出現 x = 0 的情況呢?我相信你一定想到了,變數 x 可能被 CPU 快取而導致可見性問題。這個問題在 1.5 版本已經被圓滿解決了。Java 記憶體模型在 1.5 版本對 volatile 語義進行了增強。怎麼增強的呢?答案是一項 Happens-Before 規則。
Happens-Before 規則
如何理解
Happens-Before
呢?如果望文生義(很多網文也都愛按字面意思翻譯成“先行發生”),那就南轅北轍了,
Happens-Before
並不是說前面一個操作發生在後續操作的前面,它真正要表達的是:
前面一個操作的結果對後續操作是可見的。 就像有心靈感應的兩個人,雖然遠隔千里,一個人心之所想,另一個人都看得到。Happens-Before 規則就是要保證執行緒之間的這種“心靈感應”。所以比較正式的說法是:
Happens-Before
約束了編譯器的最佳化行為,雖允許編譯器最佳化,但是要求編譯器最佳化後一定遵守
Happens-Before
規則。
Happens-Before
規則應該是 Java 記憶體模型裡面最晦澀的內容了,和程式設計師相關的規則一共有如下六項,都是關於可見性的。
恰好前面示例程式碼涉及到這六項規則中的前三項,為便於你理解,我也會分析上面的示例程式碼,來看看規則 1、2 和 3 到底該如何理解。至於其他三項,我也會結合其他例子作以說明。
①程式的順序性規則
這條規則是指在一個執行緒中,按照程式順序,前面的操作 Happens-Before 於後續的任意操作。這還是比較容易理解的,比如剛才那段示例程式碼,按照程式的順序,第 6 行程式碼 “
x = 42;
” Happens-Before 於第 7 行程式碼 “
v = true;
”,這就是規則 1 的內容,也比較符合單執行緒裡面的思維:
程式前面對某個變數的修改一定是對後續操作可見的。
②volatile 變數規則
這條規則是指對一個 volatile 變數的寫操作, Happens-Before 於後續對這個 volatile 變數的讀操作。
這個就有點費解了,對一個 volatile 變數的寫操作相對於後續對這個 volatile 變數的讀操作可見,這怎麼看都是禁用快取的意思啊,貌似和 1.5 版本以前的語義沒有變化啊?如果單看這個規則,的確是這樣,但是如果我們關聯一下規則 3,就有點不一樣的感覺了。
③傳遞性
這條規則是指如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens-Before C。
我們將規則 3 的傳遞性應用到我們的例子中,會發生什麼呢?可以看下面這幅圖:
從圖中,我們可以看到:
- “x=42” Happens-Before 寫變數 “v=true” ,這是規則 1 的內容;
- 寫變數“v=true” Happens-Before 讀變數 “v=true”,這是規則 2 的內容 。
再根據這個傳遞性規則,我們得到結果:“x=42” Happens-Before 讀變數“v=true”。這意味著什麼呢?
如果執行緒 B 讀到了“v=true”,那麼執行緒 A 設定的“x=42”對執行緒 B 是可見的。也就是說,執行緒 B 能看到 “x == 42” ,有沒有一種恍然大悟的感覺?這就是 1.5 版本對 volatile 語義的增強,這個增強意義重大,1.5 版本的併發工具包(
java.util.concurrent
)就是靠 volatile 語義來搞定可見性的,這個在後面的內容中會詳細介紹。
④管程中鎖的規則
這條規則是指對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。
要理解這個規則,就首先要了解“管程指的是什麼”。 管程是一種通用的同步原語,在 Java 中指的就是 synchronized,synchronized 是 Java 裡對管程的實現。
管程中的鎖在 Java 裡是隱式實現的,例如下面的程式碼,在進入同步塊之前,會自動加鎖,而在程式碼塊執行完會自動釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實現的。
所以結合規則 4——管程中鎖的規則,可以這樣理解:假設 x 的初始值是 10,執行緒 A 執行完程式碼塊後 x 的值會變成 12(執行完自動釋放鎖),執行緒 B 進入程式碼塊時,能夠看到執行緒 A 對 x 的寫操作,也就是執行緒 B 能夠看到 x==12。這個也是符合我們直覺的,應該不難理解。
⑤執行緒 start() 規則
這條是關於執行緒啟動的。它是指主執行緒 A 啟動子執行緒 B 後,子執行緒 B 能夠看到主執行緒在啟動子執行緒 B 前的操作。
換句話說就是,如果執行緒 A 呼叫執行緒 B 的
start()
方法(即線上程 A 中啟動執行緒 B),那麼該
start()
操作 Happens-Before 於執行緒 B 中的任意操作。具體可參考下面示例程式碼。
⑥執行緒 join() 規則
這條是關於執行緒等待的。它是指主執行緒 A 等待子執行緒 B 完成(主執行緒 A 透過呼叫子執行緒 B的 join() 方法實現),當子執行緒 B 完成後(主執行緒 A 中 join() 方法返回),主執行緒能夠看到子執行緒的操作。當然所謂的“看到”,指的是對 共享變數的操作。
換句話說就是,如果線上程 A 中,呼叫執行緒 B 的 join() 併成功返回,那麼執行緒 B 中的任意操作 Happens-Before 於該 join() 操作的返回。具體可參考下面示例程式碼。
被我們忽視的 final
前面我們講 volatile 為的是禁用快取以及編譯最佳化,我們再從另外一個方面來看,有沒有辦法告訴編譯器最佳化得更好一點呢?這個可以有,就是 final 關鍵字。
final 修飾變數時,初衷是告訴編譯器:這個變數生而不變,可以可勁兒最佳化。 Java 編譯器在 1.5 以前的版本的確最佳化得很努力,以至於都最佳化錯了。
問題類似於上一期提到的利用雙重檢查方法建立單例,建構函式的錯誤重排導致執行緒可能看到 final 變數的值會變化。
當然了,在 1.5 以後 Java 記憶體模型對 final 型別變數的重排進行了約束。現在只要我們提供正確建構函式沒有“逸出”,就不會出問題了。
“逸出”有點抽象,我們還是舉個例子吧,在下面例子中,在建構函式里面將 this 賦值給了全域性變數
global.obj
,這就是“逸出”,執行緒透過
global.obj
讀取 x 是有可能讀到 0 的。因此我們一定要避免“逸出”。
總結
Java 的記憶體模型是併發程式設計領域的一次重要創新,之後 C++、C#、Golang 等高階語言都開始支援記憶體模型。Java 記憶體模型裡面,最晦澀的部分就是 Happens-Before 規則了,Happens-Before 規則最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System的論文中提出來的,在這篇論文中,Happens-Before 的語義是一種因果關係。在現實世界裡,如果 A 事件是導致 B 事件的起因,那麼 A 事件一定是先於(Happens-Before)B 事件發生的,這個就是 Happens-Before 語義的現實理解。
在 Java 語言裡面,Happens-Before 的語義本質上是一種可見性,A Happens-Before B 意味著 A 事件對 B 事件來說是可見的,無論 A 事件和 B 事件是否發生在同一個執行緒裡。例如 A 事件發生線上程 1 上,B 事件發生線上程 2 上,Happens-Before 規則保證執行緒 2 上也能看到 A 事件的發生。
Java 記憶體模型主要分為兩部分,一部分面向你我這種編寫併發程式的應用開發人員,另一部分是面向 JVM 的實現人員的,我們可以重點關注前者,也就是和編寫併發程式相關的部分,這部分內容的核心就是 Happens-Before 規則。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69964492/viewspace-2769404/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Java併發程式設計實戰 02Java如何解決可見性和有序性問題Java程式設計
- 【Java併發入門】02 Java記憶體模型:看Java如何解決可見性和有序性問題Java記憶體模型
- Java併發程式設計-解決可見性與有序性問題Java程式設計
- Java併發程式設計Bug源頭:可見性、原子性和有序性問題Java程式設計
- Java併發之原子性、有序性、可見性Java
- Java併發程式設計-併發程式設計的Bug源頭:可見性、原子性和有序性問題Java程式設計
- java多執行緒3:原子性,可見性,有序性Java執行緒
- 高階java必須清楚的概念:原子性、可見性、有序性Java
- [深入理解Java虛擬機器]原子性/可見性/有序性Java虛擬機
- 深刻理解JAVA併發中的有序性問題和解決之道Java
- 執行緒安全性-原子性、可見性、有序性執行緒
- 可見性有序性,Happens-before來搞定APP
- 三大性質總結:原子性、可見性以及有序性
- Vue 常見面試問題,你可能都知道,但能答好嗎?Vue面試
- 【Java面試】什麼是冪等?如何解決冪等性問題?Java面試
- 理解併發程式設計的幾種"性" -- 可見性,有序性,原子性程式設計
- 「跬步千里」詳解 Java 記憶體模型與原子性、可見性、有序性Java記憶體模型
- 你知道MySQL的Limit有效能問題嗎MySqlMIT
- 分散式系統的資料一致性問題,你是如何解決的分散式
- 你知道是哪個是常見的約束中MySql不支援的嗎?MySql
- 程式設計師如何解決面試難題?你可知道你的缺點是什麼?程式設計師面試
- 如何解決Facebook SDK常見問題?
- 你知道SSL是如何工作的嗎?
- 深入理解Java多執行緒與併發框(第③篇)——Java記憶體模型與原子性、可見性、有序性Java執行緒記憶體模型
- 走進volatile的世界,探索它與可見性,有序性,原子性之間的愛恨情仇!
- java安全編碼指南之:可見性和原子性Java
- 解密詭異併發問題的幕後黑手:可見性問題解密
- 防護DDoS問題你知道更好的緩解流程嗎?
- 你知道的反射是這樣嗎?(二)反射
- 一個有趣的問題, 你知道SqlDataAdapter中的Fill是怎麼實現的嗎SQLLDAAPT
- 【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?synchronizedJVM
- 你知道 Java 有哪些引用嗎?Java
- 常見的雲端計算安全問題以及如何解決
- 40個Java集合面試問題和答案,面試奇葩問題,你掌握了嗎?Java面試
- 什麼是報表的多樣性資料來源問題?如何解決?
- Java包範圍可見性Java
- 你知道嗎?——ASP.NET的Session會導致的效能問題ASP.NETSession
- 你知道前端是如何實現水印的嗎前端