從緩存入門到並發編程三要素詳解 Java中 volatile 、final 等關鍵字解析案例
引入高速緩存概念
-
在計算機在執行程序時,以指令為單位來執行,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到數據的讀取和寫入。
-
由於程序運行過程中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,由於CPU執行指令的速度很快,而從內存讀取數據和向內存寫入數據的過程相對很慢,因此如果任何時候對數據的操作都要通過和內存的交互來進行,會大大降低指令執行的速度。因此就引入了高速緩存。
-
特性:緩存(Cache memory)是硬盤控制器上的一塊內存,是硬盤內部存儲和外界接口之間的緩衝器。
高速緩存作用呢?
-
預讀取
相當於提前加載,猜測你可能會用到硬盤相鄰存儲地址的數據,它會提前進行加載到緩存中,後面你需要時,CPU就不需要去硬盤讀取數據,直接讀取緩存中的數據傳輸到內存中就OK了,由於讀取緩存的速度遠遠高於讀取硬盤時磁頭讀寫的速度,所以能夠明顯的改善性能。
-
對寫入動作進行緩存
硬盤接到寫入數據的指令之後,並不會馬上將數據寫入到盤片上,而是先暫時存儲在緩存里,然後發送一個「數據已寫入」的信號給系統,這時系統就會認為數據已經寫入,並繼續執行下面的工作,而硬盤則在空閑(不進行讀取或寫入的時候)時再將緩存中的數據寫入到盤片上。
-
換到應用程序層面也就是,當程序在運行過程中,會將運算需要的數據從主存複製一份到CPU的高速緩存當中,那麼CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之後,再將高速緩存中的數據同步到主存當中。
舉個簡單的例子,比如下面的這段代碼:
i = i + 1;
-
當線程執行這個語句時,會先從主存當中讀取i的值,然後複製一份到高速緩存當中,然後CPU執行指令對i進行加1操作,然後將數據寫入高速緩存,最後將高速緩存中i最新的值刷新到主存當中。
-
這個代碼在單線程中運行是沒有任何問題的,但是在多線程中運行就會有問題了(存在臨界區)。在多核CPU中,每條線程可能運行於不同的CPU中,因此每個線程運行時有自己的高速緩存區(對單核CPU來說,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。
比如有兩個線程像下列執行順序:
- 線程一執行
i = i + 1
,線程二執行var = i
- 線程二此時去主存中獲取變量
i
,線程一隻是在高速緩存中更新了變量,還未將變量i
寫會主存 - 線程二讀到的
i
不是最新值,此時多線程導致數據不一致
類似上面這種情況即為緩存一致性問題。讀寫場景、雙寫場景都會存在緩存一致性問題,但讀讀不會。前提是需要在多線程運行的環境下,並且需要多線程去訪問同一個共享變量。
這裡的共享又可以回到上文中,即為上面所說,他們每個線程都有自己的高速緩存區,但是都是從同一個主存同步獲取變量。
那麼這種問題應該怎樣解決呢?
解決緩存不一致問題(硬件層面)
- 總線加鎖模式
- 由於CPU在執行命令和其他組件進行通信的時候都需要通過總線,倘若對總線加鎖的話,線程一執行
i = i + 1
整個命令過程中,其他線程是無法訪問主存的。 - 優缺只有一個,可以解決本問題;缺點的話除了優點全是缺點,效率低,成本高·····(誰也不會讓一個主存同時只能幹一件事)
- 由於CPU在執行命令和其他組件進行通信的時候都需要通過總線,倘若對總線加鎖的話,線程一執行
- 緩存一致性協議
- 協議可以保證每個緩存中使用的共享變量的副本是一致的,原理:CPU對主存中的共享變量有寫入操作時,會立即通知其他CPU將該變量緩存行置為無效狀態。其他CPU發現該變為無效狀態時,就會重新去主存中讀取該變量最新值。
- 優點就是可以解決問題,讀多寫少效率還OK;缺點就是實現繁瑣,較耗費性能,在對於寫多的場景下效率很不可觀
問題:線程為什麼會不安全?
答:共享資源不能及時同步更新,歸根於 分時系統 上下文切換時 指令還未執行完畢 (沒有寫回結果) 更新異常
引入並解釋並發編程特性
眾所周知現在的互聯網大型項目,都是採用分佈式架構同時具有其「三高癥狀」,高並發、高可用、高性能。高並發為其中最重要的特性之一,在高並發場景下並發編程就顯得尤為重要,其並發編程的特性為原子性、可見性、有序性。
原子性指的是一個或多個操作要麼全部執行成功要麼全部執行失敗,期間不能被中斷,也不存在上下文切換,線程切換會帶來原子性的問題。
-
變量賦值問題:
-
b 變量賦值的底層位元組碼指令被分為兩步:第一步先定義 int b;第二步再賦值為 10。
-
兩條指令之間不具有原子性,且在多線程下會發生線程安全性問題
int b = 10;
-
可見性指的是當前線程對共享變量的修改對其他線程來說是可見的。以下案例中假設不會出現多線程原子性問題(比如多個線程寫入覆蓋問題等),即保證一次變量操作底層執行指令為原子性的。
例如上述變量在讀寫場景下,不能保證其可見性,導致寫線程完成修改指令時但為同步到主存中,讀線程並不能獲得最新值。這就是對於B線程來說沒有滿足可見性。
-
案例解析:final關鍵字
-
final 變量可以保證其他線程獲取的該變量的值是唯一的。變量指成員變量或者靜態變量
-
b 變量賦值的底層位元組碼指令被分為兩步:第一步先定義 int b;第二步再賦值為 10
final a = 10; int b = 10;
-
final修飾的變量在其指令後自動加入了寫屏障,可以保證其變量的可見性
-
a 可以保證其他線程獲取的值唯一;b 不能保證其他線程獲取到的值一定是 10,有可能為 0。
-
讀取 final 變量解析 :
- 不加 final 讀取變量時去堆內存尋找,final 變量是在棧空間,讀取速度快
- 讀取 final 變量時,直接將其在棧中的值複製一份,不用去 getstatic ,性能得到提升
- 注意:不是所有被 final 修飾的變量都在棧中。當數值超過變量類型的 MAX_VALUE 時,將其值存入常量池中
- 讀取變量的速度:棧 > 常量池 > 堆內存
-
-
final 可以加強線程安全,而且符合面向對象編程開閉原則中的close,例如子類不可繼承、方法不可重寫、初始化後不可改變、非法訪問(如修飾參數時,該參數為只讀模式)等
有序性指的是程序執行的順序按照代碼的先後順序執行。
在Java中有序性問題會時常出現,由於我們的JVM在底層會對代碼指令的執行順序進行優化(提升執行速度且保證結果),這隻能保證單線程下安全,不能保證多線程環境線程安全,會導致指令重排發生有序性問題。
案例:排名世界第一的代碼被玩壞了的單例模式
DCL(double checked):加入 volatile 保證線程安全,其實就是保證有序性。
上代碼:其中包括了三個問題並且有詳細注釋解釋。(鳴謝itheima滿一航老師)
- 為什麼加入 volatile 關鍵字?
- 對比實現3(給靜態代碼塊加synchronized) 說出這樣做的意義?
- 為什麼要在這裡加空判斷,之前不是判斷過了嗎?
final class SingletonLazyVolatile {
private SingletonLazyVolatile() { }
// 問題1:為什麼加入 volatile 關鍵字?
// 答: 防止指令重排序 造成返回對象不完整。 如 TODO
private static volatile SingletonLazyVolatile INSTANCE = null;
// 問題2:對比實現3(給靜態代碼塊加synchronized) 說出這樣做的意義?
// 答:沒有鎖進行判斷、效率較高
public static SingletonLazyVolatile getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
// 問題3:為什麼要在這裡加空判斷,之前不是判斷過了嗎?
// 答:假入t1 先進入判斷空成立,先拿到鎖, 然後到實例化對象這一步(未執行)
// 同時 線程 t2 獲取鎖進入阻塞狀態,若 t1 完成創建對象後,t2 沒有在同步塊這進行判空,t2 會再新創建一個對象,
// 導致 t1 的對象被覆蓋 造成線程不安全。
synchronized (SingletonLazyVolatile.class) { // t1
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new SingletonLazyVolatile(); // t1 這行代碼會發生指令重排序,需要加入 volatile
// 如:先賦值指令INSTANCE = new SingletonLazyVolatile,導致實例不為空,下一個線程會判空失敗直接返回該對象
// 但是構造方法()指令還沒執行,返回的就是一個不完整的對象。
return INSTANCE;
}
}
}
通過對並發編程的三要素介紹,也就是說,要想並發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。
補充volatile知識:
-
volatile
只保證可見性(多線程下對變量的修改是可見的)、有序性(禁止進行指令重排序) -
volatile 的底層實現原理是內存屏障(內存柵欄),Memory Barrier(Memory Fence),內存屏障會提供3個功能:
- 它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成
- 它會強制將對緩存的修改操作立即寫入主存
- 如果是寫操作,它會導致其他CPU中對應的緩存行無效
-
volatile
修飾之後的變量會加入讀寫屏障-
寫屏障(sfence):保證在該屏障之前的,對共享變量的改動,都同步到主存當中
-
讀屏障(lfence):保證在該屏障之後的, 對共享變量的讀取,加載的是主存中的最新數據
-
對 volatile 變量的寫指令後會加入寫屏障
-
對 volatile 變量的讀指令前會加入讀屏障
-
關於volatile
的用途像兩階段終止、單例雙重鎖等等:
兩階段終止–volatile
@Log
public class TwoPhaseStop {
// 監控線程
private Thread monitorThread;
// 多線程共享變量 單線程寫入(停止線程) 多線程讀取 使用 volatile
private volatile boolean stop = false;
// 啟動監控線程
public void start() {
monitorThread = new Thread(() -> {
log.info("開始監控");
while (true) {
log.info("監控中");
Thread currentThread = Thread.currentThread();
if (stop) {
log.info("正在停止");
break;
}
try {
log.info("正常運行");
Thread.sleep(5000);
} catch (InterruptedException e) {
// sleep出現被打斷異常後、被打斷後會清除打斷標記
// 需要重新打斷標記
currentThread.interrupt();
}
}
log.info("已停止");
},"monitor");
monitorThread.start();
}
// 停止監控線程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}
·
·
·
·
下篇預告:synchronized 和 volatile 區別和底層原理