1. 程式人生 > >為什麼用列舉類來實現單例模式越來越流行?

為什麼用列舉類來實現單例模式越來越流行?

前言

單例模式是 Java 設計模式中最簡單的一種,只需要一個類就能實現單例模式,但是,你可不能小看單例模式,雖然從設計上來說它比較簡單,但是在實現當中你會遇到非常多的坑,所以,繫好安全帶,上車。

單例模式的定義

單例模式就是在程式執行中只例項化一次,建立一個全域性唯一物件,有點像 Java 的靜態變數,但是單例模式要由於靜態變數,靜態變數在程式啟動的時候就要建立,會造成大量的資源浪費,好的單例模式不會有這個問題。開發中的很多工具類都應用了單例模式,執行緒池、快取、日誌物件等,它們都只需要建立一個物件,如果建立多份例項,可能會帶來不可預知的問題,比如資源的浪費、結果處理不一致等問題。

單例的實現思路

  • 靜態化例項物件
  • 私有化構造方法,禁止通過構造方法建立例項
  • 提供一個公共的靜態方法,用來返回唯一例項

單例的好處

  • 只有一個物件,記憶體開支少、效能好(當一個物件的產生需要比較多的資源,如讀取配置、產生其他依賴物件時,可以通過應用啟動時直接產生一個單例物件,讓其永駐記憶體的方式解決)

  • 避免對資源的多重佔用(一個寫檔案操作,只有一個例項存在記憶體中,避免對同一個資原始檔同時寫操作

  • 在系統設定全域性訪問點,優化和共享資源訪問(如:設計一個單例類,負責所有資料表的對映處理)

單例模式的實現

單例模式的主流寫法有餓漢模式、懶漢模式、雙重檢查鎖模式、靜態內部類單例模式、列舉類實現單例模式五種方式,其中懶漢模式、雙重檢查鎖模式兩種模式寫法不當,會導致在多執行緒下不是單例或者單例出異常,後面將會給大家詳細介紹。我們從最基本的餓漢模式開始我們的單例編寫之路。

餓漢模式

餓漢模式採用的是一種簡單粗暴的形式,在定義靜態屬性時,直接例項化了物件。程式碼如下:

//在類載入時就完成了初始化,所以類載入較慢,但獲取物件的速度快
public class SingletonObject1 {
    // 利用靜態變數來儲存唯一例項
    private static final SingletonObject1 instance = new SingletonObject1();

    // 私有化建構函式
    private SingletonObject1(){
        // 裡面可能有很多操作
    }

    // 提供公開獲取例項介面
    public static SingletonObject1 getInstance(){
        return instance;
    }
}

餓漢模式的優缺點

優點
  • JVM層面的執行緒安全,static關鍵字保證了在引用這個變數時,關於這個變數的所以寫入操作都完成
缺點
  • 不能實現懶載入,造成空間浪費,如果一個類比較大,我們在初始化的時就載入了這個類,但是我們長時間沒有使用這個類,這就導致了記憶體空間的浪費。

懶漢模式

懶漢模式就像一個懶漢的時候,只有餓了才會想辦法找東西來填飽肚子,從來不會先的準備好食物,以防餓了。懶漢模式實現了懶載入,解決了餓漢模式帶來的空間浪費問題,實現了使用時才去初始化類,但是也引入了其他的問題,我們先來看看下面這個懶漢模式

public class SingletonObject2 {
    // 定義靜態變數時,未初始化例項
    private static SingletonObject2 instance;

    // 私有化建構函式
    private SingletonObject2(){

    }

    public static SingletonObject2 getInstance(){
        // 使用時,先判斷例項是否為空,如果例項為空,則例項化物件
        if (instance == null)
            instance = new SingletonObject2();
        return instance;
    }
}

上面這段懶漢模式實現程式碼,在多執行緒的情況下,不能保證是單例模式,主要問題出現在例項化物件的時候,所以我單獨把例項化的程式碼提出來,給大夥講講為什麼在多執行緒的情況下有可能會初始化多份例項。

     1   if (instance == null)
     2       instance = new SingletonObject2();

假設有兩個執行緒都進入到 1 這個位置,因為我們沒有任何資源保護措施,所以兩個執行緒判斷的instance都為空,都將去執行 2 的例項化程式碼,所以就會出現多份例項的情況。

我們已經知道,上面的這段程式碼出現多份例項是因為沒有對資源進行保護,如果我們對資源加鎖,是不是可以解決多份例項的問題?確實如此,我們給getInstance()方法加上synchronized關鍵字,使得getInstance()方法成為受保護的資源就能夠解決多份例項的問題。加上synchronized關鍵字之後程式碼如下:

public class SingletonObject3 {
    private static SingletonObject3 instance;

    private SingletonObject3(){

    }

    public synchronized static SingletonObject3 getInstance(){
        /**
         * 新增class類鎖,影響了效能,加鎖之後將程式碼進行了序列化,
         * 我們的程式碼塊絕大部分是讀操作,在讀操作的情況下,程式碼執行緒是安全的
         *
         */

        if (instance == null)
            instance = new SingletonObject3();
        return instance;
    }
}

這樣確實解決了可能出現多份例項的情況,但是加synchronized關鍵字之後,引入了新的問題,加鎖之後將程式碼進行了序列化,降低了系統的使用效能。getInstance()方法大部分的操作都是讀操作,讀操作是執行緒安全的。

懶漢模式的優缺點

優點
  • 實現了懶載入,節約了記憶體空間
缺點
  • 在不加鎖的情況下,執行緒不安全,可能出現多份例項
  • 在加鎖的情況下,會是程式序列化,使系統有嚴重的效能問題

雙重檢查鎖模式

在懶漢模式中我們知道了getInstance()方法大部分的操作都是讀操作,讀操作是執行緒安全的,對getInstance()方法加鎖造成了很大的效能問題,由此產生了一種更加優雅的加鎖方式,既能對getInstance()加鎖,又能不降低效能,這種模式就是我們現在要了解的雙重檢查鎖模式,我們先來看看雙重檢查鎖模式的單例實現:

public class SingletonObject4 {
    private static SingletonObject4 instance;

    private SingletonObject4(){

    }

    public static SingletonObject4 getInstance(){

        // 第一次判斷,如果這裡為空,不進入搶鎖階段,直接返回例項
        if (instance == null)
            synchronized (SingletonObject4.class){
                // 搶到鎖之後再次判斷是否為空
                if (instance == null){
                    instance = new SingletonObject4();
                }
            }

        return instance;
    }
}

可以說雙重檢查鎖模式是一種非常好的單例實現模式,解決了單例、效能、執行緒安全問題,上面的實現看上去完美無缺,但是上面的實現程式碼,在多執行緒的情況下,可能會出現空指標問題,下面我們一起來了解是為什麼會出現空指標問題。

空指標問題是由於虛擬機器的優化和指令重排序造成的,我們在例項化物件時,虛擬機器會對裡面的程式碼進行優化,也許你還不太理解,我們來看看下面一段程式碼

    private SingletonObject4(){
     1   int x = 10;
     2   int y = 30;
     3  Object o = new Object();
                
    }

JVM 在例項化SingletonObject4()時不一定按照1、2、3的順序執行,JVM 會對它進行優化,可能是3、1、2,也可能是2、3、1,JVM 會保證最後都例項化完成。 如果建構函式中操作比較多時,為了提升效率,JVM 會在建構函式裡面的屬性為全部完成例項化時就返回物件。這也就造成了其他執行緒獲取到例項,在使用某屬性時,可能該屬性還沒例項化完成,就會造成空指標異常。

要解決上面雙重檢查鎖模式帶來空指標異常的問題,需要使用volatile關鍵字,volatile關鍵字嚴格遵循happens-before原則,即在讀操作前,寫操作必須全部完成。新增volatile關鍵字之後的單例模式程式碼:

    // 新增volatile關鍵字
    private static volatile SingletonObject5 instance;

    private SingletonObject5(){

    }

    public static SingletonObject5 getInstance(){

        if (instance == null)
            synchronized (SingletonObject5.class){
                if (instance == null){
                    instance = new SingletonObject5();
                }
            }

        return instance;
    }
}

新增volatile關鍵字之後的雙重檢查鎖模式是一種比較好的單例實現模式,能夠保證在多執行緒的情況下執行緒安全也不會有效能問題。

靜態內部類單例模式

靜態內部類單例模式也稱單例持有者模式,例項由內部類建立,由於 JVM 在載入外部類的過程中, 是不會載入靜態內部類的, 只有內部類的屬性/方法被呼叫時才會被載入, 並初始化其靜態屬性。靜態屬性由static修飾,保證只被例項化一次,並且嚴格保證例項化順序。靜態內部類單例模式程式碼如下:

public class SingletonObject6 {


    private SingletonObject6(){

    }
    // 單例持有者
    private static class InstanceHolder{
        private  final static SingletonObject6 instance = new SingletonObject6();

    }
    
    // 
    public static SingletonObject6 getInstance(){
        // 呼叫內部類屬性
        return InstanceHolder.instance;
    }
}

靜態內部類單例模式是一種比較好的單例實現模式,也是比較常用的一種單例實現模式。在沒有加任何鎖的情況下,保證了多執行緒下的安全,並且沒有任何效能影響和空間的浪費。

列舉類實現單例模式

列舉類實現單例模式是 effective java 作者極力推薦的單例實現模式,列舉型別是執行緒安全的,並且只會裝載一次,設計者充分的利用了這個特性來實現單例模式,列舉的寫法非常簡單,而且列舉型別是所用單例實現中唯一一種不會被破壞的單例實現模式。

public class SingletonObject7 {


    private SingletonObject7(){

    }

    /**
     * 列舉型別是執行緒安全的,並且只會裝載一次
     */
    private enum Singleton{
        INSTANCE;

        private final SingletonObject7 instance;

        Singleton(){
            instance = new SingletonObject7();
        }

        private SingletonObject7 getInstance(){
            return instance;
        }
    }

    public static SingletonObject7 getInstance(){

        return Singleton.INSTANCE.getInstance();
    }
}

破壞單例模式的方法及解決辦法

1、除列舉方式外, 其他方法都會通過反射的方式破壞單例,反射是通過呼叫構造方法生成新的物件,所以如果我們想要阻止單例破壞,可以在構造方法中進行判斷,若已有例項, 則阻止生成新的例項,解決辦法如下:

private SingletonObject1(){
    if (instance !=null){
        throw new RuntimeException("例項已經存在,請通過 getInstance()方法獲取");
    }
}

2、如果單例類實現了序列化介面Serializable, 就可以通過反序列化破壞單例,所以我們可以不實現序列化介面,如果非得實現序列化介面,可以重寫反序列化方法readResolve(), 反序列化時直接返回相關單例物件。

  public Object readResolve() throws ObjectStreamException {
        return instance;
    }

最後

打個小廣告,歡迎掃碼關注微信公眾號:「平頭哥的技術博文」,一起進步吧。