DCL之單例模式

  • 2021 年 2 月 28 日
  • 筆記

所謂的DCL 就是 Double Check Lock,即雙重鎖定檢查,在了解DCL在單例模式中如何應用之前,我們先了解一下單例模式。單例模式通常分為「餓漢」和「懶漢」,先從簡單入手

餓漢

所謂的「餓漢」是因為程式剛啟動時就創建了實例,通俗點說就是剛上菜,大家還沒有開始吃的時候就先自己吃一口。

public class Singleton {
    private static final Singleton singleton = new Singleton();
    private Singleton(){}
    public static Singleton getInstance(){
        return singleton;
    }
}

第3行 通過一個私有構造方法限制了創建此類對象的途徑(反射忽略)。這種方法很安全,但從某種程度上有點浪費資源,比方說從一開始就創建了Singleton實例,但很少去用它,這就造成了方法區資源的浪費,因此出現了另外一種單例模式,即懶漢單例模式

懶漢

之所以叫「懶漢」是因為只有真正叫它的時候,才會出現,不叫它它就不理,跟它沒關係。也就是說真正用到它的時候才去創建實例,並不是一開始就創建實例。如下程式碼所示:


public class Singleton {
    private static Singleton singleton = null;
    private Singleton(){}
    public static Singleton getInstance(){
        if(null == singleton){
            singleton = new Singleton();
        }
        return singleton;
    }
}

看似很簡單的一段程式碼,但存在一個問題,就是執行緒不安全的問題。例如,現在有1000個執行緒,都需要這一個Singleton的實例,驗證一下是否拿到同一個實例,程式碼如下所示:

public class Singleton {
    private static Singleton singleton = null;
    private Singleton(){}
    public static Singleton getInstance(){
        if(null == singleton){
            try {
                Thread.sleep(1);//象徵性的睡了1ms
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            singleton = new Singleton();
        }
        return singleton;
    }

    public static void main(String[] args) {
        for (int i=0;i<1000;i++){
            new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
        }
    }
}

部分運行結果,亂七八糟:

944436457
1638599176
710946821
67862359

為什麼會這樣?第一個執行緒過來了,執行到第7行,睡了1ms,正在睡的同時第二個執行緒來了,第二個執行緒執行到第5行時,結果肯定為空,因此接下來將會有兩個執行緒各自創建一個對象,這必然會導致Singleton.getInstance().hashCode()結果不一致。可以通過給整個方法加上一把鎖改進如下:

改進1

public class Singleton {
    private static Singleton singleton = null;
    private Singleton(){}
    public static synchronized Singleton getInstance(){
        if(null == singleton){
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            singleton = new Singleton();
        }
        return singleton;
    }

    public static void main(String[] args) {
        for (int i=0;i<1000;i++){
            new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
        }
    }
}

通過給getInstance()方法加上synchronized來解決執行緒一致性問題,結果分析雖然顯示所有實例的hashcode都一致,但是synchronized的粒度太大了,即鎖的臨界區太大了,有點影響效率,例如如果第4行和第5行之間有業務處理邏輯,不會涉及共享變數,那麼每次對這部分業務邏輯加鎖必然會導致效率低下。為了解決粗粒度的問題,可以對程式碼進一步改進:

改進2

public class Singleton {
    private static Singleton singleton = null;
    private Singleton(){}
    public static Singleton getInstance(){
        /*
        一堆業務處理程式碼
         */
        if(null == singleton){
            synchronized(Singleton.class){//鎖粒度變小
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                singleton = new Singleton();
            }
        }
        return singleton;
    }

    public static void main(String[] args) {
        for (int i=0;i<1000;i++){
            new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
        }
    }
}

部分運行結果 :

391918859
391918859
391918859
1945023194

通過分析運行結果發現,雖然鎖的粒度變小了,但執行緒不安全了。為什麼會這樣呢?因為有種情況,執行緒1執行完if判斷後還沒有拿到鎖的時候時間片用完了,此時執行緒2來了,執行if判斷時發現對象還是空的,繼續往下執行,很順利的拿到鎖了,因此執行緒2創建了一個對象,當執行緒2創建完之後釋放掉鎖,這時執行緒1激活了,順利的拿到鎖,又創建了一個對象。所以程式碼還需要再一步的改進。

改進3

public class Singleton {
    private static Singleton singleton = null;
    private Singleton(){}
    public static Singleton getInstance(){
        /*
        一堆業務處理程式碼
         */
        if(null == singleton){
            synchronized(Singleton.class){//鎖粒度變小
                if(null == singleton){//DCL
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

    public static void main(String[] args) {
        for (int i=0;i<1000;i++){
            new Thread(()-> System.out.println(Singleton.getInstance().hashCode())).start();
        }
    }
}

通過在第10行又加了一層if判斷,也就是所謂的Double Check Lock。也就是說即便拿到鎖了,也得去作一步判斷,如果這時判斷對像不為空,那麼就不用再創建對象,直接返回就可以了,很好的解決了「改進2」中的問題。但這裡第8行是不是可以去了,我個人覺得都行,保留第8行的話,是為了提升效率,因為如果去了,每個執行緒過來就直接搶鎖,搶鎖本身就會影響效率,而if判斷就幾ns,且大部分執行緒是不需要搶鎖的,所以最好保留。
到這DCL 單例的原理就介紹完了,但是還是有一個問題。就是需要考慮指令重排序的問題,因此得加入volatile來禁止指令重排序。繼續分析程式碼,為了分析方便這裡將Singleton程式碼簡化:

public class Singleton {
    int a = 5;//考慮指令重排序的問題
}

singleton = new Singleton()的位元組碼如下:

  0: new    #2           // class com/reasearch/Singleton
  3: dup
  4: invokespecial #3   // Method com/reasearch/Singleton."<init>":()V
  7: astore_1

先不管dup指令。這裡補充一個知識點,創建對象的時候,先分配空間,類裡面的變數先有一個默認值,等調用了構造方法後才給變數賦值。例如int a = 5剛開始的時候 a = 0。位元組碼指令執行過程如下,

  1. new 分配空間,a=0
  2. invokespecial 構造方法 a=5
  3. astore_1將對象賦給singleton

這是理想的狀態,2和3語義和邏輯上沒有什麼關聯,因此jvm可以允許這些指令亂序執行,即先執行3再執行2 。回到改進3,假如執行緒1再執行第16行程式碼時,指令的執行順序是1,3,2,當執行完3時,時間片用完了,此時a=0,也就是說初始化到一半時就掛起了。這時執行緒2 來了,第8行判斷,singleton肯定不為空,因此直接返回一個Singleton的對象,但其實這個對象是一個問題對象,是一個半初始化的對象,即a=0。這就是指令重排序造成的,因此為了防止這種現象的發生加上關鍵字volatile就可以了。因而,最終DCL之單例模式的程式碼完整版如下:

完整版

public class Singleton {
    private volatile static Singleton singleton = null;//加上volatile 
    private Singleton(){}
    public static Singleton getInstance(){
        /*
        一堆業務處理程式碼
         */
        if(null == singleton){
            synchronized(Singleton.class){//鎖粒度變小
                if(null == singleton){//DCL
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

至此,可以告一段落了,相信很多小夥伴都會寫單例,但是了解其中的原理還是有一定的難度,大家一起加油!