單例模式的幾種實現And反射對其的破壞


一 單例模式概述

(一) 什么是單例模式

單例模式屬於創建型模式之一,它提供了一種創建對象的最佳方式

在軟件工程中,創建型模式是處理對象創建的設計模式,試圖根據實際情況使用合適的方式創建對象。基本的對象創建方式可能會導致設計上的問題,或增加設計的復雜度。創建型模式通過以某種方式控制對象的創建來解決問題。

因為我們平時雖然可以定義一個全局變量使一個對象被訪問,但是它並不能保證你多次實例化對象,最直觀的,多次創建對象的代價就是消耗性能,導致效率會低一些。單例模式就是用來解決這些問題

順便提一個很常見的例子:例如在 Win 系的電腦下我們永遠只能打開一個任務管理器,這樣可以避免出現一些資源浪費,以及多窗口顯示數據不一致的問題

定義:單例模式,保證一個類僅有一個實例,並且提供一個訪問它的全局訪問點

(二) 特點

  • ① 單例類只能有一個實例對象

  • ② 單例類必須自己創建自己的唯一實例

  • ③ 單例類必須對外提供一個訪問該實例的方法

(三) 優缺點以及使用場景

(1) 優點

  • 提供了對唯一實例的受控訪問

  • 保證了內存中只有唯一實例,減少了內存的開銷

    • 尤其表現在一些需要多次創建銷毀實例的情況下
  • 避免對資源的多重占用

    • 比如對文件的寫操作

(2) 缺點

  • 單例模式中沒有抽象層,沒有接口,不能繼承,擴展困難,擴展需要修改原來的代碼,違背了 “開閉原則”
  • 單例類的代碼一般寫在同一個類中,一定程度上職責過重,違背了 “單一職責原則”

(3) 應用場景

先說幾個大家常見單例的例子:

  • Windows 下的任務管理器和回收站,都是典型的單例模式,你可以試一下,沒法同時打開兩個的哈

  • 數據庫連接池的設計一般也是單例模式,因為頻繁的打開關閉與數據庫的連接,會有不小的效率損耗

    • 但是濫用單例也可能帶來一些問題,例如導致共享連接池對象的程序過多而出現連接池溢出
  • 網站計數器,通過單例解決同步問題

  • 操作系統的文件系統

  • Web 應用的配置對象讀取,因為配置文件屬於共享的資源

  • 程序的日志應用,一般也是單例,否則追加內容時,容易出問題

所以,根據一些常見的例子,簡單總結一下,什么時候用單例模式呢?

  • ① 需要頻繁創建銷毀實例的
  • ② 實例創建時,消耗資源過多,或者耗時較多的,例如數據連接或者IO
  • ③ 某個類只要求生成一個類的情況,例如生成唯一序列號,或者人的身份證
  • ④ 對象需要共享的情況,如 Web 中配置對象

二 實現單例模式

根據單例模式的定義和特點,我們可以分為三步來實現最基本的單例模式

  • ① 構造函數私有化
  • ② 在類的內部創建實例
  • ③ 提供本類實例的唯一全局訪問點,即提供獲取唯一實例的方法

(一) 餓漢式

我們就按照最基本的這三點來寫

public class Hungry {
    // 構造器私有,靜止外部new
    private Hungry(){}

    // 在類的內部創建自己的實例
    private static Hungry hungry = new Hungry();

    // 獲取本類實例的唯一全局訪問點
    public static Hungry getHungry(){
        return hungry;
    }
}

這種做法一開始就直接創建這個實例,我們也稱為餓漢式單例,但是如果這個實例一直沒有被調用,會造成內存的浪費,顯然這樣做是不合適的

(二) 懶漢式

餓漢式的主要問題在於,一開始就創建實例導致的內存浪費問題,那么我們將創建對象的步驟,挪到具體使用的時候

public class Lazy1 {
    // 構造器私有,靜止外部new
    private Lazy1(){
        System.out.println(Thread.currentThread().getName() + " 訪問到了");
    }
    
    // 定義即可,不真正創建
    private static Lazy1 lazy1 = null;

    // 獲取本類實例的唯一全局訪問點
    public static Lazy1 getLazy1(){
        // 如果實例不存在則new一個新的實例,否則返回現有的實例
        if (lazy1 == null) {
            lazy1 = new Lazy1();
        }
        return lazy1;
    }

    public static void main(String[] args) {
        // 多線程訪問,看看會有什么問題
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                Lazy1.getLazy1();
            }).start();
        }
    }
}

例如上述代碼,我們只在剛開始做了一個定義,真正的實例化是在調用 getLazy1() 時被執行

單線程環境下是沒有問題的,但是多線程的情況下就會出現問題,例如下面是我運行結果中的一次:

Thread-0 訪問到了
Thread-4 訪問到了
Thread-1 訪問到了
Thread-3 訪問到了
Thread-2 訪問到了

(三) DCL 懶漢式

(1) 方法上直接加鎖

很顯然,多線程下的普通懶漢式出現了問題,這個時候,我們只需要加一層鎖就可以解決

簡單的做法就是在方法前加上 synchronized 關鍵字

public static synchronized Lazy1 getLazy1(){
    if (lazy1 == null) {
        lazy1 = new Lazy1();
    }
    return lazy1;
}

(2) 縮小鎖的范圍

但是我們又想縮小鎖的范圍,畢竟方法上加鎖,多線程中效率會低一些,所以只把鎖加到需要的代碼上

我們直觀的可能會這樣寫

public static Lazy1 getLazy1(){
    if (lazy1 == null) {
    	synchronized(Lazy1.class){
    		lazy1 = new Lazy1();
    	}
   	}
    return lazy1;
}

但是這樣還是有問題的

(3) 雙重鎖定

當線程 A 和 B 同時訪問getLazy1(),執行到到 if (lazy1 == null) 這句的時候,同時判斷出 lazy1 == null,也就同時進入了 if 代碼塊中,后面因為加了鎖,只有一個能先執行實例化的操作,例如 A 先進入,但是 后面的 B 進入后同樣也可以創建新的實例,就達不到單例的目的了,不信可以自己試一下

解決的方式就是再進行第二次的判斷

// 獲取本類實例的唯一全局訪問點
public static Lazy1 getLazy1(){
    // 如果實例不存在則new一個新的實例,否則返回現有的實例
    if (lazy1 == null) {
        // 加鎖
        synchronized(Lazy1.class){
            // 第二次判斷是否為null
            if (lazy1 == null){
                lazy1 = new Lazy1();
            }
        }
    }
    return lazy1;
}

(4) 指令重排問題

這種在適當位置加鎖的方式,盡可能的降低了加鎖對於性能的影響,也能達到預期效果

但是這段代碼,在一定條件下還是會有問題,那就是指令重排問題

指令重排序是JVM為了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,盡可能地提高並行度。

什么意思呢?

首先要知道 lazy1 = new Lazy1(); 這一步並不是一個原子性操作,也就是說這個操作會分成很多步

  • ① 分配對象的內存空間
  • ② 執行構造函數,初始化對象
  • ③ 指向對象到剛分配的內存空間

但是 JVM 為了效率對這個步驟進行了重排序,例如這樣:

  • ① 分配對象的內存空間
  • ③ 指向對象到剛分配的內存空間,對象還沒被初始化
  • ② 執行構造函數,初始化對象

按照 ① ③ ② 的順序,當 A 線程執行到 ② 后,B線程判斷 lazy1 != null ,但是此時的 lazy1 還沒有被初始化,所以會出問題,並且這個過程中 B 根本執行到鎖那里,配個表格說明一下:

Time ThreadA ThreadB
t1 A:① 分配對象的內存空間
t2 A:③ 指向對象到剛分配的內存空間,對象還沒被初始化
t3 B:判斷 lazy1 是否為 null
t4 B:判斷到 lazy1 != null,返回了一個沒被初始化的對象
t5 A:② 初始化對象

解決的方法很簡單——在定義時增加 volatile 關鍵字,避免指令重排

(5) 最終代碼

最終代碼如下:

public class Lazy1 {
    // 構造器私有,靜止外部new
    private Lazy1(){
        System.out.println(Thread.currentThread().getName() + " 訪問到了");
    }

    // 定義即可,不真正創建
    private static volatile Lazy1 lazy1 = null;

    // 獲取本類實例的唯一全局訪問點
    public static Lazy1 getLazy1(){
        // 如果實例不存在則new一個新的實例,否則返回現有的實例
        if (lazy1 == null) {
            // 加鎖
            synchronized(Lazy1.class){
                // 第二次判斷是否為null
                if (lazy1 == null){
                    lazy1 = new Lazy1();
                }
            }
        }
        return lazy1;
    }

    public static void main(String[] args) {
        // 多線程訪問,看看會有什么問題
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                Lazy1.getLazy1();
            }).start();
        }
    }
}

(四) 靜態內部類懶漢式

雙重鎖定算是一種可行不錯的方式,而靜態內部類就是一種更加好的方法,不僅速度較快,還保證了線程安全,先看代碼

public class Lazy2 {
    // 構造器私有,靜止外部new
    private Lazy2(){
        System.out.println(Thread.currentThread().getName() + " 訪問到了");
    }

    // 用來獲取對象
    public static Lazy2 getLazy2(){
        return InnerClass.lazy2;
    }

    // 創建內部類
    public static class InnerClass {
        // 創建單例對象
        private static Lazy2 lazy2 = new Lazy2();
    }

    public static void main(String[] args) {
        // 多線程訪問,看看會有什么問題
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                Lazy2.getLazy2();
            }).start();
        }
    }
}

上面的代碼,首先 InnerClass 是一個內部類,其在初始化時是不會被加載的,當用戶執行了 getLazy2() 方法才會加載,同時創建單例對象,所以他也是懶漢式的方法,因為 InnerClass 是一個靜態內部類,所以只會被實例化一次,從而達到線程安全,因為並沒有加鎖,所以性能上也會很快,所以一般是推薦的

(五) 枚舉方式

最后推薦一個非常好的方式,那就是枚舉單例方式,其不僅簡單,且保證了安全,先看一下 《Effective Java》中作者的說明:

這種方法在功能上與公有域方法相似,但更加簡潔無償地提供了序列化機制,絕對防止多次實例化。即使是在面對復雜的序列化或者反射攻擊的時候。雖然這種方法還沒有廣泛采用,但是單元素的枚舉類型經常成為實現Singleton 的最佳方法,注意,如果 Singleton 必須擴展一個超類,而不是擴展 enum 時則不宜使用這個方法,(雖然可以聲明枚舉去實現接口)。

節選自 《Effective Java》第3條:用私有構造器或者枚舉類型強化 Singleton 屬性

原著:Item3: Enforce the singleton property with a private constructor or an enum

代碼就這樣,簡直不要太簡單,訪問通過 EnumSingle.IDEAL 就可以訪問了

public enum EnumSingle {
    IDEAL;
}

我們接下來就要給大家演示為什么枚舉是一種比較安全的方式

三 反射破壞單例模式

(一) 單例是如何被破壞的

下面用雙重鎖定的懶漢式單例演示一下,這是我們原來的寫法,new 兩個實例出來,輸出一下

public class Lazy1 {
    // 構造器私有,靜止外部new
    private Lazy1(){
        System.out.println(Thread.currentThread().getName() + " 訪問到了");
    }

    // 定義即可,不真正創建
    private static volatile Lazy1 lazy1 = null;

    // 獲取本類實例的唯一全局訪問點
    public static Lazy1 getLazy1(){
        // 如果實例不存在則new一個新的實例,否則返回現有的實例
        if (lazy1 == null) {
            // 加鎖
            synchronized(Lazy1.class){
                // 第二次判斷是否為null
                if (lazy1 == null){
                    lazy1 = new Lazy1();
                }
            }
        }
        return lazy1;
    }

    public static void main(String[] args) {

        Lazy1 lazy1 = getLazy1();
        Lazy1 lazy2 = getLazy1();
        System.out.println(lazy1);
        System.out.println(lazy2);

    }
}

運行結果:
main 訪問到了
cn.ideal.single.Lazy1@1b6d3586
cn.ideal.single.Lazy1@1b6d3586

可以看到,結果是單例沒有問題

(1) 一個普通實例化,一個反射實例化

但是我們如果通過反射的方式進行實例化類,會有什么問題呢?

public static void main(String[] args) throws Exception {
    Lazy1 lazy1 = getLazy1();
    // 獲得其空參構造器
    Constructor<Lazy1>  declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
    // 使得可操作性該 declaredConstructor 對象
    declaredConstructor.setAccessible(true);
    // 反射實例化
    Lazy1 lazy2 = declaredConstructor.newInstance();
    System.out.println(lazy1);
    System.out.println(lazy2);
}

getDeclaredConstructor() 方法說明

方法返回一個Constructor對象,它反映此Class對象所表示的類或接口指定的構造函數。parameterTypesparameter是確定構造函數的形參類型,在Class對象聲明順序的數組。

public Constructor getDeclaredConstructor(Class<?>... parameterTypes) throws NoSuchMethodException, SecurityException

運行結果:

main 訪問到了
main 訪問到了
cn.ideal.single.Lazy1@1b6d3586
cn.ideal.single.Lazy1@4554617c

可以看到,單例被破壞了

解決辦法:因為我們反射走的其無參構造,所以在無參構造中再次進行非null判斷,加上原來的雙重鎖定,現在也就有三次判斷了

// 構造器私有,靜止外部new
private Lazy1(){
    synchronized (Lazy1.class){
        if(lazy1 != null) {
            throw new RuntimeException("反射破壞單例異常");
        } 
    }
}

不過結果也沒讓人失望,這種測試下,第二次實例化會直接報異常

(2) 兩個都是反射實例化

如果兩個都是反射實例化出來的,也就是說,根本就不去調用 getLazy1() 方法,那可怎么辦?

如下:

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

    // 獲得其空參構造器
    Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
    // 使得可操作性該 declaredConstructor 對象
    declaredConstructor.setAccessible(true);
     // 反射實例化
    Lazy1 lazy1 = declaredConstructor.newInstance();
    Lazy1 lazy2 = declaredConstructor.newInstance();

    System.out.println(lazy1);
    System.out.println(lazy2);
}

運行結果:

main 訪問到了
main 訪問到了
cn.ideal.single.Lazy1@1b6d3586
cn.ideal.single.Lazy1@4554617c

單例又被破壞了

解決方案:增加一個標識位,例如下文通過增加一個布爾類型的 ideal 標識,保證只會執行一次,更安全的做法,可以進行加密處理,保證其安全性

// 構造器私有,靜止外部new
private Lazy1(){
    synchronized (Lazy1.class){
        if (ideal == false){
            ideal = true;
        } else {
            throw new RuntimeException("反射破壞單例異常");
        }
    }
	System.out.println(Thread.currentThread().getName() + " 訪問到了");
}

這樣就沒問題了嗎,並不是,一旦別人通過一些手段得到了這個標識內容,那么他就可以通過修改這個標識繼續破壞單例,代碼如下(這個把代碼貼全一點,前面都是節選關鍵的,都可以參考這個)

public class Lazy1 {

    private static boolean ideal = false;

    // 構造器私有,靜止外部new
    private Lazy1(){
        synchronized (Lazy1.class){
            if (ideal == false){
                ideal = true;
            } else {
                throw new RuntimeException("反射破壞單例異常");
            }
        }
        System.out.println(Thread.currentThread().getName() + " 訪問到了");
    }

    // 定義即可,不真正創建
    private static volatile Lazy1 lazy1 = null;

    // 獲取本類實例的唯一全局訪問點
    public static Lazy1 getLazy1(){
        // 如果實例不存在則new一個新的實例,否則返回現有的實例
        if (lazy1 == null) {
            // 加鎖
            synchronized(Lazy1.class){
                // 第二次判斷是否為null
                if (lazy1 == null){
                    lazy1 = new Lazy1();
                }
            }
        }
        return lazy1;
    }

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

        Field ideal = Lazy1.class.getDeclaredField("ideal");
        ideal.setAccessible(true);

        // 獲得其空參構造器
        Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
        // 使得可操作性該 declaredConstructor 對象
        declaredConstructor.setAccessible(true);
        // 反射實例化
        Lazy1 lazy1 = declaredConstructor.newInstance();
        ideal.set(lazy1,false);
        Lazy1 lazy2 = declaredConstructor.newInstance();

        System.out.println(lazy1);
        System.out.println(lazy2);

    }
}

運行結果:

main 訪問到了
main 訪問到了
cn.ideal.single.Lazy1@4554617c
cn.ideal.single.Lazy1@74a14482

實例化 lazy1 后,其執行了修改 ideal 這個布爾值為 false,從而繞過了判斷,再次破壞了單例

所以,可以得出,這幾種方式都是不安全的,都有着被反射破壞的風險

(二) 枚舉類不會被破壞

上面在講解枚舉單例方式的時候就提過《Effective Java》中提到,即使是在面對復雜的序列化或者反射攻擊的時候,(枚舉單例方式)絕對防止多次實例化,下面來看一下是不是這樣:

首先說一個前提條件:這是 Constructor 下的 newInstance 方法節選,也就是說遇到枚舉時,會報異常,也就是不允許通過反射創建枚舉

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

看一下我們枚舉單例類 EnumSingle 生成的字節碼文件,可以看到其中有一個無參構造,也就是說,我們還是只需要拿到 getDeclaredConstructor(null) 就行了

代碼如下:

public enum EnumSingle {
    IDEAL;

    public static void main(String[] args) throws Exception {
        EnumSingle ideal1 = EnumSingle.IDEAL;
        // 獲得其空參構造器
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
        // 使得可操作性該 declaredConstructor 對象
        declaredConstructor.setAccessible(true);
        // 反射實例化
        EnumSingle ideal2 = declaredConstructor.newInstance();
        System.out.println(ideal1);
        System.out.println(ideal2);
    }
}

運行結果卻是出人意料:

提示竟然是找不到這個空參???字節碼中可是卻是存在的啊

Exception in thread "main" java.lang.NoSuchMethodException: cn.ideal.single.EnumSingle.<init>()

自己 javap 反編譯一下,可以看到還是有這個空參

換成 jad 再看看(將 jad.exe 放在字節碼文件同目錄下)

  • 執行:jad -sjava EnumSingle.class

提示已經反編譯結束:Parsing EnumSingle.class... Generating EnumSingle.java

打開生成的 java 文件,終於發現,原來它是一個帶參構造,同時有兩個參數,String 和 int

所以下面,我們只需要修改原來的無參為有參即可:

public enum EnumSingle {
    IDEAL;

    public static void main(String[] args) throws Exception {
        EnumSingle ideal1 = EnumSingle.IDEAL;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);
        // 使得可操作性該 declaredConstructor 對象
        declaredConstructor.setAccessible(true);
        // 反射實例化
        EnumSingle ideal2 = declaredConstructor.newInstance();
        System.out.println(ideal1);
        System.out.println(ideal2);
    }
}

這樣就沒問題了,提示了我們想要的錯誤:Cannot reflectively create enum objects

這也說明,枚舉類的單例模式寫法確實不會被反射破壞!

四 結尾

如果文章中有什么不足,歡迎大家留言交流,感謝朋友們的支持!

如果能幫到你的話,那就來關注我吧!如果您更喜歡微信文章的閱讀方式,可以關注我的公眾號

在這里的我們素不相識,卻都在為了自己的夢而努力 ❤

一個堅持推送原創開發技術文章的公眾號:理想二旬不止


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM