要玩轉 happens-before 咱們須要先簡單介紹下幾個基本概念java
隨着 CPU 的快速發展它的計算速度和內存的讀寫速度差距愈來愈大,若是仍是去讀寫內存的話那麼 CPU 的處理速度就會收到內存讀寫速度的限制,爲了彌補這種差距,爲了保證 CPU 的快速處理就出現了高速緩存。程序員
高速緩存特色是讀寫速度快,容量小,照價昂貴。編程
隨着 CPU 的快速發展,所依賴的高速緩存的讀寫速度也在不斷提高,爲了知足更高的要求就發展出了工藝更好也更加快速的緩存,它的照價也更加昂貴。緩存
對於 CPU 來講按照讀寫速度和緊密程度來講依次分爲 L1(一級緩存)、L2(二級緩存)、L3(三級緩存)他們之間的處理速度依次遞減,對於現代的計算機來講至少會存在一個 L1 緩存。安全
Java 線程之間的通訊是由 Java 內存模型(JMM)來控制的,JMM 定義了多個線程之間的共享變量存儲在主內存中,每一個線程私有的數據則存儲在線程的本地內存當中,本地內存中又存儲了多線程共享變量在主內存中的副本(本地內存是一個虛擬的概念並不存在,指的是緩存區,寄存器等概念)。抽象模型圖以下:bash
happens-before 的概念最初是由 Leslie Lamport 在一篇影響深遠的論文 (《Time,Clocks and thhe Ordering of Events in a Distributed System》)中提出。它用 happens-before 來描述分佈式系統中事件的偏序關係。多線程
從 JDK5 開始,Java 使用 JSR-133 內存模型,JSR-133 使用了 happens-before 的概念來爲單線程或者多線程提供內存可見性保證。架構
happens-before 爲程序員提供了多線程之間的內存可見性併發
happens-before 的規則以下app
根據這個規則咱們就可以保證線程之間的內存可見性,後面會詳細分析,這裏先將定義放出來
上面說了 happens-before 主要是爲單線程或者多線程提供內存可見性保證,那麼內存可見性又是什麼呢,咱們先看下下面的定義
堆內存是線程之間共享的,棧內存是線程私有的。堆內存中的數據存在內存可見性問題,棧內存不受內存可見性影響。
內存可見性:其實就是一種多線程可以看到的共享內存的數據狀態,這個狀態有多是正確的也有多是錯誤的(固然咱們的目的就是爲了保證內存可見性正確)。
下面咱們來分析說明下何時會出現內存可見性問題(也就是在什麼狀況下,不正確的內存可見性狀態會致使多線程程序訪問錯誤)
咱們知道每一個 CPU 都有本身的高速緩存,那麼在有多個 CPU 的計算機上,讀寫一個數據的時候,由於處理器會往高速緩存中寫數據(對應的就是 JMM 中的線程私有內存),而高速緩存不會立馬刷到內存中(JMM 抽象模型中的主內存),這樣就會形成多個 CPU 之間的讀寫數據不一致,以下
class Test {
int val = 0;
void f() {
val = val + 1;
// ...
}
}
複製代碼
上圖只是其中一種可能出錯的狀態,也有多是正確的,多線程未同步就存在不肯定性
能夠看到程序員本意是使用 2 個線程對 val 分別執行 + 1 操做,想要獲得的結果 val = 2 結果程序運行完畢獲得的結果是 val = 1
咱們先來看下什麼是指令重排序
void f() {
int a = 1;
int b = 2;
int c = a + b;
}
複製代碼
通過編譯器或者處理器重排序後,執行的順序可能變爲先執行 b = 2
後執行 a = 1
而 C 是不可能排在上面 2 步以前的,下面會說明。
指令重排序又分爲編譯器指令重排序、處理器指令重排序。
編譯器和處理器爲了提升指令運行的並行度而進行指令重排序,它們的目的都是爲了加速程序的運行速度,可是不管怎麼重排序都必須保證單線程最終的執行結果不能改變,可是若是是在多線程狀況下就沒法保證了,因此就有可能出現執行結果不正確的狀況。
爲了保證單線程程序最終的正確性,有一點能夠肯定的是若是操做之間存在依賴性,那麼不管是編譯器仍是處理器都不容許對其進行重排序,這一點如今的編譯器和處理都是實現了的。以下
void f() {
int a = 1;
// 這個操做依賴上一步操做 a = 1,因此他們不會被重排序
int b = a + 1;
}
複製代碼
那麼指令重排序又是如何致使了內存可見性問題的呢?咱們來看一個例子
class Test {
private static Instance instance;
public static Instance getInstance() {
if (instance != null) {
synchronized(Test.class) {
if (instance != null) {
// 錯在這裏
instance = new Instance();
}
}
}
return instance;
}
}
複製代碼
這是一個常見雙重檢查鎖定的單列模型(錯誤的),它錯就錯在指令重排序可能致使返回未被初始化的 instance,咱們來分析下爲何。
instance = new Instance(); 在處理器執行的時候實際上是拆解爲了幾步執行的,僞代碼以下
// 步驟1 分配內存空間
memory = allocate();
// 步驟2 初始化對象
ctorInstance(memory);
// 步驟3 設置對象的內存地址
instance = memory;
複製代碼
咱們能夠看到上面這 3 步驟在單線程的場景下對於步驟 2 和步驟 3這兩部是沒有依賴性的,咱們能夠先設置了它的地址再給他初始化對象內容也能夠,因此可能會指令重排序以下:
// 步驟1 分配內存空間
memory = allocate();
// 步驟2 設置對象的內存地址
instance = memory;
// 步驟3 初始化對象
ctorInstance(memory);
複製代碼
那麼在多線程場景下,線程 A 執行到了步驟 2(尚未初始化),而且正好將工做內存刷新到了主內存中,那麼線程 B 就看到了 instance,認爲已經建立初始化完畢,就直接 return 了,就致使線程 B 可能拿到的是未被初始化的對象,那麼後續使用的時候就會出現問題。
正是因爲這些緣由致使了內存可見性問題,在多線程的場景下可能會出現意外的狀況,咱們要正確獲得正確的多線程程序執行的結果,那麼咱們就要保證內存可見性的正確性。
內存可見性的正確性保證主要是經過如下一些技術來實現的
當寫一個 volatile 變量的時候,JMM 會把線程對應的本地內存中的共享變量值刷新到主內存中去。
volatile 兩大特性
JMM 經過限制 volatile 讀/寫的重排序,針對編譯器制定了以下 volatile 重排序規則
是否能重排序 | 第二個操做 | ||
---|---|---|---|
第一個操做 | 普通讀 / 寫 | volatile 讀 | volatile 寫 |
普通讀 / 寫 | NO | ||
volatile 讀 | NO | NO | NO |
volatile 寫 | NO | NO |
從表能夠總結出:
看完這幾個規則腦子是否是有點暈,那是由於不知道爲何要這麼作,咱們先從一個方面去思考。
就是當寫一個 volatile 變量的時候,會把線程對應的本地內存變量值刷新到內存中去,意味着若是 volatile 寫以前有一個或者多個操做也寫了共享變量,那麼這個時候會將以前全部修改的共享變量所有刷新到主內存中去,這個特性是否是感受特別重要!
看完後面的內容再來看這個表格就能沉底夠理解爲何要這麼作了。
咱們如今再來看一下以前個單例錯誤的例子,是因爲指令重排序致使的,可是咱們把程序作以下更改就能夠保證正確了
class Test {
private static volatile Instance instance;
public static Instance getInstance() {
if (instance != null) {
synchronized(Test.class) {
if (instance != null) {
// 錯在這裏
instance = new Instance();
}
}
}
return instance;
}
}
複製代碼
能夠看到加了個 volatile,加了它以後就可以保證下面這段帶啊不能被重排序的了,意識就是隻能以步驟 1 - > 2 - > 3 的順序執行了,也就保證了這個單列模型的正確性了。
// 步驟1 分配內存空間
memory = allocate();
// 步驟2 初始化對象
ctorInstance(memory);
// 步驟3 設置對象的內存地址
instance = memory;
複製代碼
那麼編譯器是如何實現這個規則的呢,也就說編譯器是用什麼技術實現的這樣的重排序規則,來限制 volatile 的重排序的呢。
編譯器在生成字節碼的時候,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序,因爲插入最優屏障策略過於繁瑣幾乎難以作到,因此 JMM 採起保守策略插入內存屏障以下
內存屏障解釋以下
基於這個策略這個策略,就能夠保證在任意處理器平臺,任意程序都能獲得正確的 volaile 內存語義了,看下圖是 volatile 寫的場景
StoreStore 可以保證上面全部的普通寫在 volatile 寫以前刷新到主內存中。
StoreLoad 若是上面這個 volatile 在方法末尾,它就很難確認調用它的方法是否有 volatile 讀或者寫因此,若是在方法末尾或者 volatile 寫後面真的有 volatile 讀寫這兩種狀況下都會插入 StoreLoad 屏障。
總結個記憶方法:
下面是 volatile 讀的場景
總結個記憶方法而後咱們來看個代碼用 volatile 和 happen-before 規則來分析一下
class Test {
int num = 10;
boolean volatile flag = false;
void writer() {
num = 100; // 1
flag = true; // 2
}
void reader() {
if (flag) { // 3
System.out.println(num); // 4
}
}
}
複製代碼
假設有線程 A 執行完了 writer 方法後,線程 B 執行去執行 reader 方法。(忘了規則的上面翻一下)
最後強調一下就是,關於這些 volatile 讀寫這些屏障並不必定非得所有按照要求插入,編譯器會進行優化發現不須要插入的時候就不會去插入內存屏障,可是它可以保證和咱們這種插入屏障方式獲得同樣的正確的結果。
好比咱們最經常使用的服務 Linux_x86 架構下它禁止了大量的重排序,它只會在 volatile 寫後面插入一個 StoreLoad 屏障,而這個屏障就能保證 volatile 寫讀語義,它會保證在這屏障以前寫入緩存的數據所有刷入主存再執行後續的指令
對於加鎖了的代碼塊或者方法來講,他們是互斥執行的,一個線程釋放了鎖,另一個線程得到了這個鎖以後才能執行。
它有着和 volatile 類似的內存語義
當線程釋放鎖的時候會把該線程對應的本地內存共享變量刷新到主內存中去。
當線程獲取鎖的時候,JMM 會把當前線程對應的本地內存置位無效,從而使得被監視器保護的臨界區的代碼必須重新從主內存中獲取共享變量。
咱們來看一段代碼
int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public void synchronized reader() { // 4
int i = a; // 5
} // 6
複製代碼
假設線程 A 執行了 writer() 方法後線程 B 執行了 reader() 方法,繼續用 happens-before 來分析下
順序一致性模型,JMM,在設計的時候就參考了順序一致性模型。
咱們來看下順序一致性模型的定義
第一點和 JMM 中的差異相信能很容易看出來,JMM 中是容許指令重排序的,他們的執行順序有可能改變,只不過最終的獲得的結果是一致的。
對於未同步的程序來講在順序一致性模型中是這樣的
順序一致性模型要求對於未同步的模型必須達到這樣的效果,這其實意義不大,爲何呢?由於就算達到了這種效果未同步的程序最終的結果也是不肯定的。因此 JMM 從設計上來講並無這麼作。具體怎麼作的咱們以前已經通過詳細的分析了。而 JMM 對爲同步的多線程最了最小化安全性,即線程看到的數據要麼是默認值,要麼是其它線程寫入的值。
最後其實還有 final 的內存語義和 final 帶來的內存可見性問題 因爲篇幅太長了後面單獨寫。
每次看 <<Java 併發編程的藝術>> 都有不同的感觸,此次結合本身的思考寫篇文章加深下本身的理解。
參考: