設計模式——單例模式

  • 2020 年 9 月 15 日
  • 筆記

單例模式

個人主頁

單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。

這種模式涉及到一個單一的類,該類負責創建自己的對象,同時確保只有單個對象被創建。這個類提供了一種訪問其唯一的對象的方式,可以直接訪問,不需要實例化該類的對象。

注意:

  • 1、單例類只能有一個實例。
  • 2、單例類必須自己創建自己的唯一實例。
  • 3、單例類必須給所有其他對象提供這一實例。

餓漢式單例

​ 由於餓漢式單例是在類加載的時候創建的實例,避免了線程安全問題,所以是線程安全的。

​ 但是由於餓漢式是在類加載的時候就初始化,所以浪費內存。

/**
 * Hungry   餓漢式單例
 */
public class Hungry {
	//如果此時加入一個成員,那類加載的時候就初始化,會浪費內存
    private Hungry() {
			/*單例模式構造器都是私有的*/
    }

    private final static Hungry HUNGRY = new Hungry();

    public static Hungry getInstance() {
        return HUNGRY;
    }
}

靜態內部類

package Design_Patterns.Single;

//靜態內部類實現單例
public class Holder {

    private Holder() {
        //單例模式,都必須構造器私有
    }

    public static Holder getInstance() {
        return InnerClass.HOLDER;
    }
	//一個靜態的內部類
    public static class InnerClass {
        private static final Holder HOLDER = new Holder();
    }
}

懶漢式單例

線程不安全

/**
 * 懶漢式單例
 */
public class Lazy {

    private Lazy() {
        System.out.println(Thread.currentThread().getName() + "ok");
    }

    private static Lazy LAZY;

    public static Lazy getInstance() {
        if (LAZY == null) {
            LAZY = new Lazy();  //不是一個原子性操作
        }
        return LAZY;    
    }
}

分析:假如在getInstance()方法中,判斷LAZY為null後,CPU切換到另一個線程,再來判斷又是null,CPU繼續切換回剛開始那個線程,繼續執行new對象操作,然後CPU切換回第二個線程,也會順着繼續執行new對象操作,此時的對象就不再是單個的對象,違反了單例模式。

我們對可以做一個測試:通過輸出得知調用了四次構造函數,已經破壞了單例模式

public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    Lazy.getInstance();
                }
            }.start();
        }
    }
/**output
Thread-1ok
Thread-3ok
Thread-0ok
Thread-2ok
*/

線程安全

為了解決線程安全問題,我們採用雙重檢驗鎖(DCL,即 double-checked locking)

/**
 * 懶漢式單例
 */
public class Lazy {

    private Lazy() {
        synchronized(Lazy.class) {
            if(LAZY != null) {		//防止反射破壞
                throw new RuntimeException("不要試圖使用反射破壞單例");
            } else {
                System.out.println(Thread.currentThread().getName() + "ok");
            }            
        } 
    }

    private volatile static Lazy LAZY;

    //雙重檢測鎖模式的懶漢式單例
    public static Lazy getInstance() {
        if (LAZY == null) {
            synchronized (Lazy.class) {
                if (LAZY == null) {
                    LAZY = new Lazy();  //不是一個原子性操作
                }
            }
        }
        return LAZY;    
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    Lazy.getInstance();
                }
            }.start();
        }
    }
}

分析:

為什麼給LAZY對象加volatile關鍵字

在Java中new一個對象並非一個原子操作,可分為三步:

  1. 分配內存空間
  2. 執行構造方法,初始化對象
  3. 把這個對象指向空間

由於new對象並不是一個原子操作,所以可能發生指令重排,執行順序可能是123,也可能是132,假如指令執行順序變成了132:

  1. 假如A進程剛進來,先分配內存空間,再把對象指向這個空間
  2. 此時進來一個線程B,由於LAZY已經指向了一個空間,它會認為對象不為null,所以會直接返回
  3. 此時LAZY還未完成構造,空間是一片虛無,所以LAZY必須要避免指令重排,加volatile
反射對單例的破壞

Java的反射可以從class中反射出構造函數,從而達到創建對象的目的,也就破壞了單例的「只有一個實例」。

public static void main(String[] args) throws Exception {
        Lazy lazy1 = Lazy.getInstance();
        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);   //構造器是空參
        declaredConstructor.setAccessible(true);
        Lazy lazy2 = declaredConstructor.newInstance();
        System.out.println(lazy1.hashCode() + " ::: " + lazy2.hashCode());
    }
/** output
705927765 ::: 366712642
*/ 

可以看出兩個對象並不是同一個對象,而是不同的兩個對象,所以單例模式被破壞了。所以在構造函數里我們應該加上對對象的判斷,如果LAZY已經不為空,就要拋出異常。

more try

當然除此之外,就算在構造器中加入了判斷,也可以利用反射對單例造成破壞。判斷是根據類中聲明的對象是否為空來作為依據的,如果我們不調用getInstance()方法,而是直接利用反射構造出兩個對象,即可避過這種檢查,使LAZY一直等於null。

public static void main(String[] args) throws Exception {
        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);   //構造器是空參
        declaredConstructor.setAccessible(true);
        Lazy lazy1 = declaredConstructor.newInstance();
        Lazy lazy2 = declaredConstructor.newInstance();
        System.out.println(lazy1.hashCode() + " ::: " + lazy2.hashCode());
    }
/**output
705927765 ::: 366712642
*/

出現了這種情況,我已經可以解決。加入一個變量,這個變量的名字可以是加密過後的,在構造器中繼續加入判斷

private static boolean flag = false;   //表示還未調用過構造器new對象

    private Lazy() {
        synchronized(Lazy.class) {
            if(flag == false) { //還未new過對象
                flag = true;
            } else {
                throw new RuntimeException("不要嘗試使用反射破壞單例");
            }
        }
    }

當然,這種也不是絕對安全的,如果利用反編譯技術,可以得到flag這個變量(雖說已經加過密,但有加密也就有解密),那麼flag依舊可以被反射出來,看下面示例:

public static void main(String[] args) throws Exception {

        //對flag變量的反射
        Field flag = Lazy.class.getDeclaredField("flag");
        flag.setAccessible(true);
		
    	//對構造器的反射
        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);   //構造器是空參
        declaredConstructor.setAccessible(true);
        Lazy lazy1 = declaredConstructor.newInstance();
        flag.set(lazy1, false);				//將標誌變量又變回false
        Lazy lazy2 = declaredConstructor.newInstance();
        System.out.println(lazy1.hashCode() + " ::: " + lazy2.hashCode());
    }
/** output
366712642 ::: 1829164700
*/

所以反射本就是一個bug,需要見招拆招,而不是一味的墨守成規。

枚舉

​ JDK1.5開始引入了枚舉類型,它可以防止反射來破壞單例。

​ 這種實現方式還沒有被廣泛採用,但這是實現單例模式的最佳方法。它更簡潔,自動支持序列化機制,絕對防止多次實例化。
​ 這種方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不僅能避免多線程同步問題,而且還自動支持序列化機制,防止反序列化重新創建新的對象,絕對防止多次實例化。不過,由於 JDK1.5 之後才加入 enum 特性,用這種方式寫不免讓人感覺生疏,在實際工作中,也很少用。
​ 不能通過 reflection attack 來調用私有構造方法。

import java.lang.reflect.Constructor;

public enum EnumSingle {
    INSTANCE;

    public EnumSingle getInstance() {
        return INSTANCE;
    }
}

class Test {
    public static void main(String[] args) throws Exception {
        EnumSingle instance1 = EnumSingle.INSTANCE;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
        declaredConstructor.setAccessible(true);
        EnumSingle instance2 = declaredConstructor.newInstance();
        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }
}
/** output
Exception in thread "main" java.lang.NoSuchMethodException: EnumSingle.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at Test.main(EnumSingle.java:14)
	*/

拋出的異常說明enum中根本沒有一個空參的構造方法,通過將class反編譯為java文件,發現我們的類繼承了枚舉類,而構造器並非空參構造器,而是有參構造器,一個String和一個int

//更改一下
class Test {
    public static void main(String[] args) throws Exception {
        EnumSingle instance1 = EnumSingle.INSTANCE;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);
        declaredConstructor.setAccessible(true);
        EnumSingle instance2 = declaredConstructor.newInstance();
        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }
}
/** output
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at Test.main(EnumSingle.java:16)
*/

這樣說明反射確實無法破解枚舉的單例

總結

實現單例模式有四種方式,餓漢式、懶漢式、靜態內部類、枚舉。

餓漢式:

  1. 線程安全
  2. 由於在類加載的時候初始化,浪費內存

懶漢式:

  1. 要想線程安全得加鎖,但加鎖就會影響效率,但getInstance方法由於調用機會不多,所以影響不是很大
  2. 第一次調用才初始化,避免內存的浪費。

靜態內部類:

  1. 線程安全

枚舉:

  1. 線程安全
  2. 絕對防止多次實例化
  3. 自動支持序列化