JUC學習筆記——共用模型之記憶體

2022-11-14 09:04:06

JUC學習筆記——共用模型之記憶體

在本系列內容中我們會對JUC做一個系統的學習,本片將會介紹JUC的記憶體部分

我們會分為以下幾部分進行介紹:

  • Java記憶體模型
  • 可見性
  • 模式之兩階段終止
  • 模式之Balking
  • 原理之指令級並行
  • 有序性
  • volatile原理

Java記憶體模型

我們首先來介紹一下Java記憶體模型:

  • JMM 即 Java Memory Model,它定義了主記憶體、工作記憶體抽象概念,底層對應著 CPU 暫存器、快取、硬體記憶體、 CPU 指令優化等。

JMM的主要作用如下:

  • 計算機硬體底層的記憶體結構過於複雜
  • JMM的意義在於避免程式設計師直接管理計算機底層記憶體,用一些關鍵字synchronized、volatile等可以方便的管理記憶體。

JMM主要體現在三個方面:

  • 原子性 - 保證指令不會受到執行緒上下文切換的影響 (我們在管程已經介紹過了)
  • 可見性 - 保證指令不會受 cpu 快取的影響
  • 有序性 - 保證指令不會受 cpu 指令並行優化的影響

可見性

這一小節我們來介紹可見性

可見性問題

首先我們根據一段程式碼來體驗什麼是可視性:

// 我們首先設定一個run執行條件設定為true,線上程t執行1s之後,我們在主執行緒修改run為false希望停下t執行緒

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不會如預想的停下來!

我們進行簡單的分析:

  1. 初始狀態, t 執行緒剛開始從主記憶體讀取了 run 的值到工作記憶體。

  1. 因為 t 執行緒要頻繁從主記憶體中讀取 run 的值,JIT 編譯器會將 run 的值快取至自己工作記憶體中的快取記憶體中,減少對主記憶體中 run 的存取,提高效率

  1. 1 秒之後,main 執行緒修改了 run 的值,並同步至主記憶體,而 t 是從自己工作記憶體中的快取記憶體中讀取這個變數 的值,結果永遠是舊值

可見性解決

我們提供兩種可見性的解決方法:

  1. volatile(易變關鍵字)
// 它可以用來修飾成員變數和靜態成員變數
// 他可以避免執行緒從自己的工作快取中查詢變數的值,必須到主記憶體中獲取它的值,執行緒操作 volatile 變數都是直接操作主記憶體

// 我們首先設定一個run執行條件設定為true,線上程t執行1s之後,我們在主執行緒修改run為false希望停下t執行緒

static volatile boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();
    sleep(1);
    run = false; 
}

// 這時程式會停止!
  1. synchronized(鎖關鍵字)
// 我們對執行緒內容進行加鎖處理,synchronized內部會自動封裝對其主記憶體進行查詢

static Object obj = new Object();
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        synchronized(obj){
                while(run){
                // ....
            }
        }
    });
    t.start();
    sleep(1);
    run = false; 
}

// 這時程式會停止!

可見性解決方法對比

我們對volatile和synchronized兩種方法進行簡單對比:

  • volatile只能保證可見性和有序性,synchronized可以保證可見性,有序性和原子性
  • volatile屬於輕量級操作,synchronized屬於重量級操作;前者的各部分消耗量較少,效能較高

我們在這裡介紹一下為什麼synchronized能進行可見性問題解決:

  • JMM關於synchronized的兩條規定:
  • 執行緒解鎖前,必須把共用變數的最新值重新整理到主記憶體中
  • 執行緒加鎖時,將清空工作記憶體中共用變數的值,從而使用共用變數時需要從主記憶體中重新獲取最新的值

關於volatile的講解我們會在後面單獨列出

模式之兩階段終止

我們在這一小節來修改之前講解的兩階段終止模式

模式簡介

我們重新回顧一下兩階段終止模式:

  • 在一個執行緒 T1 中如何「優雅」終止執行緒 T2?這裡的【優雅】指的是給 T2 一個料理後事的機會。

我們給出具體模式圖:

原版模式

我們首先介紹錯誤的一些方法:

  • 使用執行緒物件的 stop() 方法停止執行緒
    • stop 方法會真正殺死執行緒,如果這時執行緒鎖住了共用資源,那麼當它被殺死後就再也沒有機會釋放鎖, 其它執行緒將永遠無法獲取鎖
  • 使用 System.exit(int) 方法停止執行緒
    • 目的僅是停止一個執行緒,但這種做法會讓整個程式都停止

然後我們再來回想一下我們之前所使用的方法:

/*主函數*/

public class Main(){
    public static void main(String[] args){
    	TPTInterrupt t = new TPTInterrupt();
        t.start();
        Thread.sleep(3500);
        log.debug("stop");
        t.stop();
    }
}

/*模式函數(採用interrupt以及isInterrupt判斷來決定是否打斷程序)*/

class TPTInterrupt {
    
    private Thread thread;
    
    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                if(current.isInterrupted()) {
                    log.debug("料理後事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("將結果儲存");
                } catch (InterruptedException e) {
                    //打斷sleep執行緒會清除打斷標記,所以要新增標記
                    current.interrupt();
                }
                // 執行監控操作 
            }
        },"監控執行緒");
        thread.start();
    }
    
    public void stop() {
        thread.interrupt();
    }
}

/*結果展示*/

11:49:42.915 c.TwoPhaseTermination [監控執行緒] - 將結果儲存
11:49:43.919 c.TwoPhaseTermination [監控執行緒] - 將結果儲存
11:49:44.919 c.TwoPhaseTermination [監控執行緒] - 將結果儲存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop 
11:49:45.413 c.TwoPhaseTermination [監控執行緒] - 料理後事

改版模式

但是在我們學習了Volatile方法之後,我們可以修改上述程式碼:

/*主函數*/

public class Main(){
    public static void main(String[] args){
		TPTVolatile t = new TPTVolatile();
        t.start();
        Thread.sleep(3500);
        log.debug("stop");
        t.stop();
    }
}

/*修改後的模式函數*/

class TPTVolatile {
    
    private Thread thread;
    
    // 停止標記用 volatile 是為了保證該變數在多個執行緒之間的可見性
    private volatile boolean stop = false;
    
    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                // 我們採用stop變數來判斷是否結束程序
                if(stop) {
                    log.debug("料理後事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("將結果儲存");
                } catch (InterruptedException e) {
                     }
                // 執行監控操作
            }
        },"監控執行緒");
        thread.start();
    }
    public void stop() {
        // 呼叫後,修改stop,讓主執行緒停止操作
        stop = true;
        //讓執行緒立即停止而不是等待sleep結束
        thread.interrupt();
    }
}

/*結果展示*/
11:54:52.003 c.TPTVolatile [監控執行緒] - 將結果儲存
11:54:53.006 c.TPTVolatile [監控執行緒] - 將結果儲存
11:54:54.007 c.TPTVolatile [監控執行緒] - 將結果儲存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop 
11:54:54.502 c.TPTVolatile [監控執行緒] - 料理後事

模式之Balking

我們在這一小節來講解新的模式Balking

模式簡介

我們首先來簡單介紹一下模式:

  • Balking (猶豫)模式用在一個執行緒發現另一個執行緒或本執行緒已經做了某一件相同的事,那麼本執行緒就無需再做 了,直接結束返回

該模式的用途如下:

  • 設定某個方法只能呼叫一次
  • 適用於單例物件的構造方法

模式格式

我們直接給出該模式的模板程式碼:

public class MonitorService {
    
    // 用來表示是否已經有執行緒已經在執行啟動了
    private volatile boolean starting;
    
    // 測試模板的方法
    public void start() {
        log.info("嘗試啟動監控執行緒...");
        // 首先我們需要先鎖住內部資訊,防止多執行緒時導致混亂(因為內部存在資料變動,可能無法導致原子性)
        synchronized (this) {
            // 我們先來判斷是否該方法已執行,若已執行直接返回即可
            if (starting) {
                return;
            }
            // 若未執行,實施方法,並將引數設定為true使後續執行緒無法使用
            starting = true;
        }
		//其實synchronized外面還可以再套一層if,或者改為if(!starting),if框後直接return
        // 真正啟動監控執行緒...
    }
}

我們再給出一套單例建立物件的案例:

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;
    }
}

原理之指令級並行

我們在這一小節來講解新的原理指令級並行

概念講解

在正式進入原理講解之前我們需要明白幾個概念:

  • Clock Cycle Time

    主頻的概念大家接觸的比較多,而 CPU 的 Clock Cycle Time(時鐘週期時間),等於主頻的倒數,意思是 CPU 能 夠識別的最小時間單位

  • CPI

    有的指令需要更多的時鐘週期時間,所以引出了 CPI (Cycles Per Instruction)指令平均時鐘週期數

  • IPC

    IPC(Instruction Per Clock Cycle) 即 CPI 的倒數,表示每個時鐘週期能夠執行的指令數

  • CPU 執行時間

    程式的 CPU 執行時間,即我們前面提到的 user + system 時間,可以用下面的公式來表示

    程式 CPU 執行時間 = 指令數 * CPI * Clock Cycle Time
    

流水線操作

我們要講的指令級並行實際上就是概念化的流水線操作:

  • 現代 CPU 支援多級指令流水線
  • 例如支援同時執行 取指令 - 指令譯碼 - 執行指令 - 記憶體存取 - 資料寫回 的處理 器,就可以稱之為五級指令流水線
  • 這時 CPU 可以在一個時鐘週期內,同時執行五條指令的不同階段(相當於一 條執行時間最長的複雜指令)
  • 本質上,流水線技術並不能縮短單條指令的執行時間,但它變相地提高了 指令地吞吐率。

我們給出流水線操作圖:

指令重排序優化

我們首先來介紹一下指令重排:

  • 指令重排是由JIT即時編譯器所控制的
  • 它會在不影響當前執行緒的執行結果的前提下,在底層進行指令順序方面的調整

我們給出一個指令重排的例子:

// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );

// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2

其實指令重排優化就是由流水線操作來演變過來的:

  • 事實上,現代處理器會設計為一個時鐘週期完成一條執行時間最長的 CPU 指令。為什麼這麼做呢?
  • 可以想到指令 還可以再劃分成一個個更小的階段
  • 例如,每條指令都可以分為: 取指令 - 指令譯碼 - 執行指令 - 記憶體存取 - 資料寫回 這 5 個階段

我們給出一張指令級並排操作的展示圖:

有序性

這一小節我們來介紹可見性

有序性問題

我們同樣採用一個問題來引出有序性概念:

/*程式碼展示*/

int num = 0;
boolean ready = false;

// 執行緒1 執行此方法
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

// 執行緒2 執行此方法
public void actor2(I_Result r) { 
    num = 2;
    ready = true; 
}

/*結果展示(多次執行)*/

// 我們會發現1,4都是按照正常邏輯執行,但是0原本來說不應該出現
*** INTERESTING tests 
 Some interesting behaviors observed. This is for the plain curiosity. 
 
 2 matching test results. 
 	[OK] test.ConcurrencyTest 
 	(JVM args: [-XX:-TieredCompilation]) 
    Observed state 	Occurrences 	Expectation Interpretation 
    0 				1,729 			ACCEPTABLE_INTERESTING !!!! 
 	1 				42,617,915 		ACCEPTABLE ok 
 	4 				5,146,627 		ACCEPTABLE ok 
 
 	[OK] test.ConcurrencyTest 
 	(JVM args: []) 
 	Observed state 	Occurrences 	Expectation Interpretation 
 	0 				1,652 			ACCEPTABLE_INTERESTING !!!! 
 	1 				46,460,657 		ACCEPTABLE ok 
 	4 				4,571,072 		ACCEPTABLE ok 

/*結果分析*/
     
情況1:執行緒1 先執行,這時 ready = false,所以進入 else 分支結果為 1 

情況2:執行緒2 先執行 num = 2,但沒來得及執行 ready = true,執行緒1 執行,還是進入 else 分支,結果為1 

情況3:執行緒2 執行到 ready = true,執行緒1 執行,這回進入 if 分支,結果為 4(因為 num 已經執行過了)
    
// 由於指令重排,num = 2;ready = true; 都不會導致該執行緒出現錯誤,所以可能會將 ready = true操作先進行執行!
特殊情況:執行緒2 執行 ready = true,切換到執行緒1,進入 if 分支,相加為 0,再切回執行緒2 執行 num = 2 

有序性解決

我們同樣可以採用兩種方法進行解決:

  1. volatile(易變關鍵字)
/*程式碼展示*/

public class ConcurrencyTest {
    
    int num = 0;
    
    // 在加上volatile之後,會導致ready寫操作以及寫之前的操作不會發生指令重排
    // 在加上volatile之後,會導致ready讀操作以及讀之後的操作不會發生指令重排
    volatile boolean ready = false;
    
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}
  1. synchronized(鎖關鍵字)
/*程式碼展示*/

public class ConcurrencyTest {
    
    int num = 0;
    
    boolean ready = false;
    
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    public void actor2(I_Result r) {
        // synchronized會控制指令順序不發生改變
        synchronized(this){
			num = 2;
        	ready = true;
        }
    }
}

volatile原理

我們將在這一小節徹底解決volatile原理層面的問題

volatile原理前提

我們首先需要知道volatile是依靠什麼完成操作的:

  • volatile 的底層實現原理是記憶體屏障,Memory Barrier(Memory Fence)

  • 對 volatile 變數的寫指令後會加入寫屏障

  • 對 volatile 變數的讀指令前會加入讀屏障

volatile可見性保證

首先我們來檢視寫屏障:

// 寫屏障(sfence)保證在該屏障之前的,對共用變數的改動,都同步到主記憶體當中

public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 賦值帶寫屏障
    // 寫屏障
}

然後我們來檢視讀屏障:

// 而讀屏障(lfence)保證在該屏障之後,對共用變數的讀取,載入的是主記憶體中最新資料

public void actor1(I_Result r) {
    // 讀屏障
    // ready 是 volatile 讀取值帶讀屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

我們給出一張讀寫屏障的流程圖:

volatile有序性保證

我們同樣先來展示寫屏障:

// 寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後

public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 賦值帶寫屏障
    // 寫屏障
}

我們再來檢視讀屏障:

// 讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前

public void actor1(I_Result r) {
    // 讀屏障
    // ready 是 volatile 讀取值帶讀屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

我們同樣給出一張流程圖:

但是我們需要注意的是:

  • volatile不能解決指令交錯:

  • 寫屏障僅僅是保證之後的讀能夠讀到最新的結果,但不能保證讀跑到它前面去

  • 而有序性的保證也只是保證了本執行緒內相關程式碼不被重排序

我們針對注意點給出一張解釋圖:

double-checked locking 問題

我們來進行一個簡單的問題解析:

// 以著名的 double-checked locking 單例模式為例

public final class Singleton {
    
    private Singleton() { }
    
    // 這裡建立了唯一一個單例物件
    private static Singleton INSTANCE = null;
    
    public static Singleton getInstance() { 
        // 我們首先對INSTANCE進行檢測
        // (這一步是為了保證我們只有在創造物件的那一步需要涉及到鎖,對於後面的獲取方法不要涉及鎖,加快速率)
        if(INSTANCE == null) { 
            // 這一步是為了保證多執行緒同時進入時,防止由於執行緒指令參雜而導致兩次賦值
            synchronized(Singleton.class) {
                // 我們需要再次進行判斷,因為當t1執行緒執行到鎖中時,可能有t2程序也通過了第一個if判斷,
                // 如果不新增這一步,就會導致t2程序進入後直接再次賦值,導致兩次賦值
                if (INSTANCE == null) { 
                    // 在不出現任何問題下,我們對唯一物件進行建立
                    INSTANCE = new Singleton();
                } 
            }
        }
        // 如果已有物件,我們直接呼叫即可
        return INSTANCE;
    }
}

以上的實現特點是:

  • 懶惰範例化
  • 首次使用 getInstance() 才使用 synchronized 加鎖,後續使用時無需加鎖
  • 有隱含的,但很關鍵的一點:第一個 if 使用了 INSTANCE 變數,是在同步塊之外

我們檢視上述程式碼,會感覺所有內容都毫無疏漏,但是如果是多執行緒情況下,出現執行緒的指令重排就會導致錯誤產生:

/*原始碼展示*/

0: getstatic #2 		// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 				// class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 		// Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27	
17: new #3 				// class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 	// Method "<init>":()V
24: putstatic #2 		// Field INSTANCE:Lcn/itcast/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:Lcn/itcast/n5/Singleton;
40: areturn
    
/*重要程式碼展示*/
    
- 17 表示建立物件,將物件參照入棧 
- 20 表示複製一份物件參照  
- 21 表示利用一個物件參照,呼叫構造方法 
- 24 表示利用一個物件參照,賦值給 static INSTANCE 
    
/*指令重排問題*/
在正常情況下,我們會按照17,20,21,24的順序執行
但是如果發生指令重排問題,導致21,24交換位置,就會導致先進行賦值,再去建立物件
這時 t1 還未完全將構造方法執行完畢,如果在構造方法中要執行很多初始化操作,那麼 t2 拿到的是將是一個未初始化完畢的單例 
如果同時我們的t2執行緒去執行,就會導致直接呼叫那個未初始化完畢的單例,會導致很多功能失效!

我們針對上述重排問題給出一張流程圖:

double-checked locking 解決

其實解決方法很簡單:

  • 在INSTANCE物件上新增一個volatile變數修飾即可

我們給出具體解決方法:

/*程式碼展示*/

public final class Singleton {
    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 範例沒建立,才會進入內部的 synchronized程式碼塊
        if (INSTANCE == null) { 
            synchronized (Singleton.class) { // t2
                // 也許有其它執行緒已經建立範例,所以再判斷一次
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

/*位元組碼展示(帶有屏障解釋)*/

// -------------------------------------> 加入對 INSTANCE 變數的讀屏障
0: getstatic #2 		// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 				// class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保證原子性、可見性
11: getstatic #2 		// Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 				// class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4	// Method "<init>":()V
24: putstatic #2 		// Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入對 INSTANCE 變數的寫屏障
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:Lcn/itcast/n5/Singleton;
40: areturn
    
/*具體解析*/

如上面的註釋內容所示,讀寫 volatile 變數時會加入記憶體屏障(Memory Barrier(Memory Fence)),保證下面 兩點:
- 可見性 
  - 寫屏障(sfence)保證在該屏障之前的 t1 對共用變數的改動,都同步到主記憶體當中 
  - 而讀屏障(lfence)保證在該屏障之後 t2 對共用變數的讀取,載入的是主記憶體中最新資料 
- 有序性 
  - 寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後 
  - 讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前 
- 更底層是讀寫變數時使用 lock 指令來多核 CPU 之間的可見性與有序性
    
更簡單來說:
- 由於寫屏障的前面不會發生指令重排,我們的21和24順序不會顛倒,我們的賦值一定是已經完成初始化的賦值!

happens-before

我們來介紹一下happens-before:

  • happens-before 規定了對共用變數的寫操作對其它執行緒的讀操作可見它是可見性與有序性的一套規則總結
  • 拋開以下 happens-before 規則,JMM 並不能保證一個執行緒對共用變數的寫,對於其它執行緒對該共用變數的讀可見

我們來進行總結:

  1. 執行緒 start 前對變數的寫,對該執行緒開始後對該變數的讀可見
static int x;

x = 10;

new Thread(()->{
	System.out.println(x);
},"t2").start();
  1. 執行緒對 volatile 變數的寫,對接下來其它執行緒對該變數的讀可見
volatile static int x;

new Thread(()->{
	x = 10;
},"t1").start();

new Thread(()->{
	System.out.println(x);
},"t2").start();
  1. 執行緒解鎖 m 之前對變數的寫,對於接下來對 m 加鎖的其它執行緒對該變數的讀可見
static int x;
static Object m = new Object();

new Thread(()->{
    synchronized(m) {
    	x = 10;
    }
},"t1").start();

new Thread(()->{
    synchronized(m) {
    	System.out.println(x);
    }
},"t2").start();
  1. 執行緒結束前對變數的寫,對其它執行緒得知它結束後的讀可見(比如其它執行緒呼叫 t1.isAlive() 或t1.join()等待它結束)
static int x;

Thread t1 = new Thread(()->{
	x = 10;
},"t1");

t1.start();
t1.join();
System.out.println(x);
  1. 執行緒 t1 打斷 t2(interrupt)前對變數的寫,對於其他執行緒得知 t2 被打斷後對變數的讀可見
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(()->{
        try {
        	Thread.sleep(1000);
        } catch (InterruptedException e) {
        	e.printStackTrace();
        }
        x = 10;
        t2.interrupt();
    	},"t1").start();
    
    while(!t2.isInterrupted()) {
    	Thread.yield();
    } 
    System.out.println(x);
}
  1. 對變數預設值(0,false,null)的寫,對其它執行緒對該變數的讀可見

  2. 具有傳遞性,如果 x hb-> y 並且 y hb-> z 那麼有 x hb-> z

此外我們還需要注意幾點:

  • 變數都是指成員變數或靜態成員變數
  • happens-before規則非常重要,它是判斷資料是否存在競爭、執行緒是否安全的主要依據

happens-before主要遵循以下幾點規則:

  • 程式順序規則:一個執行緒中的每一個操作,happens-before於該執行緒中的任意後續操作。

  • 監視器規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

  • volatile規則:對一個volatile變數的寫,happens-before於任意後續對一個volatile變數的讀。

  • 傳遞性:若果A happens-before B,B happens-before C,那麼A happens-before C。

  • 執行緒啟動規則:Thread物件的start()方法,happens-before於這個執行緒的任意後續操作。

  • 執行緒終止規則:執行緒中的任意操作,happens-before於該執行緒的終止監測。

    我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。

  • 執行緒中斷操作:對執行緒interrupt()方法的呼叫,happens-before於被中斷執行緒的程式碼檢測到中斷事件的發生

    可以通過Thread.interrupted()方法檢測到執行緒是否有中斷髮生。

  • 物件終結規則:一個物件的初始化完成,happens-before於這個物件的finalize()方法的開始。

經典習題

我們首先補充兩點概念:

  • 餓漢式:類載入就會導致該單範例物件被建立
  • 懶漢式:類載入不會導致該單範例物件被建立,而是首次使用該物件時才會建立

我們最後來介紹幾道經典習題

  1. balking 模式習題
/* 希望 doInit() 方法僅被呼叫一次,下面的實現是否有問題,為什麼? */

public class TestVolatile {
    
    volatile boolean initialized = false;
    
    void init() {
        if (initialized) { 
            return;
        } 
        doInit();
        initialized = true;
    }
    
    private void doInit() {
    }
} 

/*解析*/

存在問題!
沒有對init設定鎖,可能會導致同時有多個執行緒呼叫,導致多次創造
t1進入,判斷未初始化,進行doInit(),t2進入,判斷未初始化,也進行doInit(),然後兩者才進行initialized=true的更改
  1. 執行緒安全單例習題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;
    }
}

/* 問題解析*/
1.(防止被子類繼承從而重寫方法改寫單例)
2.(重寫readResolve方法)
3.(防止外部呼叫構造方法建立多個範例;不能)
4.(能,執行緒安全性由類載入器保障)
5.(可以保證instance的安全性,也能方便實現一些附加邏輯)
  1. 執行緒安全單例習題2
/* 程式碼展示 */

// 問題1:列舉單例是如何限制範例個數的 
// 問題2:列舉單例在建立時是否有並行問題
// 問題3:列舉單例能否被反射破壞單例
// 問題4:列舉單例能否被反序列化破壞單例
// 問題5:列舉單例屬於懶漢式還是餓漢式
// 問題6:列舉單例如果希望加入一些單例建立時的初始化邏輯該如何做
enum Singleton { 
    INSTANCE; 
}

/* 問題解析 */
1.(列舉類會按照宣告的個數在類載入時範例化物件)
2.(沒有,由類載入器保障安全性)
3.(不能)
4.(不能)
5.(餓漢)
6.(寫構造方法)
  1. 執行緒安全單例習題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;
    }
}

/*問題解析*/
(沒有執行緒安全問題,同步程式碼塊粒度太大,效能差)
  1. 執行緒安全單例習題4
/* 程式碼展示 */

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;
        } 
    }
}

/*問題解析*/
1.(防止putstatic和invokespecial重排導致的異常)
2.(縮小了鎖的粒度,提高了效能)
3.(為了防止同時有執行緒進入,在第一個執行緒建立後,其他執行緒進入鎖後再次建立)
  1. 執行緒安全單例習題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;
    }
}

/*問題解析*/
1.(懶漢式,由於初始化方法是在該物件第一次呼叫時才初始化,同樣是屬於類載入不會導致該單範例物件被建立,而是首次使用該物件時才會建立)
2.(沒有並行問題,該物件的建立是在初始化建立,初始化只有一次,不會多次建立,不會修改,也沒有並行問題,由系統保護)

本章小結

下面介紹一下本篇文章的重點內容:

  • 可見性 - 由 JVM 快取優化引起
  • 有序性 - 由 JVM 指令重排序優化引起
  • happens-before 規則
  • 原理方面
    • CPU 指令並行
    • volatile
  • 模式方面
    • 兩階段終止模式的 volatile 改進
    • 同步模式之 balking

結束語

到這裡我們JUC的共用模型之管程就結束了,希望能為你帶來幫助~

附錄

該文章屬於學習內容,具體參考B站黑馬程式設計師滿老師的JUC完整教學

這裡附上視訊連結:05.001-本章內容_嗶哩嗶哩_bilibili