Java設計模式之--單例模式

Java設計模式系列:

  1. 設計模式入門:https://blog.csdn.net/u011863006/article/details/89223282
  2. 單例模式:https://blog.csdn.net/u011863006/article/details/84201592

Java各種技術、各種框架更新的速度越來越快,學習成本越來越高,但是我們學習Java要學習其中不變的部分,其中設計模式就是最高層次的抽象,是高出框架、語言的。所以學習的收益也是最高的,不會被時代淘汰,並且幾乎在任何一個面試中都會被問到。
最近在看《Head First設計模式》這本書,準備將其中的感悟結合平時的積累總結一下,寫一個設計模式系列博客。

首先就從最簡單的單例模式開始吧。單例模式的定義就是確保一個類只有一個實例,並提供一個全局的訪問點。那麼什麼時候需要單例模式呢,比如說:線程池、連接池、緩存、註冊表、日誌對象,還有打印機、顯卡的驅動程序,這些類只能有一個實例,如果有多個實例就對造成混亂。

單例模式從對象生成的時間上可以分爲懶漢模式和餓漢模式,懶漢模式比較懶:就是用到這個對象的時候就創建;餓漢模式比較飢渴:就是在類加載的時候就對對象進行創建。餓漢模式比較簡單,我們先說餓漢模式。

餓漢模式

我們以一個打印機爲例,因爲我們只有一個打印機,所有我們對應的程序中只能有一個打印機的實例對象。爲了不讓程序隨便的new出很多對象,我們最想想到的是將構造函數變成私有的。代碼如下圖:

package com.sheliming.singlenton.hungry;

/**
 * 打印機類
 * 餓漢模式
 */
public class Printer {
    private static Printer printer = new Printer();

    private Printer() {
    }

    public static Printer getInstance() {
        return printer;
    }
}

這段代碼看似很完美,它的好處是隻在類加載的時候創建一次實例,不會存在多個線程創建多個實例的情況,避免了多線程同步的問題。它的缺點也很明顯,即使這個單例沒有用到也會被創建,而且在類加載之後就被創建,內存就被浪費了。
所以下面就進入到我們懶漢式的單例模式:

懶漢式

懶漢式比較複雜,我們一一道來:

1.入門級

懶漢式就是使用的時候new對象,那麼就應該在getInstance的時候創建對象,代碼如下:

package com.sheliming.singlenton.lazy;

/**
 * 打印機類
 * 懶漢模式
 */
public class Printer {
    private static Printer printer = null;

    private Printer() {
    }

    public static Printer getInstance() {
        if (printer == null) {
            printer = new Printer();

        }
        return printer;
    }
}

這段代碼在單線程的時候沒有任何問題,但是到了多線程中,就會出問題。例如:當兩個線程同時運行到判斷if (printer == null)語句,並且instance確實沒有創建好時,那麼兩個線程都會創建一個實例。

2.加snychronized關鍵字

既然多線程下有問題,我們首先想到的是在getInstance方法上加上snychronized關鍵字,這樣在多線程的方法中同時只有一個線程可以訪問這個方法,這樣對象只會被初始化一次,在訪問的時候if (printer == null)已經不爲null了。代碼如下

package com.sheliming.singlenton.lazy;

/**
 * 打印機類
 * 懶漢模式,帶synchronized關鍵字
 */
public class Printer2 {
    private static Printer2 printer = null;

    private Printer2() {
    }

    public synchronized static Printer2 getInstance() {
        if (printer == null) {
            printer = new Printer2();

        }
        return printer;
    }
}

但是這個方法也有缺點:每次通過getInstance方法得到singleton實例的時候都有一個試圖去獲取同步鎖的過程。而衆所周知,加鎖是很耗時的。能避免則避免。

3.雙重校驗鎖(Double-Check)

下面這種方法不僅可以避免線程安全的問題,而且可以避免每次獲取對象的時候進行加鎖,代碼如下:

package com.sheliming.singlenton.lazy;

/**
 * 打印機類
 * 懶漢模式,雙重校驗鎖
 */
public class Printer3 {
    private static Printer3 printer = null;

    private Printer3() {
    }

    public static Printer3 getInstance() {
        if (printer == null) {
            synchronized (Printer3.class) {
                if (printer == null) {
                    printer = new Printer3();
                }
            }
        }
        return printer;
    }
}

4.終極版本volatile關鍵字

我們看到雙重校驗鎖即實現了延遲加載,又解決了線程併發問題,同時還解決了執行效率問題,是否真的就萬無一失了呢?

這裏要提到Java中的指令重排優化。所謂指令重排優化是指在不改變原語義的情況下,通過調整指令的執行順序讓程序運行的更快。JVM中並沒有規定編譯器優化相關的內容,也就是說JVM可以自由的進行指令重排序的優化。

這個問題的關鍵就在於由於指令重排優化的存在,導致初始化Singleton和將對象地址賦給instance字段的順序是不確定的。在某個線程創建單例對象時,在構造方法被調用之前,就爲該對象分配了內存空間並將對象的字段設置爲默認值。此時就可以將分配的內存地址賦值給instance字段了,然而該對象可能還沒有初始化。若緊接着另外一個線程來調用getInstance,取到的就是狀態不正確的對象,程序就會出錯。

以上就是雙重校驗鎖會失效的原因,不過還好在JDK1.5及之後版本增加了volatile關鍵字。volatile的一個語義是禁止指令重排序優化,也就保證了instance變量被賦值的時候對象已經是初始化過的,從而避免了上面說到的問題。代碼如下:

package com.sheliming.singlenton.lazy;

/**
 * 打印機類
 * 懶漢模式,雙重校驗鎖
 */
public class Printer4 {
    private static volatile Printer4 printer = null;

    private Printer4() {
    }

    public static Printer4 getInstance() {
        if (printer == null) {
            synchronized (Printer4.class) {
                if (printer == null) {
                    printer = new Printer4();
                }
            }
        }
        return printer;
    }
}

這種方法是可以在生產環境中使用的!!

5、靜態內部類

除了以上幾種方法,還有2中比較巧妙的方法。首先是靜態內部類的方法:

package com.sheliming.singlenton.lazy.staticclass;

/**
 * 打印機類
 * 靜態內部類
 */
public class Printer {
    private static class PrinterHolder{
        public static Printer printer = new Printer();
    }

    private Printer() {
    }

    public static Printer getInstance() {

        return PrinterHolder.printer;
    }
}

這種寫法非常巧妙:

  1. 對於內部類SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder初始化的時候會由ClassLoader來保證同步,使INSTANCE是一個真·單例。
  2. 同時,由於SingletonHolder是一個內部類,只在外部類的Singleton的getInstance()中被使用,所以它被加載的時機也就是在getInstance()方法第一次被調用的時候。
    ——它利用了ClassLoader來保證了同步,同時又能讓開發者控制類加載的時機。從內部看是一個餓漢式的單例,但是從外部看來,又的確是懶漢式的實現。

6.枚舉實現

還有最後一種方式:

public enum SingleInstance {
    INSTANCE;
    public void fun1() { 
        // do something
    }
}
// 使用
SingleInstance.INSTANCE.fun1();

上面提到的四種實現單例的方式都有共同的缺點:

  1. 需要額外的工作來實現序列化,否則每次反序列化一個序列化的對象時都會創建一個新的實例。
  2. 可以使用反射強行調用私有構造器(如果要避免這種情況,可以修改構造器,讓它在創建第二個實例的時候拋異常)。
    而枚舉類很好的解決了這兩個問題,使用枚舉除了線程安全和防止反射調用構造器之外,還提供了自動序列化機制,防止反序列化的時候創建新的對象。因此,《Effective Java》作者推薦使用的方法。不過,在實際工作中,很少看見有人這麼寫。但是它仍然不是完美的——比如,在需要繼承的場景,它就不適用了。

總結

說了這麼多,總結一下生產中經常使用的是:

  1. 加voletile關鍵字的雙重檢查鎖。
  2. 餓漢模式。

參考文獻

《Head First設計模式》
https://blog.csdn.net/goodlixueyong/article/details/51935526
https://www.cnblogs.com/dongyu666/p/6971783.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章