前言
解決並發編程中的可見性和有序性問題最直接的方法就是禁用CPU緩存和編譯器的優化。但是,禁用這兩者又會影響程序性能。於是我們要做的是按需禁用CPU緩存和編譯器的優化。
如何按需禁用CPU緩存和編譯器的優化就需要提到Java內存模型。Java內存模型是一個復雜的規范。其中最為重要的便是Happens-Before規則。下面我們先介紹如何利用Happens-Before規則解決可見性和有序性問題,然后我們再擴展簡單介紹下Java內存模型以及我們前篇文章提到的重排序概念。
volatile
在前一篇文章介紹編譯優化帶來的有序性問題時,給出的一個解決辦法時將共享變量使用volatile關鍵字修飾。volatile關鍵字的作用可以簡單理解為①禁用重排序,保證程序的有序性;②禁用緩存,保證程序的可見性。
volatile關鍵字不是Java語言中的特產,C語言中也有,其最原始的意義就是禁用CPU緩存,使得每次訪問均需要直接從內存中讀寫。
如果我們聲明一個volatile變量,那么也就會讓編譯器不能從CPU緩存中去讀取這個變量,而必須從內存中讀取。
class VolatileExample {
int x = 0; // 1
volatile boolean v = false; //2
public void writer() { //3
x = 42;
v = true;
}
public void reader() { //4
if (v == true) {
// 這里 x 會是多少呢?
}
}
}
在這段代碼中,假設線程A執行了3即writer()方法,設置了x=42和v=true。線程B執行了4即reader()方法,線程B可以看見線程A設置的v為true,那么B讀到的x值會是多少呢?(想一想再點擊我)
這要分Java版本來說,在1.5之前,會出現x=0的情況。由於可見性問題,線程A修改的x可能存儲在CPU緩存中對線程B是不可見的,於是線程B獲取到的x為0。
在Java1.5之后,線程B獲取到的x一定就是42。
這是因為Java內存模型對volatile語義進行了增強。增強體現在Java內存模型中的Happens-Before規則上。
Happens-Before規則
Happends-Before規則表達的是:前面一個操作的結果對之后操作是可見的,描述的是兩個操作的內存可見性。
Happens-Before約束了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器遵循一定的Happens-Before規則進行優化。
Happens-Before規則包括:
-
程序順序規則
在一個線程中,前面的操作Happens-Before於后續的任意操作。
-
volatile變量規則
對volatile變量的寫操作相對於之后對該volatile變量的讀操作是可見的。(這個語義可等價適用於原子變量)
對volatile變量的寫操作 Happens-Before 對該volatile變量的讀操作 -
傳遞性
如果操作A Happens-Before 操作B並且操作B Happens-Before 操作C, 那么操作A Happens-Bofore 操作C。
利用程序順序規則、volatile變量規則、傳遞性規則說明例子
根據程序順序規則,在一個線程中,之前的操作是Happens-Before后續的操作,所以x=42; Happens-Before v=true;;根據volatile變量規則,對volatile變量的寫操作相對於之后對該volatile變量的讀操作是可見的,於是寫變量v=ture;Happens-Before讀變量v==true;;根據傳遞性,得出x=42;Happens-Before讀變量v==true;於是,最終讀出的x值會是42。
-
管程中的鎖規則
對同一個鎖的解鎖 Happens-Before 后續對這個鎖的加鎖。
(管程:是一種同步原語,在Java中就是指synchronized。) -
線程啟動規則
線程的啟動操作(即Thread.start()) Happens-Before 該線程的第一個操作。
主線程A啟動子線程B,那么子線程B能夠看到線程A在啟動B之前的任意操作。Thread B = new Thread(()->{ // 主線程調用 B.start() 之前 // 所有對共享變量的修改,此處皆可見 // 此例中,var==77 }); // 此處對共享變量 var 修改 var = 77; // 主線程啟動子線程 B.start(); -
線程結束規則
線程的最后一個操作 Happens-Before 它的終止事件。
主線程A等待子線程B完成(A調用B.join())。當B完成之后(主線程A中的join()返回),主線程A可以看見子線程的操作。看到針對的是對共享變量。Thread B = new Thread(()->{ // 此處對共享變量 var 修改 var = 66; }); // 例如此處對共享變量修改, // 則這個修改結果對線程 B 可見 // 主線程啟動子線程 B.start(); B.join() // 子線程所有對共享變量的修改 // 在主線程調用 B.join() 之后皆可見 // 此例中,var==66 -
中斷規則
線程對其他線程的中斷操作 Happens-Before被中斷線程所收到中斷事件。
一個線程在另一個線程上調用interrupt,必須在被中斷線程檢測到interrupt調用之前執行。(被中斷線程的InterruptedException異常,或者第三個線程針對被中斷線程的Thread.interrupted或者Thread.isInterrupted調用) -
析構器規則
構造器中的最后一個操作 Happens-Before 析構器的第一個操作
或者說,對象的構造器必須在啟動該對象的析構器之前執行完成。
需要注意,A操作 Happens-Before B操作,但並不意味着A操作必須要在B操作之前執行。
Happens-Before表達的是前一個操作執行后的結果是對后續一個操作是可見的,且前一個操作按順序排在第二個操作之前。
Java內存模型的抽象
共享變量可指代存儲與堆內存中的實例域、靜態域和數組元素,共享變量是線程間共享的。局部變量、方法定義參數和異常處理器參數不會在線程之間共享,所以,它們不會有內存可見性問題。
Java線程之間的通信由Java內存模型(Java Memory Model, JMM)控制,JMM決定了一個線程對共享變量的寫入何時對另一個線程可見。
從抽象角度看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存存儲了該線程以讀/寫共享變量的副本。
本地內存是JMM的一個抽象概念,實際並不存在,它主要是指代緩存、寫緩沖區、寄存器以及其他的硬件和編譯器優化。
Java內存模型的抽象示意圖如下:(圖來自程曉明的深入理解Java內存模型)

從上圖來看,如果線程A和線程B要進行通信,需要進行兩步:
- 線程A將本地內存A中更新過的共享變量刷新到主內存中
- 線程B從主內存中去讀取線程A更新到主內存的共享變量
線程A和B的通信過過了主內存,JMM通過控制主內存和每個線程的本地內存之間的交互,來為Java程序員提供內存可見性的保證。
重排序
重排序分類
在執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序處理。加上前面提到的編譯器優化,重排序可以分為三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。即在單線程中,重排序指令后執行的結果與未重排序執行的結果一致,那么就可以允許這種優化。
- 指令級並行的重排序。現代處理器采用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器便可以改變語句對應機器指令的執行順序。
- 內存系統的重排序。由於處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。因為緩存可能會改變將寫入變量提交到主內存的次序。
as-if-serial屬性:在單線程情況下,雖然有可能不是順序執行,但是經過重排序的執行結果要和順序執行的結果一致。 編譯器和處理器需要保證程序能夠遵守as-if-serial屬性。
數據依賴性:如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。編譯器和處理器不能對“存在數據依賴關系的兩個操作”執行重排序。
從Java源代碼到最終執行的指令序列,會經歷下面的三種重排序:(圖來自程曉明的深入理解Java內存模型)

第一個屬於編譯器重排序,第二三個屬於處理器重排序。這些重排序都可能會導致奪多線程出現內存可見性問題。
針對編譯器的重排序,JMM會有編譯器重排序規則禁止特定類型的編譯器重排序,不會禁止所有類型的編譯器重排序。
針對處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(memory barriers)指令,來禁止特定類型的處理器重排序。
JMM屬於語言級的內存模型,它確保在不同的編譯器和處理器平台上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證。
內存屏障
處理器架構提供了一些特殊的指令(稱為內存屏障)用來在需要共享數據時實現存儲協調。JMM使編譯器在適當的位置插入內存屏障指令來禁止特定類型的處理器重排序。
內存屏障指令可分為下列四類:
| 屏障類型 | 指令示例 | 說明 |
|---|---|---|
| LoadLoad Barriers | Load1;LoadLoad;Load2 | 確保Load1數據的裝載,之前於Load2及所有后續裝載指令的裝載。 |
| StoreStore Barriers | Store1;StoreStore;Store2 | 確保Store1數據對其他處理器可見(刷新到內存),之前於Store2及所有后續存儲指令的存儲。 |
| LoadStore Barriers | Load1;LoadStore;Store2 | 確保Load1數據裝載,之前於Store2及所有后續的存儲指令刷新到內存。 |
| StoreLoad Barriers | Store1;StoreLoad;Load2 | 確保Store1數據對其他處理器變得可見(刷新到內存),之前於Load2及所有后續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執行該屏障之后的內存訪問指令。 |
JMM、Happens-Before和重排序規則之間的關系

(圖來自程曉明的深入理解Java內存模型)
看圖中的概括,一個Happens-Before規則對應於一個或者多個編譯器和處理器重排序規則。
對Java程序員來說,只需要熟悉Happens-Before規則,就可以使程序避免遭受內存可見性問題,並且不用為了理解JMM提供的內存可見性保證而學習復雜的重排序規則以及這些規則的具體實現。
再談volatile
為了不打亂前面的行文思路,於是就在后面補充關於volatile的知識。
volatile變量是Java語言提供的一種較弱的同步機制,用來確保將變量的更新操作都通知到其他線程。將變量聲明為volatile類型后,編譯器與運行時都會注意到這個變量是共享的,不會將該變量上的操作與其他內存操作一起重排序,即我們前面所說的保證程序有序性。volatile變量不會被緩存在寄存器或者CPU緩存中對其他處理器不可見,讀取volatile類型的變量時總會返回最新寫入值,即我們前面說的保證程序可見性。然而,頻繁地訪問 volatile 字段也會因為不斷地強制刷新緩存而嚴重影響程序的性能。
從內存可見性角度來看,寫入volatile變量相當於退出同步代碼塊,而讀取volatile變量相當於進入同步代碼塊。然而,並不建議過度依賴volatile變量提供的可見性。如果在代碼中依賴volatile變量來控制狀態的可見性,通常比使用鎖的代碼更脆弱也更加難以理解。(下一篇文章將介紹Java並發中的同步機制)
僅當volatile變量能簡化代碼的實現以及對同步策略的驗證時,才應該使用。
volatile變量的正確使用方式包括:確保自身狀態的可見性,確保它們所引用對象的狀態的可見性以及標識一些重要的程序生命周期事件的發生(例如,初始化或者關閉)。
下面的例子是volatile變量的一種典型用法:檢查某個狀態標記以判斷是否退出循環。
volatile boolean asleep;
...
while(!asleep)
countSomeSheep();
為了能使這個程序正確執行,alseep必須要為volatile變量。否則,當asleep被另外一個線程修改時,執行判斷的線程卻發現不了。后面也會講用鎖操作也可以確保asleep更新操作的可見性,但是這將會使代碼變得復雜。
需要注意,盡管volatile變量經常用於表示某種狀態信息如某個操作完成、發生中斷或者標記,但是volatile的語義是不足以確保遞增操作(count++)的原子性 ,除非確保只有一個線程對變量執行寫操作。后面將要介紹的同步機制中的加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性。
小結
行文思路總體看起來有點亂ε(┬┬﹏┬┬)3,不過這也不是有意為之。本打算是重點介紹Happens-Before規則,然后稍微介紹一點Java內存模型。可奈何中途瞥見了一個網友力推程曉明的深入理解Java內存模型,於是就去拜讀了一遍。看完發現還是要補充介紹一些東西,於是補着補着就亂了。唉,也怪我這深入淺出介紹知識的能力不夠,各位看官擇其所需看看就好。
參考:
[1]極客時間專欄王寶令《Java並發編程實戰》
[2]Brian Goetz.Tim Peierls. et al.Java並發編程實戰[M].北京:機械工業出版社,2016
[3]程曉明.深入理解Java內存模型.https://www.infoq.cn/article/java_memory_model
