本文分爲四個部分來講解:
- Java內存模型的基礎, 主要介紹內存模型相關的基本概念;
- Java內存模型中的順序一致性, 主要介紹重排序與順序一致性內存模型;
- 同步原語, 主要介紹三個同步原語(
synchronized
,volatile
,final
)的內存語義及重排序規則在處理器中的實現; - Java內存模型的設計, 主要介紹Java內存模型的設計原理, 及其與處理器內存模型和順序一致性內存模型的關係;
Java內存模型基礎
併發編程模型的兩個關鍵問題
- 線程之間如何通信;
- 線程之間如何同步;
線程通信機制主要有兩種: 共享內存和消息傳遞. Java的併發採用的是共享內存模型.
Java內存模型(JMM)的抽象結構
在Java中, 所有實例域, 靜態域和數組元素都存儲在堆內存中, 堆內存在線程之間共享. 局部變量, 方法定義參數,
和異常處理參數不會在線程之間共享, 它們不會有內存可見性問題, 也不受內存模型的影響.
JMM定義了線程和主內存(Main Memory)之間的抽象關係, 屬於語言級的內存模型:
線程之間的共享變量存儲在主內存中, 每個線程又有一個私有的本地內存(Local Memory, 實際上就是Java虛擬機棧, 寄存器, 處理器高速緩存等), 本地內存中存儲了該線程已操作過的共享變量的副本.
本地內存是JMM的一個抽象概念, 並不真實存在, 因爲它涵蓋了緩存, 寫緩衝區, 寄存器及其他硬件和編譯器的諸多優化的集合.
如果線程A和線程B要通信的話, 必須要經歷下面兩個步驟:
- 線程A把本地內存更新過的共享變量刷新到主內存中;
- 線程B到主內存去讀取線程A之前已更新過的共享變量.
可以看出, JMM通過控制主內存和每個線程的本地內存(包含緩存, 寄存器等等)之間的交互, 來爲Java程序提供內存可見性的保證.
從源代碼到指令序列的重排序
重排序主要是爲了提高性能, 通常分爲三種:
- 編譯器優化的重排序. 原則是在不改變單線程程序語義的前提下, 重新安排語句的執行順序;
- 指令級並行的重排序. 在不存在數據依賴性的時候, 處理器可以改變語句對應的機器指令的執行順序, 甚至並行執行指令;
- 內存系統的重排序. 由於處理器使用高速緩存和讀/寫緩衝區, 這使得加載和存儲操作看上去可能是在亂序執行.
Java從源代碼到最終執行的指令序列, 會依次進行以上三種重排序. 其中1屬於編譯器重排序, 2和3屬於處理器重排序.
重排序會導致內存可見性的問題. JMM通過設定重排序規則, 禁止特定的編譯器重排序, 對於處理器重排序,
則是通過插入特定類型的內存屏障(Memory Barriers)指令, 來禁止特定類型的處理器重排序, 以確保在不同編譯器和處理器平臺下,
始終能爲程序員提供一致的內存可見性保證.
內存屏障類型表
注意: 內存屏障要特別注意Store類型的屏障, 每個Store類型的屏障都對應着將線程私有的寫緩衝寫回到主存的操作, 也就是實現線程間可見性的操作
內存屏障實際上是通過限制單線程內指令的重排序來作用的.
JMM將內存屏障指令分爲4種類型:
屏障類型 | 指令示例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 |
確保Load1數據的裝載先於Load2指令的裝載(load2的裝載是本線程內部的狀態,其他線程的決定不了) |
StoreStore Barriers | Store1; StoreStore; Store2 |
確保Store1數據對其它處理器可見(將Store1及之前的Store操作數據刷入主內存中)先於Store2的存儲(store2的存儲是本線程內部的存儲, 其他線程的存儲決定不了) |
LoadStore Barriers | Load1; LoadStore; Store2 |
確保Load1數據的裝載先於Store2的存儲(store2的存儲是本線程內部的存儲, 其他線程的存儲決定不了) |
StoreLoad Barriers | Store1; StoreLoad; Load2 |
確保Store1數據對其它處理器可見(將Store1及之前的Store操作刷入主內存中)先於Load2的裝載(load2的裝載發生在線程私有內存內部) |
其中, StoreLoad屏障是一個全能屏障, 因爲它包含了其他所有屏障的效果, 但是開銷大, 因爲要把寫緩衝區的所有數據全部刷新到內存中.
happens-before簡介
在JMM中, 如果一個操作執行的結果要對另一個操作可見(通常指的是數據依賴性), 那麼這兩個操作之間必須要存在happens-before關係. 主要有以下規則:
- 程序順序規則: 單線程中的某操作, happens-before於對其有數據依賴性的操作;
- 監視器鎖規則: 對一個鎖的解鎖, happens-before於隨後對這個鎖的加鎖;
volatile
變量規則: 對一個volatile
域的寫, happens-before於後續對這個域的讀(實質上是通過緩存鎖定的LOCK信號來實現的);
使所有處理器的相應地址的緩存行失效, 強制重新從共享主存中讀取. 而且不允許兩個線程同時更改同一個緩存行- 傳遞性: A happens-before B happens-before C, 則 A happens-before C
重排序
重排序遵守一個統一的原則, 就是讓重排序後的程序至少能夠在單線程的情況下正確運行(意思是在單線程下和重排序前的運行結果相同).
順序一致性
即所有操作具有全序關係, 是一個理想化的模型. 但是JMM天然並不能保證順序一致性,
需要通過同步原語(Synchronized
, volatile
, final
)來輔助完成.
volatile的內存語義
volatile
作用於一個filed上, 能夠確保它的可見性. 例如, 現在有一個filed名爲l
, 我們定義private volatile long l
,
就相當於定義:
private long l;
public synchronized long get() {
return this.l;
}
public synchronized set(long l) {
this.l = l;
}
volatile寫-讀與內存屏障
從內存語義的角度來說, volatile
的寫和鎖的釋放有相同的內存語義; volatile
的讀與鎖的獲取有相同的語義.
volatile
底層實際上是通過內存屏障的方式來確保了可見性, 以下是volatile
附近的內存屏障的情況:
- 在每個
volatile
寫操作的前面插入一個StoreStore屏障; - 在每個
volatile
寫操作的後面插入一個StoreLoad屏障; - 在每個
volatile
讀操作後面插入一個LoadLoad屏障; - 在每個
volatile
讀操作後面插入一個LoadStore屏障;
實際使用中volatile
常用做if
或者循環的標識位.
定義成volatile
的變量, 能夠在線程間保持可見性, 能夠被多線程同時讀(注意: 內存屏障只是限制了單線程內的語句排序), 但是同時只能被一個線程寫.
鎖的內存語義
當線程釋放鎖時, JMM會把該線程對應的本地內存中的共享變量刷新到主內存中去;
當線程獲取鎖時, JMM會把該線程對應的本地內存置爲無效, 臨界區代碼必須從主內存重新讀取共享變量;
在底層的實現上
- 在鎖的釋放上, 公平鎖和非公平鎖最後都需要寫一個
volatile
變量state
; - 在鎖的獲取時, 公平鎖會讀
volatile
變量, 非公平鎖會用CAS更新volatile
變量.
所以鎖的釋放與volatile
的寫, 鎖的獲取同時具有volatile
讀寫的語義.
concurrent包的實現
concurrent包的基礎就是volatile
變量的讀/寫, 以及CAS. CAS兼具volatile
變量讀寫的內存語義
final域的內存語義
final
域的寫之後, 會插入一個StoreStore屏障final
域的讀之前, 會插入一個LoadLoad屏障
只要被構造的對象的引用在構造函數中沒有逸出, 那麼基於上述兩條規則, 就不需要使用同步,
就可以保證任意線程都能看到這個final
域在構造函數中被初始化之後的值. 如果逸出了, 那麼可能會引起重排序, 導致引用在final
域初始化之前被其他線程獲取, 導致獲得未經初始化的final
域的值.
happens-before
最實用的三種happens-before
1.
volatile
寫, happens-before後續volatile
讀;
以下是一個例子:
/**
* 下面一段語句, 能夠保證1 happens before 4, 也就是無論運行多少次, 結果都輸出100
*
* <p>所以結論是, volatile變量非常適合作爲循環的標識位.
*
* Created by [email protected] on 16-11-4.
*/
public class VolatileHappensBefore {
private volatile static boolean ready = true;
private static int number = 1;
private static class ReaderThread extends Thread {
@Override
public void run() {
// 3. 子線程讀volatile變量
while (VolatileHappensBefore.ready) {
// 這裏是LoadLoad+LoadStore屏障
}
// 這裏是LoadLoad+LoadStore屏障
// 4. 子線程讀共享變量
out.println(VolatileHappensBefore.number);
}
}
public static void main(String[] args) throws InterruptedException {
ReaderThread readerThread = new ReaderThread();
readerThread.start();
Thread.sleep(100);
/*下面語句復現的是volatile寫讀的happens-before規則*/
// 1. 主線程修改共享變量
VolatileHappensBefore.number = 100;
// 這裏是StoreStore屏障
// 2. 主線程寫volatile變量
VolatileHappensBefore.ready = false;
// 這裏是StoreLoad屏障
// 如此一來能夠保證只要volatile變量的修改能夠讀到, 那麼之前的修改一定能夠被讀到
}
}
2.
start()
規則: 如果線程A執行操作ThreadB.start()
, 那麼線程AThreadB.start()
操作happens-before線程B中的任何操作;
3.
join()
規則: 如果線程A執行操作ThreadB.join()
併成功返回, 那麼線程B中的任意操作happens-before與線程A在ThreadB.join()
操作的成功返回.
單例模式 - 雙重檢查鎖定與延遲初始化
雙重檢查鎖定其實是錯誤的, 因爲可能一個實例還沒有被完全初始化, 就返回了引用. 導致外層的檢查失效, 使得其他線程獲得一個不完整的對象引用.
替代方案1: 使用volatile
關鍵字修飾單例對象, 確保可見性, 不會讓寫了一半的對象被其他線程讀到;
替代方案2: 基於類的初始化方案;
替代方案3(推薦): 使用enum進行單例的初始化;
總結
- CAS操作同時具有
volatile
的讀寫語義, 也就是之前之後的代碼都不能重排序. 底層是通過一個lock指令, 進行緩存鎖定, 確保讀-改-寫操作的原子性. - 緩存一致性和緩存鎖定說的是同一件事, 都是lock指令造成的緩存鎖定(或者說獨佔僅那一個地址的主存和緩存).
- 只有
volatile
寫操作或者是CAS(一種內置的複合操作)纔會觸發lock