多執行緒下指令重排與DCL單列模式

chetwhy發表於2019-04-19

指令重排簡述

1、JMM記憶體模型三大特性包括原子性,可見性,有序性。詳細請看https://juejin.im/post/5cb5d419e51d456e500f7d02。

2、指令重排是相對有序性來說的,指在程式執行過程中, 為了效能考慮, 編譯器和CPU可能會對指令重新排序。單執行緒模式下只有一個執行引擎,不存在競爭,所有的操作都是有有序的,不影響最後的執行結果。

3、指令重排只能保證序列(單執行緒)語句執行的一致性。

單例模式

假設我的單列物件是Faith(一個人只有一個信仰),檢視多執行緒下示例的建立次數,即建構函式的呼叫次數。

餓漢模式

示例程式碼

class Faith {
    private static Faith myFaith = new Faith();
    private Faith(){
        System.out.println("Faith.Faith --- 私有構造呼叫了");
    }

    public static Faith getMyFaith() {
        return myFaith;
    }
}

public class TestSingleton {
    public static void main(String[] args) {
        for (int i = 0; i <= 10; i++) {
            new Thread(() -> {
                Faith.getMyFaith();
            },String.valueOf(i)).start();
        }
    }
}
複製程式碼

控制檯:

Faith.Faith --- 私有構造呼叫了
複製程式碼
  • 多條執行緒同時執行時,只建立了一個例項。
  • 餓漢模式下,在類載入的時候建立一次例項,不會存在多個執行緒建立多個例項的情況。但在類載入時就自動建立,佔用記憶體。
  • 因此重點講懶漢模式,即第一次呼叫獲取實列方法時,才被動建立物件。

懶漢模式

單執行緒懶漢模式

示例程式碼

class Faith {
    private static Faith myFaith = null;
    private Faith(){
        System.out.println(Thread.currentThread().getName()+" --- Faith.Faith --- 私有構造呼叫了");
    }

    public static Faith getMyFaith() {
        if (myFaith == null){
             myFaith =  new Faith();
        }
        return myFaith;
    }
}
複製程式碼

上面的程式碼是單執行緒下的懶漢模式,但是在併發情況下,當myFaith為空,需new物件時,多個執行緒可能同時進入這個方法。

public class TestSingleton {
    public static void main(String[] args) {
        for (int i = 0; i <= 10; i++) {
            new Thread(() -> {
                Faith.getMyFaith();
            },String.valueOf(i)).start();
        }
    }
}
複製程式碼

控制檯:

5 --- Faith.Faith --- 私有構造呼叫了
1 --- Faith.Faith --- 私有構造呼叫了
8 --- Faith.Faith --- 私有構造呼叫了
4 --- Faith.Faith --- 私有構造呼叫了
2 --- Faith.Faith --- 私有構造呼叫了
3 --- Faith.Faith --- 私有構造呼叫了
9 --- Faith.Faith --- 私有構造呼叫了
7 --- Faith.Faith --- 私有構造呼叫了
10 --- Faith.Faith --- 私有構造呼叫了
0 --- Faith.Faith --- 私有構造呼叫了
6 --- Faith.Faith --- 私有構造呼叫了
複製程式碼

可以看到,結果非常糟糕,得到多個不同物件。

多執行緒懶漢模式-synchronized

最直接的方法就是在靜態方法上加synchronized互斥鎖.

public static synchronized Faith getMyFaith() {
    if (myFaith == null){
        myFaith =  new Faith();
    }
    return myFaith;
}
複製程式碼

synchronized屬於重量鎖,在高併發情況下,上百條個執行緒都等在靜態方法外,阻塞很大,不推薦。

多執行緒懶漢模式-DCL

DCL(double check lock)雙端檢索機制,在new方法上加同步鎖,但要在加鎖前後進行非空判斷。

class Faith {
    private static Faith myFaith = null;
    private Faith(){
        System.out.println(Thread.currentThread().getName()+" --- Faith.Faith --- 私有構造呼叫了");
    }

    public static Faith getMyFaith() {
        // 第一次判斷,若myFaith例項為空
        if (myFaith == null){
            // 加同步鎖
            synchronized (Faith.class) {
                // 第二次判斷,若myFaith例項確實為空,進入構造方法
                if (myFaith == null) {
                    myFaith = new Faith();
                }
            }
        }
        return myFaith;
    }
}
複製程式碼
public class TestSingleton {
    public static void main(String[] args) {
        for (int i = 0; i <= 10; i++) {
            new Thread(() -> {
                Faith.getMyFaith();
            },String.valueOf(i)).start();
        }
    }
}
複製程式碼

控制檯:

0 --- Faith.Faith --- 私有構造呼叫了
複製程式碼
  • 可以看到,10條執行緒下,只獲取到一個實列物件,看似是一個相對高效的方法。但在本文一開始,就提到了指令重排。
  • 當myFaith為空,進入初始化,當還沒初始化完成時,會有執行緒安全問題。

指令重排分析

myFaith = new Faith();,該方法其實有3步:

1、分配記憶體空間何記憶體地址

memeory = allocate;
複製程式碼

2、初始化物件

myFaith(memory);
複製程式碼

3、將例項指向分配的記憶體地址

myFaith = memory;
複製程式碼

第二步和第三步沒有資料依賴關係,單執行緒下指令重排不影響執行結果,因此編譯器和cpu允許重排優化的行為。

即可能出現第三步先於第二部執行, myFaith = memory; 此時因為已經給即將建立的myFaith分配了記憶體空間,所以myFaith!=null,但物件的初始化還沒有完成,造成執行緒安全問題。

多執行緒懶漢模式-DCL+volatile

JMM保證有序性的重要方法就是引入J.U.C併發包下的volatile關鍵字,volatile 關鍵字通過新增記憶體屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到記憶體屏障之前。

即原來的DCL單例模式,在例項物件上再加volatile修飾即可。

private static volatile Faith myFaith = null;
複製程式碼

相關文章