Java設計模式之單例模式

單例模式,是特別常見的一種設計模式,因此我們有必要對它的概念和幾種常見的寫法非常瞭解,而且這也是面試中常問的知識點。

所謂單例模式,就是所有的請求都用一個對象來處理,如我們常用的Spring默認就是單例的,而多例模式是每一次請求都創建一個新的對象來處理,如structs2中的action。

使用單例模式,可以確保一個類只有一個實例,並且易於外部訪問,還可以節省系統資源。如果在系統中,希望某個類的對象只存在一個,就可以使用單例模式。

那怎麼確保一個類只有一個實例呢?

我們知道,通常我們會通過new關鍵字來創建一個新的對象。這個時候類的構造函數是public公有的,你可以隨意創建多個類的實例。所以,首先我們需要把構造函數改爲private私有的,這樣就不能隨意new對象了,也就控制了多個實例的隨意創建。

然後,定義一個私有的靜態屬性,來代表類的實例,它只能類內部訪問,不允許外部直接訪問。

最後,通過一個靜態的公有方法,把這個私有靜態屬性返回出去,這就爲系統創建了一個全局唯一的訪問點。

以上,就是單例模式的三個要素。總結爲:

  1. 私有構造方法
  2. 指向自己實例的私有靜態變量
  3. 對外的靜態公共訪問方法

單例模式分爲餓漢式和懶漢式。它們的主要區別就是,實例化對象的時機不同。餓漢式,是在類加載時就會實例化一個對象。懶漢式,則是在真正使用的時候纔會實例化對象。

餓漢式單例代碼實現:

public class Singleton {

    // 餓漢式單例,直接創建一個私有的靜態實例
    private static Singleton singleton = new Singleton();

    //私有構造方法
    private Singleton(){

    }

    //提供一個對外的靜態公有方法
    public static Singleton getInstance(){
        return singleton;

    }
}

懶漢式單例代碼實現

public class Singleton {

    // 懶漢式單例,類加載時先不創建實例
    private static Singleton singleton = null;

    //私有構造方法
    private Singleton(){

    }

    //真正使用時才創建類的實例
    public static Singleton getInstance(){
        if(singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

稍有經驗的程序員就發現了,以上懶漢式單例的實現方式,在單線程下是沒有問題的。但是,如果在多線程中使用,就會發現它們返回的實例有可能不是同一個。我們可以通過代碼來驗證一下。創建十個線程,分別啓動,線程內去獲得類的實例,把實例的 hashcode 打印出來,只要相同則認爲是同一個實例;若不同,則說明創建了多個實例。

public class TestSingleton {
    public static void main(String[] args) {
        for (int i = 0; i < 10 ; i++) {
            new MyThread().start();
        }
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        Singleton singleton = Singleton.getInstance();
        System.out.println(singleton.hashCode());
    }
}
/**
運行多次,就會發現,hashcode會出現不同值
668770925
668770925
649030577
668770925
668770925
668770925
668770925
668770925
668770925
668770925
*/

所以,以上懶漢式的實現方式是線程不安全的。那餓漢式呢?你可以手動測試一下,會發現不管運行多少次,返回的hashcode都是相同的。因此,認爲餓漢式單例是線程安全的。

那爲什麼餓漢式就是線程安全的呢?這是因爲,餓漢式單例在類加載時,就創建了類的實例,也就是說在線程去訪問單例對象之前就已經創建好實例了。而一個類在整個生命週期中只會被加載一次。因此,也就可以保證實例只有一個。所以說,餓漢式單例天生就是線程安全的。(可以瞭解一下類加載機制)

既然懶漢式單例不是線程安全的,那麼我們就需要去改造一下,讓它在多線程環境下也能正常工作。以下介紹幾種常見的寫法:

1) 使用synchronized方法

實現非常簡單,只需要在方法上加一個synchronized關鍵字即可

public class Singleton {

    private static Singleton singleton = null;

    private Singleton(){

    }

    //使用synchronized修飾方法,即可保證線程安全
    public static synchronized Singleton getInstance(){
        if(singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

這種方式,雖然可以保證線程安全,但是同步方法的作用域太大,鎖的粒度比較粗,因此,執行效率就比較低。

2) synchronized 同步塊

既然,同步整個方法的作用域大,那我縮小範圍,在方法裏邊,只同步創建實例的那一小部分代碼塊不就可以了嗎(因爲方法較簡單,所以鎖代碼塊和鎖方法沒什麼明顯區別)。

public class Singleton {

    private static Singleton singleton = null;

    private Singleton(){

    }

    public static Singleton getInstance(){
        //synchronized只修飾方法內部的部分代碼塊
        synchronized (Singleton.class){
            if(singleton == null){
                singleton = new Singleton();
            }
        }
        return singleton;
    }
}

這種方法,本質上和第一種沒什麼區別,因此,效率提升不大,可以忽略不計。

3) 雙重檢測(double check)

可以看到,以上的第二種方法只要調用getInstance方法,就會走到同步代碼塊裏。因此,會對效率產生影響。其實,我們完全可以先判斷實例是否已經存在。若已經存在,則說明已經創建好實例了,也就不需要走同步代碼塊了;若不存在即爲空,才進入同步代碼塊,這樣可以提高執行效率。因此,就有以下雙重檢測了:

public class Singleton {

    //注意,此變量需要用volatile修飾以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){

    }

    public static Singleton getInstance(){
        //進入方法內,先判斷實例是否爲空,以確定是否需要進入同步代碼塊
        if(singleton == null){
            synchronized (Singleton.class){
                //進入同步代碼塊時也需要判斷實例是否爲空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

需要注意的一點是,此方式中,靜態實例變量需要用volatile修飾。因爲,new Singleton() 是一個非原子性操作,其流程爲:

a.給 singleton 實例分配內存空間
b.調用Singleton類的構造函數創建實例
c.將 singleton 實例指向分配的內存空間,這時認爲singleton實例不爲空

正常順序爲 a->b->c,但是,jvm爲了優化編譯程序,有時候會進行指令重排序。就會出現執行順序爲 a->c->b。這在多線程中就會表現爲,線程1執行了new對象操作,然後發生了指令重排序,會導致singleton實例已經指向了分配的內存空間(c),但是實際上,實例還沒創建完成呢(b)。

這個時候,線程2就會認爲實例不爲空,判斷 if(singleton == null)爲false,於是不走同步代碼塊,直接返回singleton實例(此時拿到的是未實例化的對象),因此,就會導致線程2的對象不可用而使用時報錯。

4)使用靜態內部類

思考一下,由於類加載是按需加載,並且只加載一次,所以能保證線程安全,這也是爲什麼說餓漢式單例是天生線程安全的。同樣的道理,我們是不是也可以通過定義一個靜態內部類來保證類屬性只被加載一次呢。

public class Singleton {

    private Singleton(){

    }

    //靜態內部類
    private static class Holder {
        private static Singleton singleton = new Singleton();
    }

    public static Singleton getInstance(){
        //調用內部類的屬性,獲取單例對象
        return Holder.singleton;
    }
}

而且,JVM在加載外部類的時候,不會加載靜態內部類,只有在內部類的方法或屬性(此處即指singleton實例)被調用時纔會加載,因此不會造成空間的浪費。

5)使用枚舉類

因爲枚舉類是線程安全的,並且只會加載一次,所以利用這個特性,可以通過枚舉類來實現單例。

public class Singleton {

    private Singleton(){

    }

    //定義一個枚舉類
    private enum SingletonEnum {
        //創建一個枚舉實例
        INSTANCE;

        private Singleton singleton;

        //在枚舉類的構造方法內實例化單例類
        SingletonEnum(){
            singleton = new Singleton();
        }

        private Singleton getInstance(){
            return singleton;
        }
    }

    public static Singleton getInstance(){
        //獲取singleton實例
        return SingletonEnum.INSTANCE.getInstance();
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章