CPU 緩存結構原理
CPU 緩存結構
查看 cpu 緩存
速度比較
查看 cpu 緩存行
cpu 拿到的內存地址格式是這樣的
CPU 緩存讀
根據低位,計算在緩存中的索引
判斷是否有效
-
0 去內存讀取新數據更新緩存行
-
1 再對比高位組標記是否一致
一致,根據偏移量返回緩存數據
不一致,去內存讀取新數據更新緩存行
CPU 緩存一致性
MESI 協議
- M(修改,Modified):本地處理器已經修改緩存行,即是臟行,它的內容與內存中的內容不一樣,並且此 cache 只有本地一個拷貝(專有);
- E(專有,Exclusive):緩存行內容和內存中的一樣,而且其它處理器都沒有這行數據;
- S(共享,Shared):緩存行內容和內存中的一樣, 有可能其它處理器也存在此緩存行的拷貝;
- I(無效,Invalid):緩存行失效, 不能使用。
狀態 | 觸發本地讀取 | 觸發本地寫入 | 觸發遠端讀取 | 觸發遠端寫入 |
---|---|---|---|---|
M狀態(修改) | 本地cache:M 觸發cache:M 其他cache:I |
本地cache:M 觸發cache:M 其他cache:I |
本地cache:M→E→S 觸發cache:I→S 其他cache:I→S 同步主內存后修改為E獨享,同步觸發、其他cache后本地、觸發、其他cache修改為S共 |
本地cache:M→E→S→I 觸發cache:I→S→E→M 其他cache:I→S→I 同步和讀取一樣,同步完成后觸發cache改為M,本地、其他cache改為I |
E狀態(獨享) | 本地cache:E 觸發cache:E 其他cache:I |
本地cache:E→M 觸發cache:E→M 其他cache:I 本地cache變更為M,其他cache狀態應當是I(無效) |
本地cache:E→S 觸發cache:I→S 其他cache:I→S 當其他cache要讀取該數據時,其他、觸發、本地cache都被設置為S(共享) |
本地cache:E→S→I 觸發cache:I→S→E→M 其他cache:I→S→I 當觸發cache修改本地cache獨享數據時時,將本地、觸發、其他cache修改為S共享.然后觸發cache修改為獨享,其他、本地cache修改為I(無效),觸發cache再修改為M |
S狀態(共享) | 本地cache:S 觸發cache:S 其他cache:S |
本地cache:S→E→M 觸發cache:S→E→M 其他cache:S→I 當本地cache修改時,將本地cache修改為E,其他cache修改為I,然后再將本地cache為M狀態 |
本地cache:S 觸發cache:S 其他cache:S |
本地cache:S→I 觸發cache:S→E→M 其他cache:S→I 當觸發cache要修改本地共享數據時,觸發cache修改為E(獨享),本地、其他cache修改為I(無效),觸發cache再次修改為M(修改) |
I狀態(無效) | 本地cache:I→S或者I→E 觸發cache:I→S或者I →E 其他cache:E、M、I→S、I本地、觸發cache將從I無效修改為S共享或者E獨享,其他cache將從E、M、I 變為S或者I |
本地cache:I→S→E→M 觸發cache:I→S→E→M 其他cache:M、E、S→S→I |
既然是本cache是I,其他cache操作與它無關 | 既然是本cache是I,其他cache操作與它無關 |
下圖示意了,當一個cache line的調整的狀態的時候,另外一個cache line 需要調整的狀態。
M | E | S | I | |
---|---|---|---|---|
M | × | × | × | √ |
E | × | × | × | √ |
S | × | × | √ | √ |
I | √ | √ | √ | √ |
舉個栗子來說:
假設cache 1 中有一個變量x = 0的cache line 處於S狀態(共享)。
那么其他擁有x變量的cache 2、cache 3等x的cache line調整為S狀態(共享)或者調整為 I 狀態(無效)。
-
初始:一開始時,緩存行沒有加載任何數據,所以它處於 I 狀態。
-
本地寫(Local Write):如果本地處理器寫數據至處於 I 狀態的緩存行,則緩存行的狀態變成 M。
-
本地讀(Local Read):如果本地處理器讀取處於 I 狀態的緩存行,很明顯此緩存沒有數據給它。此時分兩種情況:
- (1)其它處理器的緩存里也沒有此行數據,則從內存加載數據到此緩存行后,再將它設成 E 狀態,表示只有我一家有這條數據,其它處理器都沒有;
- (2)其它處理器的緩存有此行數據,則將此緩存行的狀態設為 S 狀態。(備注:如果處於M狀態的緩存行,再由本地處理器寫入/讀出,狀態是不會改變的)
-
遠程讀(Remote Read):假設我們有兩個處理器 c1 和 c2,如果 c2 需要讀另外一個處理器 c1 的緩存行內容,c1 需要把它緩存行的內容通過內存控制器 (Memory Controller) 發送給 c2,c2 接到后將相應的緩存行狀態設為 S。在設置之前,內存也得從總線上得到這份數據並保存。
-
遠程寫(Remote Write):其實確切地說不是遠程寫,而是 c2 得到 c1 的數據后,不是為了讀,而是為了寫。也算是本地寫,只是 c1 也擁有這份數據的拷貝,這該怎么辦呢?c2 將發出一個 RFO (Request For Owner) 請求,它需要擁有這行數據的權限,其它處理器的相應緩存行設為 I,除了它自已,誰不能動這行數據。這保證了數據的安全,同時處理 RFO 請求以及設置I的過程將給寫操作帶來很大的性能消耗。
RFO(Read Or Ownership)請求:cpu需要獲取緩存行的所有權需要先發送 RFO 請求
什么情況下會發生RFO請求?
-
- 線程的工作從一個處理器移到另一個處理器, 它操作的所有緩存行都需要移到新的處理器上。此后如果再寫緩存行,則此緩存行在不同核上有多個拷貝,需要發送 RFO 請求了。
-
- 兩個不同的處理器確實都需要操作相同的緩存行
緩存行是什么?
請看:偽共享和緩存行
緩存系統中是以緩存行(cache line)為單位存儲的。緩存行通常是 64 字節(譯注:本文基於 64 字節,其他長度的如 32 字節等不適本文討論的重點),並且它有效地引用主內存中的一塊地址。一個 Java 的 long 類型是 8 字節,因此在一個緩存行中可以存 8 個 long 類型的變量。所以,如果你訪問一個 long 數組,當數組中的一個值被加載到緩存中,它會額外加載另外 7 個,以致你能非常快地遍歷這個數組。事實上,你可以非常快速的遍歷在連續的內存塊中分配的任意數據結構。而如果你在數據結構中的項在內存中不是彼此相鄰的(如鏈表),你將得不到免費緩存加載所帶來的優勢,並且在這些數據結構中的每一個項都可能會出現緩存未命中。
我們經常會看到這這樣的代碼
long p1,p2,p3,p4,p5,p6,p7; // 每個long 8 byte,共7個, 56byte
public volatile long x = 0L; // 56 + 8 = 64
// long p8,p9,p10,p11,p12,p13,p14;
如果存在這樣的場景,有多個線程操作不同的成員變量,但是相同的緩存行,這個時候會發生什么?。沒錯,偽共享(False Sharing)問題就發生了!有張 Disruptor 項目的經典示例圖,如下:
上圖中,一個運行在處理器 core1上的線程想要更新變量 X 的值,同時另外一個運行在處理器 core2 上的線程想要更新變量 Y 的值。但是,這兩個頻繁改動的變量都處於同一條緩存行。兩個線程就會輪番發送 RFO 消息,占得此緩存行的擁有權。當 core1 取得了擁有權開始更新 X,則 core2 對應的緩存行需要設為 I 狀態。當 core2 取得了擁有權開始更新 Y,則 core1 對應的緩存行需要設為 I 狀態(失效態)。輪番奪取擁有權不但帶來大量的 RFO 消息,而且如果某個線程需要讀此行數據時,L1 和 L2 緩存上都是失效數據,只有 L3 緩存上是同步好的數據。從前一篇我們知道,讀 L3 的數據非常影響性能。更壞的情況是跨槽讀取,L3 都要 miss,只能從內存上加載。
表面上 X 和 Y 都是被獨立線程操作的,而且兩操作之間也沒有任何關系。只不過它們共享了一個緩存行,但所有競爭沖突都是來源於共享。
內存屏障 --- Memory Barrier(Memory Fence)
可見性
寫屏障(sfence)保證在該屏障之前的,對共享變量的改動,都同步到主存當中
而讀屏障(lfence)保證在該屏障之后,對共享變量的讀取,加載的是主存中最新數據
有序性
寫屏障會確保指令重排序時,不會將寫屏障之前的代碼排在寫屏障之后
讀屏障會確保指令重排序時,不會將讀屏障之后的代碼排在讀屏障之前
java線程的內存模型
原子性、可見性與有序性
JMM 即 Java Memory Model,它定義了主存、工作內存抽象概念,底層對應着 CPU 寄存器、緩存、硬件內存、
CPU 指令優化等。
JMM 體現在以下幾個方面
- 原子性 - 保證指令不會受到線程上下文切換的影響
- 可見性 - 保證指令不會受 cpu 緩存的影響
- 有序性 - 保證指令不會受 cpu 指令並行優化的影響
(1) 原子性(Atomicity)
由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write這六個, 我們大致可以認為,基本數據類型的訪問、讀寫都是具備原子性的(例外就是long和double的非原子性 協定,讀者只要知道這件事情就可以了,無須太過在意這些幾乎不會發生的例外情況)。
如果應用場景需要一個更大范圍的原子性保證(經常會遇到),Java內存模型還提供了lock和unlock操作來滿足這種需求,盡管虛擬機未把lock和unlock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作。這兩個字節碼指令反映到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。
(2) 可見性(Visibility)
可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。
除了volatile之外,Java還有兩個關鍵字能實現可見性,它們是synchronized和final
在執行synchronized最后需要unlock掉, 但是unlock之前需要完成store, write操作, 所以是可見的
而final關鍵字的功能是修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通 過這個引用訪問到“初始化了一半”的對象),那么在其他線程中就能看見final字段的值
導致共享變量在線程間不可見的原因
- 線程交叉執行
- 重排序結合線程交叉執行
- 共享變量更新后的值沒有在工作內存與主存間及時更新
JMM關於synchronized的兩條規定
- 線程解鎖前,必須把共享變量的最新值刷新到主內存
- 線程加鎖時,將清空工作內存中共享變量的值,從而使用共享變量時需要從主內存中重新讀取最新的值(注意加鎖與解鎖是同- -把鎖)
主存與工作內存
Java內存模型規定了所有的變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理 硬件時提到的主內存名字一樣,兩者也可以類比,但物理上它僅是虛擬機內存的一部分)。每條線程 還有自己的工作內存(Working Memory,可與前面講的處理器高速緩存類比),線程的工作內存中保 存了被該線程使用的變量的主內存副本,線程對變量的所有操作(讀取、賦值等)都必須在工作內 存中進行,而不能直接讀寫主內存中的數據。不同的線程之間也無法直接訪問對方工作內存中的變 量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關系如圖
關於主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從 工作內存同步回主內存這一類的實現細節,Java內存模型中定義了以下8種操作來完成。Java虛擬機實 現時必須保證下面提及的每一種操作都是原子的、不可再分的
線程持有主存變量的副本, 在沒有特殊處理的前提下線程的所有操作都是針對副本, 而后再由副本同步到主存中, 下面就是java主存和副本的操作過程圖示:
下圖是對每個操作的詳細說明:
- lock(鎖定):作用於主內存的變量,它把一個變量標識為一條線程獨占的狀態。
- unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放后的變量 才可以被其他線程鎖定。
- read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以 便隨后的load動作使用。
- load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的 變量副本中。
- use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛 擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
- assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量, 每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
- store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨 后的write操作使用。
- write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的 變量中。
如果要把一個變量從主內存拷貝到工作內存,那就要按順序執行read和load操作,如果要把變量從 工作內存同步回主內存,就要按順序執行store和write操作。注意,Java內存模型只要求上述兩個操作 必須按順序執行,但不要求是連續執行。也就是說read與load之間、store與write之間是可插入其他指令 的,如對主內存中的變量a、b進行訪問時,一種可能出現的順序是read a、read b、load b、load a。除此 之外,Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:
- 不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內 存不接受,或者工 作內存發起回寫了但主內存不接受的情況出現。
- 不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之后必須把該變化同步回 主內存。
- 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存
中。 - 一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或 assign)的變量,換句話說就是對一個變量實施use、store操作之前,必須先執行assign和load操作。
- 一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重復執 行多次,多次執行lock后,只有執行相同次數的unlock操作,變量才會被解鎖。
- 如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量 前,需要重新執行load或assign操作以初始化變量的值。
- 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個 被其他線程鎖定的變量。
- 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。
案例一: 利用 i++來分析內存間交互操作的詳細過程
主體來說就是這么一個步驟:
// 多線程操作 a++;
① 將主存中的變量a鎖定為一條線程獨占狀態 lock --- 不允許新變量直接在工作內存中產生, 只能在主存中產生讀取和加載到工作內存
② 將主存讀取的變量a讀取出來 read
③將讀取出來的變量加載到工作內存中 load --- ②③不允許獨自執行, 必須按照順序執行, 但不保證連續執行
④ 線程操作變量a自增 use; load操作可能同時給多個線程進行
⑤ 將線程執行完畢后的結果賦值給工作內存 assign --- 不允許線程的丟棄assign操作, 即工作變量變化了, 必須同步到主存, 同時如果沒有assign操作也不允許工作內存私自同步到主存
⑥ 將工作內存中的變量存儲到主存空間 store
⑦ 將store的變量寫入到主存中 write --- ⑥⑦步驟不允許獨自運行, 必須按照順序執行, 但不保證連續執行
⑧ 解除獨占模式, 釋放變量, 之后該變量才可以被其他線程鎖定 unlock
注意: 上面加粗的兩句話, 這就是為啥要使用volatile修飾變量的原因了
案例二: 退不出的循環
先來看一個現象,main 線程對 run 變量的修改對於 t 線程不可見,導致了 t 線程無法停止:
class Zhazha {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 線程t不會如預想的停下來
}
}
為什么呢?分析一下:
- 初始狀態, t 線程剛開始從主內存讀取了 run 的值到工作內存。
-
因為 t 線程要頻繁從主內存中讀取 run 的值,JIT 編譯器會將 run 的值緩存至自己工作內存中的高速緩存中,減少對主存中 run 的訪問,提高效率
-
1 秒之后,main 線程修改了 run 的值,並同步至主存,而 t 是從自己工作內存中的高速緩存中讀取這個變量
的值,結果永遠是舊值
那如何解決呢???
volatile(易變關鍵字)
它可以用來修飾成員變量和靜態成員變量,他可以避免線程從自己的工作緩存中查找變量的值,必須到主存中獲取它的值,線程操作 volatile 變量都是直接操作主存
volatile關鍵字
volatile處理可見性問題
第一項是保證此變量對所有線程的可見性,這里的“可見性”是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的, 對於普通變量來說是需要重新讀取變量才能夠獲取到最新線程改變的變量
注意: volatile變量依然有工作內存的拷貝,但是由於它特殊的操作順序性規定,所以看起來如同直接在主內存中讀寫訪問一般
如下圖是下了線程斷點的例子:
當前線程是
count 的大小是 2, 准備自增 count, 突然它失去了時間片
變成了線程 12, 直接執行到 count++ 的下面一行, 自增了值, 變成了 3
然后我們切換到線程 10 再看看這個 count 的值是多少
[Thread-10] DEBUG com.xxx.ExerciseTransfer - count = 3
發現打印出來的是 3, 說明了, 線程12的修改在其他線程是能夠立即被反應出來的
這個就是volatile的第一個作用
而普通變量是不行的, 比如:
線程A修改一個普通變量的值,然后向主內存進行回寫,另外一條線程B在線程A回寫完成了之后再對 主內存進行讀取操作,新變量值才會對線程B可見。
它需要其他線程重新讀取時才會獲取到新的值
Java里面的運算操作符並非原子操作, 這導致volatile變量的運算在並發下一樣是不安全
如下分析i++和i--線程不安全問題
我們知道 i++ 或者 i-- 是線程不安全的, 但是為什么呢???
先上代碼:
@Slf4j
public class Demo01 {
private int counter = 0;
@Test
public void test() throws Exception {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
this.counter++;
}
log.debug("{} = {}", Thread.currentThread().getName(), counter);
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
this.counter--;
}
log.debug("{} = {}", Thread.currentThread().getName(), counter);
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("res counter = {}", counter);
}
}
我們發現打印的結果並不是絕對正確的
注意: 上面這段代碼確實是線程不安全的, 但是由於線程比較少, 所以可能需要多試幾次才會出現問題, 當然你也可以把創建線程的代碼放在for循環下面, 讓for循環創建500+個線程自增, 500+線程自減, 讓線程多做幾次上下文切換, 就會表現出問題了(工作環境不要抱有僥幸心理, 不要因為沒有看到問題而不去理會)
首先我們了解 counter++ 和 counter-- 在jvm字節碼上是怎么運行的?
counter++
getfield #獲取字段
iconst_1 #給定一個為1的常量
iadd # 相加
putfield # 賦值
counter--
getfield #獲取字段
iconst_1 #給定一個為1的常量
isub # 相減
putfield # 賦值
實事求是地說,使用字節碼來分析並發問題仍然是不嚴謹的,因為即使編譯出來只有一條字節碼指令,也並不意味執行這條指令就是一個原子操作。一條字節碼指令在解釋執行時,解釋器要運 行許多行代碼才能實現它的語義。如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼 指令。此處使用-XX:+PrintAssembly參數輸出反匯編來分析才會更加嚴謹一些,但是考慮到閱讀的方便性,並且字節碼已經能很好地說明問題,所以此處使用字節碼來解釋。
下圖就顯示着這個過程存在的問題
volatile處理指令重排序優化的問題
volatile變量的第二個語義是禁止指令重排序優化,普通的變量僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致
volatile的使用場景
(1) 這個修飾符比較合適在修飾單個變量單純的讀取時的使用方法上, 舉個例子比較簡單
class Zhazha {
public void func() {
Map configOptions;
char[] configText;
// 此變量必須定義為volatile
volatile boolean initialized = false;
// 假設以下代碼在線程A中執行
// 模擬讀取配置信息,當讀取完成后
// 將initialized設置為true,通知其他線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假設以下代碼在線程B中執行
// 等待initialized為true,代表線程A已經把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用線程A中初始化好的配置信息
doSomethingWithConfig();
}
}
(2) volatile 還能使用在一個線程寫, 其他線程讀的情況下
(3) 高並發下 long
或者 double
類型變量需要添加 volatile 以保證線程安全, 它是8byte, 在jvm中需要分成 4 + 4 這樣子計算, 所以不是線程安全的
總結: 記住這句話, volatile兩個作用: 保持共享變量可見性和禁止指令重排序, synchronized關鍵字只能保證那一塊代碼只有一個線程運行, 不能禁止指令重排, volatile只能保證可見性不能保證原子性
volatile底層原理
如何保證可見性
volatile修飾了ready
寫屏障(sfence) 保證在該屏障之前的,對共享變量的改動,都同步到主存當中
class Zhazha {
public void actor2(I_Result r) {
num = 2;
ready = true;
// 寫屏障, 在這之前的代碼所有變量都會被同步到主存
}
}
而讀屏障(fence) 保證在該屏障之后,對共享變量的讀取,加載的是主存中最新數據
class Zhazha {
public void actor1(I_Result r) {
// 讀屏障
// 在讀之后的變量都會被同步到主存
if (ready) {
r.r1 = num + num;
}
else {
r.r1 = 1;
}
}
}
如果保證有序性
寫屏障會確保指令重排序時,不會將寫屏障之前的代碼排在寫屏障之后
class Zhazha {
public void actor2(I_Result r) {
num = 2;
ready = true; // 寫屏障, 在這之前的代碼不會被jit重排到這行之后
}
}
讀屏障會確保指令重排序時,不會將讀屏障之后的代碼排在讀屏障之前
class Zhazha {
public void actor1(I_Result r) {
if (ready) { // 讀屏障, 在這之后的代碼都不會被jit重排到這行之前
r.r1 = num + num;
}
else {
r.r1 = 1;
}
}
}
volatile內存屏障實現
volatile重排序規則表
舉例來說,第三行最后一個單元格的意思是:在程序中,當第一個操作為普通變量的讀或
寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。
從表3-5我們可以看出。
當第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。
當第一個操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。
為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來
禁止特定類型的處理器重排序。對於編譯器來說,發現一個最優布置來最小化插入屏障的總
數幾乎不可能。為此,JMM采取保守策略。下面是基於保守策略的JMM內存屏障插入策略。
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的后面插入一個StoreLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadStore屏障。
上述內存屏障插入策略非常保守,但它可以保證在任意處理器平台,任意的程序中都能得到正確的volatile內存語義。
下面是保守策略下,volatile寫插入內存屏障后生成的指令序列示意圖,如下圖所示
上圖中的StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見了。這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內存。
這里比較有意思的是,volatile寫后面的StoreLoad屏障。此屏障的作用是避免volatile寫與后面可能有的volatile讀/寫操作重排序。因為編譯器常常無法准確判斷在一個volatile寫的后面是否需要插入一個StoreLoad屏障(比如,一個volatile寫之后方法立即return)。為了保證能正確實現volatile的內存語義,JMM在采取了保守策略:在每個volatile寫的后面,或者在每個volatile讀的前面插入一個StoreLoad屏障。從整體執行效率的角度考慮,JMM最終選擇了在每個volatile寫的后面插入一個StoreLoad屏障。因為volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫之后插入StoreLoad屏障將帶來可觀的執行效率的提升。從這里可以看到JMM在實現上的一個特點:首先確保正確性,然后再去追求執行效率。
下面是在保守策略下,volatile讀插入內存屏障后生成的指令序列示意圖,如下圖所示
上圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的內存屏障插入策略非常保守。在實際執行時,只要不改變volatile寫-讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。下面通過具體的示例代碼進行說明。
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一個volatile讀
int j = v2; // 第二個volatile讀
a = i + j; // 普通寫
v1 = i + 1; // 第一個volatile寫
v2 = j * 2; // 第二個 volatile寫
}
// 其他方法
}
針對readAndWrite()方法,編譯器在生成字節碼時可以做如下的優化。
注意,最后的StoreLoad屏障不能省略。因為第二個volatile寫之后,方法立即return。此時編譯器可能無法准確斷定后面是否會有volatile讀或寫,為了安全起見,編譯器通常會在這里插入一個StoreLoad屏障
- 線程安全問題遇到sync和volatile修改時, 在sync代碼塊或者volatile變量修改前面的普通變量會默認從主存拉取變量到普通變量中
public class JitTest {
public static boolean init = false;
public static volatile int i = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
// // storestore ===> 保證了前面的load(讀)
// i++;
// // storeload ===> 后面好像都不保證了...
// // loadload ===> 保證了前面的讀
// int n = i;
// // loadstore ===> 后面好像都不保證普通讀取了...
// System.out.println(1); // 同樣的效果, 后面的init不變還是存在線程問題
while (!init) {
// // storestore ===> 保證了前面的load(讀)
// i++;
// // storeload ===> 后面好像都不保證了...
// System.out.println(1);
// // loadload ===> 前面的共享變量load(讀)
// int b = i;
// // loadstore ===> 后面好像都不保證了????
}
}).start();
Thread.sleep(1000);
new Thread(() -> {
init = true;
}).start();
}
}
總結:
說了這么多就簡單點解釋下,LoadLoad
就是 Load1(讀表達式) + 內存屏障 + Load2(讀表達式) 這里的Load1和Load2不能重排序
Lock指令
volatile
底層使用了 Lock
指令實現,而lock
指令有三個功能:
- 保證原子操作
- 保證工作內存刷新到主存
- 設置其他cpu上的這個變量無效
cpu提供了三種內存屏障系統原語:
sfence
mfence
lfence
但實際上volatile底層它壓根不用, 原因是不是大多數cpu都支持的上面的系統指令
其底層實現仍然使用的 lock
, 原因也很簡單, 大多數cpu都有
AMD64
lock: addl $0, 0(%%rsp)
other
lock: addl $0, 0(%%esp)
(3) 有序性(Ordering)
有序性: 線程的執行始終跟着代碼的順序運行
Java程序中天然的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程, 所有的操作都是無序的。前半句是指“線程內似表現為串行的語義”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象
線程內似表現為串行的語義: 就是單線程看的話, 我們的指令表現的是一條一條的串行執行完畢的
工作內存與主內存同步延遲: 工作內存不能夠即時的和主內存進行相互的更新
舉個例子:
class Zhazha {
static int i;
static int j;
// 在某個線程內執行如下賦值操作
// i = ...;
// j = ...;
}
可以看到,至於是先執行 i 還是 先執行 j ,對最終的結果不會產生影響。所以,上面代碼真正執行時,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
這種特性稱之為『指令重排』,多線程下『指令重排』會影響正確性。為什么要有重排指令這項優化呢?從 CPU 執行指令的原理來理解一下吧
指令級並行原理
魚罐頭的故事
加工一條魚需要 50 分鍾,只能一條魚、一條魚順序加工...
可以將每個魚罐頭的加工流程細分為 5 個步驟:
-
去鱗清洗 10分鍾
-
蒸煮瀝水 10分鍾
-
加注湯料 10分鍾
-
殺菌出鍋 10分鍾
-
真空封罐 10分鍾
即使只有一個工人,最理想的情況是:他能夠在 10 分鍾內同時做好這 5 件事,因為對第一條魚的真空裝罐,不會影響對第二條魚的殺菌出鍋...
指令重排序優化
事實上,現代處理器會設計為一個時鍾周期完成一條執行時間最長的 CPU 指令。為什么這么做呢?可以想到指令還可以再划分成一個個更小的階段,例如,每條指令都可以分為: 取指令 - 指令譯碼 - 執行指令 - 內存訪問 - 數據寫回 這 5 個階段
術語參考:
- instruction fetch (IF)
- instruction decode (ID)
- execute (EX)
- memory access (MEM)
- register write back (WB)
在不改變程序結果的前提下,這些指令的各個階段可以通過重排序和組合來實現指令級並行
支持流水線的處理器
現代 CPU 支持多級指令流水線,例如支持同時執行 取指令 - 指令譯碼 - 執行指令 - 內存訪問 - 數據寫回 的處理器,就可以稱之為五級指令流水線。這時 CPU 可以在一個時鍾周期內,同時運行五條指令的不同階段(相當於一條執行時間最長的復雜指令),IPC = 1,本質上,流水線技術並不能縮短單條指令的執行時間,但它變相地提高了指令地吞吐率
提示:
奔騰四(Pentium 4)支持高達 35 級流水線,但由於功耗太高被廢棄
詭異的結果
創建maven項目
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
}
else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.zhazha</groupId>
<artifactId>ordering</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<prerequisites>
<maven>3.0</maven>
</prerequisites>
<dependencies>
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>${jcstress.version}</version>
</dependency>
</dependencies>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jcstress.version>0.5</jcstress.version>
<javac.target>1.8</javac.target>
<uberjar.name>jcstress</uberjar.name>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<compilerVersion>${javac.target}</compilerVersion>
<source>${javac.target}</source>
<target>${javac.target}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.2</version>
<executions>
<execution>
<id>main</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>${uberjar.name}</finalName>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jcstress.Main</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/TestList</resource>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
經過控制態測試
java -jar jcstress -v
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
4 matching test results.
[OK] com.zhazha.ConcurrencyTest
(JVM args: [-XX:+UnlockDiagnosticVMOptions, -XX:+StressLCM, -XX:+StressGCM])
Observed state Occurrences Expectation Interpretation
0 276 ACCEPTABLE_INTERESTING !!!!
1 59,999,454 ACCEPTABLE ok
4 28,530,221 ACCEPTABLE ok
[OK] com.zhazha.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation, -XX:+UnlockDiagnosticVMOptions, -XX:+StressLCM, -XX:+StressGCM])
Observed state Occurrences Expectation Interpretation
0 104 ACCEPTABLE_INTERESTING !!!!
1 67,885,202 ACCEPTABLE ok
4 29,954,445 ACCEPTABLE ok
[OK] com.zhazha.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 3,889 ACCEPTABLE_INTERESTING !!!!
1 67,195,281 ACCEPTABLE ok
4 32,530,071 ACCEPTABLE ok
[OK] com.zhazha.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 2,099 ACCEPTABLE_INTERESTING !!!!
1 85,847,970 ACCEPTABLE ok
4 26,918,422 ACCEPTABLE ok
*** All remaining tests
Tests that do not fall into any of the previous categories.
2 matching test results.
[OK] com.zhazha.ConcurrencyTest
(JVM args: [-XX:TieredStopAtLevel=1])
Observed state Occurrences Expectation Interpretation
0 0 ACCEPTABLE_INTERESTING !!!!
1 49,062,963 ACCEPTABLE ok
4 22,903,528 ACCEPTABLE ok
[OK] com.zhazha.ConcurrencyTest
(JVM args: [-Xint])
Observed state Occurrences Expectation Interpretation
0 0 ACCEPTABLE_INTERESTING !!!!
1 2,089,147 ACCEPTABLE ok
4 1,663,694 ACCEPTABLE ok
經過測試發現上面確實存在指令重排序
0 3,889 ACCEPTABLE_INTERESTING !!!!
0 2,099 ACCEPTABLE_INTERESTING !!!!
但是加上了 volatile 之后這種情況消失了
class Zhazha {
volatile boolean ready = false;
}
double-checked-locking雙重檢測原則
class Zhazha {
private volatile static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (null == instance) {
synchronized (SingletonLazy.class) {
if (null == instance) {
instance = new SingletonLazy();
}
}
}
return instance;
}
}
0: getstatic #2 // Field INSTANCE:Lcom/zhazha/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class com/zhazha/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcom/zhazha/n5/Singleton;
14: ifnonnull 27
17: new #3 // class com/zhazha/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcom/zhazha/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcom/zhazha/n5/Singleton;
40: areturn
其中
- 17 表示創建對象,將對象引用入棧 // new Singleton
- 20 表示復制一份對象引用 // 引用地址
- 21 表示利用一個對象引用,調用構造方法
- 24 表示利用一個對象引用,賦值給 static INSTANCE
也許 jvm 會優化為:先執行 24,再執行 21。如果兩個線程 t1,t2 按如下時間序列執行:
關鍵在於 0: getstatic 這行代碼在 monitor 控制之外,它就像之前舉例中不守規則的人,可以越過 monitor 讀取
INSTANCE 變量的值
這時 t1 還未完全將構造方法執行完畢,如果在構造方法中要執行很多初始化操作,那么 t2 拿到的是將是一個未初
始化完畢的單例
對 INSTANCE 使用 volatile 修飾即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才會真正有效
加上volatile之后的指令集雖然還是看不出問題, 但是在實際作用中還是體現出了效果
為什么synchronized能夠保證有序性卻無法保證指令重排序的順序?
Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一個時刻只允許一條線程對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入
雖然說synchronized是有序的, 但不是真正的有序, 它只不過是保證了在臨界區只有一個線程運行, 所以即使發生了指令重排, 對於這個塊來說是不影響的, 但是不在這個塊的呢???
借助雙重驗證方式的單例詳解區別
class Zhazha {
// 如果這個字段沒有volatile關鍵字則會出現指令重排序
private static volatile SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (null == instance) { // ①
synchronized (SingletonLazy.class) { // 線程2 阻塞
if (null == instance) {
instance = new SingletonLazy(); // 線程1 運行中 ...
}
}
}
return instance;
}
}
如果instance不添加volatile則會出現這樣一個過程:
- jvm分配內存
- 初始化內存對象(引用內存, 對象的內存, 堆)
- 棧變量指向引用對象內存
但是這個過程如果重排序成這樣對於一個線程來說結果也是一樣的
- jvm分配內存
- 棧變量指向引用對象內存
- 初始化內存對象(引用內存, 對象的內存, 堆)
滿足所謂的 as-if-serial(說白了就是不管怎么重排, 只要不影響這個線程運行的結果就行)
那么上面代碼線程2阻塞的位置, 就會出現不同的效果了,
如果是第一種方式, 未經過指令重排序, 則結果是正常的, 返回有非null的instance,
但是如果是第二種方式, 先初始化了棧變量后, 線程2發現這個棧變量發生了變化, 直接返回這個棧變量, 但是此時棧變量對應的對象堆內存還未初始化, 此時將會發生錯誤(這種情況發生的比較少, 我在實測中沒發現問題, 但是synchronized是不能處理指令重排序的, 可能jvm做了優化吧)
當然要讓sync保證指令重排的方法其實也有, 那就是讓共享變量全部都在代碼塊中
總結下來就是: sync可以保證原子性, 可見性和有序性
先行發生原則(happens-before)
是什么?
happens-before 規定了對共享變量的寫操作對其它線程的讀操作可見,它是可見性與有序性的一套規則總結,拋開以下 happens-before 規則,JMM 並不能保證一個線程對共享變量的寫,對於其它線程對該共享變量的讀可見
詳細內容
(1) 線程解鎖 m 之前對變量的寫,對於接下來對 m 加鎖的其它線程對該變量的讀可見
class Zhazha {
static int x;
static Object m = new Object();
public void func() {
new Thread(() -> {
synchronized (m) {
x = 10;
}
},"t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
},"t2").start();
}
}
(2) 線程對 volatile 變量的寫,對接下來其它線程對該變量的讀可見
class Zhazha {
volatile static int x;
public void func() {
new Thread(() -> {
x = 10;
}, "t1").start();
new Thread(() -> {
System.out.println(x);
}, "t2").start();
}
}
(3) 線程 start 前對變量的寫,對該線程開始后對該變量的讀可見
class Zhazha {
static int x = 10;
public void func() {
new Thread(() -> {
System.out.println(x);
},"t2").start();
}
}
(4) 線程結束前對變量的寫,對其它線程得知它結束后的讀可見(比如其它線程調用 t1.isAlive() 或 t1.join()等待它結束)
class Zhazha {
static int x;
public void func() {
Thread t1 = new Thread(() -> {
x = 10;
}, "t1");
t1.start();
t1.join();
System.out.println(x);
}
}
(5) 線程打斷前的寫, 對線程打斷后的其他線程可見
class Zhazha {
static int x;
public static void main(String[]args){
Thread t2 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
}, "t2");
t2.start();
new Thread(() -> {
sleep(1);
x = 10;
t2.interrupt();
}, "t1").start();
while (!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
}
(6) 對變量默認值(0,false,null)的寫,對其它線程對該變量的讀可見
(7) 具有傳遞性,線程1對x的修改在線程2中x是可見的, 並且這種可見也包括y變量
class Zhazha {
volatile static int x;
static int y;
public void func() {
new Thread(() -> {
y = 10;
x = 20; // x 寫前面的所有變量都是有序的
}, "t1").start();
new Thread(() -> {
// x=20 對 t2 可見, 同時 y=10 也對 t2 可見
System.out.println(x);
}, "t2").start();
}
}
對加鎖范圍的理解
public class TestVolatile {
volatile boolean initialized = false;
void init() {
if (initialized) {
return;
}
doInit();
initialized = true;
}
private void doInit() {
}
}
確認 initialized 是共享變量, 並且 doInit 方法只能被執行一次
我們圍繞着共享變量來進行加鎖
首先發現共享變量存在讀寫操作所以我們圍繞着讀寫加上鎖, 但是要上this鎖還是Class鎖, 根據出題條件判斷, 這里我們上this鎖, 保證一個 TestVolatile 對象只能調用一次 doInit 方法
class Zhazha {
volatile boolean initialized = false;
void init() {
synchronized (this) {
if (initialized) {
return;
}
doInit();
initialized = true;
}
}
}
這里發現 volatile 其實可加可不加
單例模式
單例模式有很多實現方法,餓漢、懶漢、靜態內部類、枚舉類,試分析每種實現下獲取單例對象(即調用 getInstance)時的線程安全,並思考注釋中的問題
實現1:
// 問題1:為什么加 final
// 問題2:如果實現了序列化接口, 還要做什么來防止反序列化破壞單例
public final class Singleton implements Serializable {
// 問題3:為什么設置為私有? 是否能防止反射創建新的實例?
private Singleton() {
}
// 問題4:這樣初始化是否能保證單例對象創建時的線程安全?
private static final Singleton INSTANCE = new Singleton();
// 問題5:為什么提供靜態方法而不是直接將 INSTANCE 設置為 public, 說出你知道的理由
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
實現2:(推薦)
// 問題1:枚舉單例是如何限制實例個數的
// 問題2:枚舉單例在創建時是否有並發問題
// 問題3:枚舉單例能否被反射破壞單例
// 問題4:枚舉單例能否被反序列化破壞單例
// 問題5:枚舉單例屬於懶漢式還是餓漢式
// 問題6:枚舉單例如果希望加入一些單例創建時的初始化邏輯該如何做
enum Singleton {
INSTANCE;
}
實現3:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析這里的線程安全, 並說明有什么缺點
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
實現4:DCL
public final class Singleton {
private Singleton() { }
// 問題1:解釋為什么要加 volatile ?
private static volatile Singleton INSTANCE = null;
// 問題2:對比實現3, 說出這樣做的意義
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 問題3:為什么還要在這里加為空判斷, 之前不是判斷過了嗎
if (INSTANCE != null) { // t2
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
實現5: (推薦)
public final class Singleton {
private Singleton() { }
// 問題1:屬於懶漢式還是餓漢式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 問題2:在創建時是否有並發問題
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
final字段在高並發下存在的問題及解決方案
首先存在問題的類結構是這樣的
/**
* 寫final域的重排序規則
* 讀final域的重排序規則
**/
public class FinalExample {
int i; // 普通變量
final int j; // final變量
static FinalExample obj;
public FinalExample() { // 構造函數
i = 1; // 寫普通域
j = 2; // 寫final域
// StoreStore
}
public static void writer() { // 寫線程A執行
// 賦值構造的引用賦值給引用字段obj
obj = new FinalExample(); // 這句話不是線程安全的(如果構造函數沒有StoreStore內存屏障的話),它可以選擇先不初始化FinalExample對象中的字段。先創建內存空間,把內存空間的地址賦值給obj,此時這段內存空間中的字段未被初始化
}
public static void reader() { // 讀線程B執行
FinalExample object = obj; // 讀對象引用 這里非常可能被重排序,對象的引用和初始化final字段只見重排序
int a = object.i; // 讀普通域 有可能未被初始化(如果構造函數沒有StoreStore內存屏障的話)
int b = object.j; // 讀final域 有可能未被初始化(如果構造函數沒有StoreStore內存屏障的話)
}
}
一般都存在一個字段擁有自己的引用的類中需要注意:
這種情況下我們需要注意初始化final
字段和初始化obj
對象的順序,防止在讀取到obj
時發現final
對象還未被初始化完畢
jvm
的處理方案是在初次讀取final
字段時需要添加上LoadLoad
內存屏障,在初次寫final
時,需要在構造函數return
之前添加StoreStore
內存屏障
- 在構造函數內對一個
final
域(字段)的寫入,與隨后把這個被構造對象的引用賦值給一個引用
變量,這兩個操作之間不能重排序。 - 初次讀一個包含
final
域(字段)的對象的引用,與隨后初次讀這個final域(字段),這兩個操作之間不能
重排序。
總而言之,我在獲取
obj
自引用時,final
需要寫入完畢。
我們還需要關注final
字段需要在構造函數內部初始化完畢,而不能逸出在構造函數之外,所以還要保證this
不逃逸出去。