JUC學習筆記——共享模型之內存

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