1.錯誤案例
通過一個案例引出volatile關鍵字,例如以下代碼示例 : 此時沒有加volatile關鍵字兩個線程間的通訊就會有問題
public class ThreadsShare {
private static boolean runFlag = false; // 此處沒有加 volatile
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("線程一等待執行");
while (!runFlag) {
}
System.out.println("線程一開始執行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("線程二開始執行");
runFlag = true;
System.out.println("線程二執行完畢");
}).start();
}
}
輸出結果 :
結論 : 線程一並沒有感覺到線程二已經將 runFlag 改為true 的信號, 所以"線程一開始執行"這句話一直也沒有輸出,而且程序也沒有終結
就像下面的場景:
在當前場景中就可能出現在處理器 A 和處理器 B 沒有將它們各自的寫緩沖區中的數據刷回內存中, 將內存中讀取的A = 0、B = 0 進行給X和Y賦值,此時將緩沖區的數據刷入內存,導致了最后結果和實際想要的結果不一致。因為只有將緩沖區的數據刷入到了內存中才叫真正的執行
造成這個問題的原因:
計算機在執行程序時,每條指令都是在處理器中執行的。而執行指令過程中,勢必涉及到數據的讀取和寫入。程序運行過程中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,由於處理器執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟處理器執行指令的速度比起來要慢的多,因此如果任何時候對數據的操作都要通過和內存的交互來進行,會大大降低指令執行的速度。為了解決這個問題,就設計了CPU高速緩存,每個線程執行語句時,會先從主存當中讀取值,然后復制一份到本地的內存當中,然后進行數據操作,將最新的值刷新到主存當中。這就會造成一種現象緩存不一致
針對以上現象提出了緩存一致性協議: MESI
核心思想是:MESI協議保證了每個緩存中使用的共享變量的副本是一致的。當處理器寫數據時,如果發現操作的變量是共享變量,即在其他處理器中也存在該變量的副本,會發出信號通知其他處理器將該共享變量的緩存行置為無效狀態(總線嗅探機制),因此當其他處理器需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那么它就會從內存重新讀取。
嗅探式的緩存一致性協議:
所有內存的傳輸都發生在一條共享的內存總線上,而所有的處理器都能看到這條總線,緩存本身是獨立的,但是內存是共享的。所有的內存訪問都要進行仲裁,即同一個指令周期中只有一個處理器可以讀寫數據。處理器不僅在內存傳輸的時候與內存總線打交道,還會不斷的在嗅探總線上發生數據交換跟蹤其他緩存在做什么,所以當一個處理器讀寫內存的時候,其他的處理器都會得到通知(主動通知),他們以此使自己的緩存保存同步。只要某個處理器寫內存,其他處理器就會知道這塊內存在他們的緩存段中已經是無效的了。
MESI詳解:
在MESI協議中每個緩存行有四個狀態 :
- Modified修改的,表示這行數據有效,數據被修改了和內存中的數據不一致,數據只存在當前緩存中
- Exclusive獨有的,這行數據有效,數據和內存中的數據一致,數據只存在在本緩存
- Shared共享的,這行數據有效,數據和內存中的數據一致,數據存在很多緩存中,
- Invalid這行數據無效
這里的Invalid,shared,modified都符合嗅探式的緩存一致性協議,但是Exclusive表示獨占的,當前數據有效並且和內存中的數據一致,但是只在當前緩存中Exclusive狀態解決了一個處理器在讀寫內存的之前我們要通知其他處理器這個問題,只有當緩存行處於Exclusive和modified的時候處理器才能寫,就是說只有在這兩種狀態之下,處理器是獨占這個緩存行的。
當處理器想寫某個緩存行的時候,如果沒有控制權就必須先發送一條我要控制權的請求給總線,這個時候會通知其他處理器把他們擁有同一緩存段的拷貝失效,只要在獲得控制權的時候處理器才能修改數據,並且此時這個處理器直到這個緩存行只有一份拷貝並且只在它的緩存里,不會有任何沖突,反之如果其他處理器一直想讀取這個緩存行,獨占或已修改的緩存行必須要先回到共享狀態,如果是已經修改的緩存行,還要先將內容回寫到內存中
所以 java 提供了一個輕量級的同步機制volatile
2.作用
volatile是Java提供的一種輕量級的同步機制。volatile是輕量級,因為它不會引起線程上下文的切換和調度。但是volatile 變量的同步性較差,它不能保證一個代碼塊的同步,而且其使用也更容易出錯。volatile關鍵字 被用來保證可見性,即保證共享變量的內存可見性以解決緩存一致性問題。一旦一個共享變量被 volatile關鍵字修飾,那么就具備了兩層語義:內存可見性和禁止進行指令重排序。在多線程環境下,volatile關鍵字主要用於及時感知共享變量的修改,並使得其他線程可以立即得到變量的最新值
使用volatile關鍵字后程序的效果 :
使用方式 :
private volatile static boolean runFlag = false;
代碼 :
public class ThreadsShare {
private volatile static boolean runFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("線程一等待執行");
while (!runFlag) {
}
System.out.println("線程一開始執行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("線程二開始執行");
runFlag = true;
System.out.println("線程二執行完畢");
}).start();
}
}
輸出結果 :
結論 : 線程一感覺到了線程二已經將 runFlag 改為true 的信號, 所以"線程一開始執行"這句話得到了輸出,而且程序終結了。
volatile 兩個效果:
- 當一個線程寫一個volatile變量時,JMM會把該線程對應的本地內存中的變量值強制刷新到主內存中去
- 這個寫會操作會導致其他線程中的這個共享變量的緩存失效,要使用這個變量的話必須重新去主內存中取值。
思考 : 如果兩個處理器同時讀取或者修改同一個共享變量咋辦?
多個處理器要訪問內存,首先要獲得內存總線鎖,任何時刻只有一個處理器能獲得內存總線的控制權,所以不會出現以上情況。
重點 : volatile關鍵字 被用來保證可見性,即保證共享變量的內存可見性以解決緩存一致性問題
3.特點
3.1 可見性
當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。而普通的共享變量不能保證可見性,因為普通共享變量被修改之后,什么時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性(以上的案例就已經展示了可見性的作用了)
3.2 禁止指令重排
在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程並發執行的正確性
volatile關鍵字禁止指令重排序有兩層意思:
- 當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對后面的操作可見;在其后面的操作肯定還沒有進行;
- 在進行指令優化時,不能將在對volatile變量訪問的語句放在其后面執行,也不能把volatile變量后面的語句放到其前面執行。
為了解決處理器重排序導致的內存錯誤,java編譯器在生成指令序列的適當位置插入內存屏障指令,來禁止特定類型的處理器重排序
內存屏障指令 : 內存屏障是volatile語義的實現下面會講解
屏障類型 | 指令示例 | 說明 |
---|---|---|
LoadLoadBarriers | Load1;LoadLoad;Load2 | Load1數據裝載發生在Load2及其所有后續數據裝載之前 |
StoreStoreBarriers | Store1;StoreStore;Store2 | Store1數據刷回主存要發生在Store2及其后續所有數據刷回主存之前 |
LoadStoreBarriers | Load1;LoadStore;Store2 | Load1數據裝載要發生在Store2及其后續所有數據刷回主存之前 |
StoreLoadBarriers | Store1;StoreLoad;Load2 | Store1數據刷回內存要發生在Load2及其后續所有數據裝載之前 |
4.volatile 與 happens-before
public class Example {
int r = 0;
double π = 3.14;
volatile boolean flag = false; // volatile 修飾
/**
* 數據初始化
*/
void dataInit() {
r = 1; // 1
flag = true; // 2
}
/**
* 數據計算
*/
void compute() {
if(flag){ // 3
System.out.println(π * r * r); //4
}
}
}
如果線程A 執行 dataInit() ,線程B執行 compute() 根據 happens-before 提供的規則(前一篇java內存模型有講) java內存模型有講步驟 2 一定在步驟 3 前面符合volatile規則, 步驟 1 在步驟 2前面,步驟 3 在步驟 4 前面,所以根據傳遞性規則 步驟 1 也在步驟 4 前面。
5.內存語義
5.1 讀內存語義
當讀取一個volatile的變量時會將本地的工作內存變成無效,去內存中獲取volatile修飾的變量當前值。
5.2 寫內存語義
當寫一個volatile的變量時會將本地的工作內存中的值強制的刷回內存中。
5.3 內存語義的實現
JMM針對編譯器制定的volatile重排序規則表
是否能重新排序 | 第二個操作 | ||
---|---|---|---|
第一個操作 | 普通的讀或者寫 | volatile讀 | volatile寫 |
普通的或者寫 | NO | ||
volatile 讀 | NO | NO | NO |
volatile 寫 | NO | NO |
舉例說明,第三行最后一個單元格的意思:
當地一個操作為普通操作的時候,如果第二個操作為volatile寫,那么編譯器不能重排序這兩個操作
5.4 總結
- 當第二個操作是volatile寫的時候,第一個操作無論是什么都不能進行重排序操作。這個規則保證了volatile寫之前的操作是不能被編譯器重新排到volatile寫后面的
- 當第一哥操作是volatile讀的時候,無論第二個操作是什么都不能進行重新排序。這個規則確保volatile讀之后的操作不會被編譯器編譯到volatile之前
- 當第一個操作volatile寫,第二個操作是volatile讀的時候不能重排序
為了實現volatile的內存語義,編譯器在生成字節碼的時候,會在指令序列中插入內存屏障來禁止特定類型的處理器排序。
JMM內存屏障插入策略:
- 在每個 volatile 寫操作的前面插入一個StoreStore 屏障。
- 在每個 volatile 寫操作后面插入一個StoreLoad 屏障。
- 在每個 volatile 讀操作的后面插入一個LoadLoad 屏障。
- 在每個 volatile 讀操作的后面插入一個LoadStore 屏障。
volatile寫插入內存屏障后生成的指令序列示意圖:
StoreStore屏障可以保證在volatile 寫之前,其前面的所有普通寫操作已經對任意處理器可見了,這是因為StoreStore屏障將保障上面所有的普通寫在volatile 寫之前刷新到主內存。
StoreLoad屏障可以保證volatile寫與后面可能有的volatile讀或者寫操作重排序。
volatile讀插入內存屏障后生成的指令序列示意圖:
LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。
LoadStore 屏障用來禁止處理器把上面的volatile讀與下面的普通讀寫重排序。
6.實戰
6.1 使用 volatile 必須具備條件
- 對變量的寫操作不依賴於當前值
- 該變量沒有包含在具有其他變量的不變式中
實際上,這些條件表明可以被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。事實上,上面的兩個條件就是保證對該volatile變量的操作是原子操作,這樣才能保證使用 volatile關鍵字的程序在並發時能夠正確執行
6.2 volatile 主要使用的場景
在多線程環境下及時感知共享變量的修改,並使得其他線程可以立即得到變量的最新值
場景一 : 狀態標記量(文中舉例)
public class ThreadsShare {
private volatile static boolean runFlag = false; // 狀態標記
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("線程一等待執行");
while (!runFlag) {
}
System.out.println("線程一開始執行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("線程二開始執行");
runFlag = true;
System.out.println("線程二執行完畢");
}).start();
}
}
場景二 Double-Check
DCL版單例模式是double check lock 的縮寫,中文名叫雙端檢索機制。所謂雙端檢索,就是在加鎖前和加鎖后都用進行一次判斷
public class Singleton1 {
private static Singleton1 singleton1 = null;
private Singleton1 (){
System.out.println("構造方法被執行.....");
}
public static Singleton1 getInstance(){
if (singleton1 == null){ // 第一次check
synchronized (Singleton1.class){
if (singleton1 == null) // 第二次check
singleton1 = new Singleton1();
}
}
return singleton1 ;
}
}
用synchronized只鎖住創建實例那部分代碼,而不是整個方法。在加鎖前和加鎖后都進行了判斷,這就叫雙端檢索機制。這樣確實只創建了一個對象。但是,這也並非絕對安全。new 一個對象也是分三步的:
- 1.分配對象內存空間
- 2.初始化對象
- 3.將對象指向分配的內存地址,此時這個對象不為null
步驟二和步驟三不存在數據依賴,因此編譯器優化時允許這兩句顛倒順序。當指令重排后,多線程去訪問也會出問題。所以便有了如下的最終版單例模式。這種情況不會發生指令重排
public class Singleton2 {
private static volatile Singleton2 singleton2 = null;
private Singleton2() {
System.out.println("構造方法被執行......");
}
public static Singleton2 getInstance() {
if (singleton2 == null) { // 第一次check
synchronized (Singleton2.class) {
if (singleton2 == null) // 第二次check
singleton2 = new Singleton2();
}
}
return singleton2;
}
}