【本人禿頂程式設計師】最簡單的設計模式——單例模式的演進和推薦寫法(Java 版)

本人禿頂程式設計師發表於2019-02-04

←←←←←←←←←←←← 快!點關注

前言

如下是之前總結的 C++ 版的;軟體開發常用設計模式—單例模式總結(c++版),對比發現 Java 實現的單例模式和 C++ 的線上程安全上還是有些區別的。

概念不多說,沒意思,我自己總結就是:

有這樣一個類,該類在生命週期內有且只能有一個例項,該類必須自己建立自己的這個唯一例項,該類必須給所有其他物件提供這一例項(提供全域性訪問點),這樣的類就叫單例類。

簡單的說就是滿足三個條件:

1、生命週期內有且只能有一個例項

2、自己提供這個獨一無二的例項

3、該例項必須是能全域性訪問的

需要的考慮的細節

進一步,單例類,最好能實現懶載入,隨用隨生成,而不是初始化的時候就生成,提高啟動速度和優化記憶體。

還有應該考慮併發環境下的場景,多執行緒的單例模式實現有什麼難點,回答這個問題,必須先知道Java的記憶體模型

考慮黑客會做反序列化的攻擊

考慮黑客會做反射的攻擊,因為反射可以訪問私有方法

單執行緒環境下懶載入的單例

如果程式確認沒有多執行緒的使用場景,完全可以簡單一些寫。

public class NoThreadSafeLazySingleton {
    private static NoThreadSafeLazySingleton lazySingleton = null;

    private NoThreadSafeLazySingleton() {
    }

    public static NoThreadSafeLazySingleton getLazySingleton() {
        if (lazySingleton == null) {
            lazySingleton = new NoThreadSafeLazySingleton();
        }

        return lazySingleton;
    }
}
複製程式碼

很簡單,但是隻適用於單執行緒環境

執行緒安全的懶載入單例

原理也很簡單,沒什麼可說的,如下示例程式碼:

public class ThreadSafeLazySingleton {
    private static volatile ThreadSafeLazySingleton lazySingleton = null;

    private ThreadSafeLazySingleton() {
    }

    public static ThreadSafeLazySingleton getLazySingleton() {
        if (lazySingleton == null) {
            synchronized (ThreadSafeLazySingleton.class) {
                if (lazySingleton == null) {
                    lazySingleton = new ThreadSafeLazySingleton();
                }
            }
        }

        return lazySingleton;
    }
}
複製程式碼

主要是注意 volatile 關鍵字的使用,否則這種所謂雙重檢查的執行緒安全的單例是有 bug 的。

靜態內部類方案

在某些情況中,JVM 隱含了同步操作,這些情況下就不用自己再來進行同步控制了。這些情況包括:

  • 由靜態初始化器(在靜態欄位上或static{}塊中的初始化器)初始化資料時
  • 訪問final欄位時
  • 在建立執行緒之前建立物件時
  • 執行緒可以看見它將要處理的物件時

在靜態內部類裡去建立本類(外部類)的物件,這樣只要不使用這個靜態內部類,那就不建立物件例項,從而同時實現延遲載入和執行緒安全。

public class Person {
    private String name;
    private Integer age;

    private Person() {
    }

    private Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    // 在靜態內部類裡去建立本類(外部類)的物件
    public static Person getInstance() {
        return Holder.instatnce;
    }

    // 靜態內部類相當於外部類 Person 的 static 域,它的物件與外部類物件間不存在依賴關係,因此可直接建立。
    // 因為靜態內部類相當於其外部類 Person 的靜態成員,所以在第一次被使用的時候才被會裝載,且只裝載一次。
    private static class Holder {
        // 內部類的物件例項 instatnce ,是繫結在外部 Person 物件例項中的
        // 靜態內部類中可以定義靜態方法,在靜態方法中只能夠引用外部類中的靜態成員方法或者成員變數,比如 new Person
        // 使用靜態初始化器來實現執行緒安全的單例類,它由 JVM 來保證執行緒安全性。
        private static final Person instatnce = new Person("John", 31);
    }
}
複製程式碼

靜態內部類相當於外部類 Person 的 static 域(靜態成員),它的物件與外部類物件間不存在依賴關係,因此可直接建立。

既然,靜態內部類相當於其外部類 Person 的靜態成員,所以在第一次被使用的時候才被會裝載,且只裝載一次,實現了懶載入和單例。

而且,使用靜態初始化器來實現單例類,是執行緒安全的,因為由 JVM 來保證執行緒安全性

客戶端呼叫

    Person person = Person.getInstance();
複製程式碼

該方案實現了,執行緒安全的單例 + 懶載入的單例,但是並不能防反序列化攻擊,需要額外的加以約束。

反序列化攻擊單例類

其實這個 case 沒必要說太多,知道就行,因為哪裡就這麼巧,一個能序列化的類(實現了Serializable/Externalizable介面的類),就恰恰是單例的呢?

看下面例子,把 Person 類改造為能序列化的類,然後用反序列攻擊單例

public class SerializationTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Person person = Person.getInstance();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("person"));
        objectOutputStream.writeObject(person);

        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("person"));
        Person person1 = (Person) objectInputStream.readObject();

        System.out.println(person == person1); // false
    }
}
複製程式碼

比較兩個 person 例項地址,是 false,說明生成了兩個物件,違背了單例類的初衷,那麼為了能在序列化過程仍能保持單例的特性,可以在Person類中新增一個readResolve()方法,在該方法中直接返回Person的單例物件

    public Object readResolve() {
        return Holder.instatnce;
    }
複製程式碼

原理是當從 I/O 流中讀取物件時,ObjectInputStream 類裡有 readResolve() 方法,該方法會被自動呼叫,期間經過種種邏輯,最後會呼叫到可序列化類裡的 readResolve()方法,這樣可以用 readResolve() 中返回的單例物件直接替換在反序列化過程中建立的物件,實現單例特性。

也就是說,無論如何,反序列化都會額外建立物件,只不過使用 readResolve() 方法可以替換之。

反射攻擊單例類

直接看例子,做法很簡單,通過 Java 的反射機制,看看能不能拿到單例類的私有構造器,並且改變構造器的訪問屬性

public class ReflectTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, ClassNotFoundException {
        Person person = Person.getInstance();

        Class clazz = Class.forName("com.dashuai.D13Singleton.Person");
        Constructor constructor = clazz.getDeclaredConstructor();
//        constructor.setAccessible(true);
        Person person1 = (Person) constructor.newInstance();

        System.out.println(person == person1); // false
    }
}
複製程式碼

執行丟擲了異常:

【本人禿頂程式設計師】最簡單的設計模式——單例模式的演進和推薦寫法(Java 版)

但是,如果把註釋的行開啟,就不會出錯,且列印 false。

網上有一些解決方案,比如在構造器里加判斷,如果二次呼叫就丟擲異常,其實也沒從根本上解決問題。

解決所有問題的方案——列舉實現單例類

目前公認的最佳方案,程式碼極少,執行緒安全,防止反射和序列化攻擊

public enum EnumSingleton {
    ENUM_SINGLETON;

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
////////////////////////呼叫
EnumSingleton.ENUM_SINGLETON.setName("dashuai");
System.out.println(EnumSingleton.ENUM_SINGLETON.getName());
複製程式碼

所有的變數都是單例的。至於為什麼,可以通過反編譯工具檢視列舉的原始碼。可以安裝 idea 的 jad 外掛,會發現就是按照單例模式設計的。

享元模式和單例模式的異同

享元模式是物件級別的, 也就是說在多個使用到這個物件的地方都只需要使用這一個物件即可滿足要求。

單例模式是類級別的, 就是說這個類必須只能例項化出來一個物件。

可以這麼說, 單例是享元的一種特例, 設計模式不用拘泥於具體程式碼, 程式碼實現可能有n多種方式, 而單例可以看做是享元的實現方式中的一種, 他比享元模式更加嚴格的控制了物件的唯一性

使用單例的場景和條件是什麼?

1、單例類只能有一個例項。

2、單例類必須自己建立自己的唯一例項。

3、單例類必須給所有其他物件提供這一例項。

歡迎大家加入粉絲群:963944895,群內免費分享Spring框架、Mybatis框架SpringBoot框架、SpringMVC框架、SpringCloud微服務、Dubbo框架、Redis快取、RabbitMq訊息、JVM調優、Tomcat容器、MySQL資料庫教學視訊及架構學習思維導圖

相關文章