1. 程式人生 > >聊一聊最難的設計模式

聊一聊最難的設計模式

很多人上來肯定一臉懵逼,因為在你的印象中,單例模式實現起來還是很簡單的。不要著急,慢慢往下看,你就知道為什麼我說它最難了。

1. 基本概念

  • 單例模式是一種常用的建立型設計模式。單例模式保證類僅有一個例項,並提供一個全域性訪問點。

2. 適用場景

  • 想確保任何情況下都絕對只有一個例項。

  • 典型的場景有:windows 的工作管理員、windows 的回收站、執行緒池的設計等。

3. 單例模式的優缺點

優點

  • 記憶體中只有一個例項,減少了記憶體開銷。
  • 可以避免對資源的多重佔用。
  • 設定全域性訪問點,嚴格控制訪問。

缺點

  • 沒有介面,擴充套件困難。

4. 常見的實現模式

  • 懶漢式
  • 餓漢式

5. 先搞一個懶漢式的玩一玩

public class LazySingleton {
    // 1. 私有物件
    private static LazySingleton lazySingleton = null;

    // 2. 構造方法私有化
    private LazySingleton() {}

    // 3. 設定全域性訪問點
    public static LazySingleton getInstance() {
        if (lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

接下來,我們單執行緒測試

public class MainTest {
    public static void main(String[] args) {
        LazySingleton instance = LazySingleton.getInstance();
        LazySingleton instance2 = LazySingleton.getInstance();
        System.out.println(instance == instance2);
    }
}

img

  • 測試程式碼及結果如上,一切看著毫無違和感。

那自然而然,我們考慮一下多執行緒如何呢。

  • 我們來建立一個執行緒類
public class MyThread implements Runnable {
    @Override
    public void run() {
        LazySingleton instance = LazySingleton.getInstance();
        System.out.println(Thread.currentThread().getName() + " " + instance);
    }
}
  • 然後修改我們的測試程式碼
public class MainTest {
    public static void main(String[] args) {
        Thread t1 = new Thread(new MyThread());
        Thread t2 = new Thread(new MyThread());
        t1.start();
        t2.start();
        System.out.println("program end.");
    }
}
  • 我們通過 IDEA 自帶的斷點測試來測試多執行緒下的問題,我們在 LazySingleton 如下位置打上斷點。(設定斷點的 Suspend 為 Thread)

    img

  • 我們通過 debug 方式啟動測試程式碼,然後通過 IDEA 的工具視窗切換執行緒進行檢視。(具體的 IDEA 除錯多執行緒程式碼的方法可以通過各種途徑學習,當然,也可以找我,我教你。雖然我也是略知皮毛。)

  • 此時會看到有 Thread-0 和 Thread-1 兩個執行緒,此時兩個執行緒都判斷了 lazySingleton 為空,此時兩個執行緒都會建立物件。

  • 將程式碼執行完,此時可以看到控制檯列印的訊息。

img

  • 很明顯地,兩個執行緒拿到的是不同的物件。也就說明了,我們如上的懶漢式程式碼不是執行緒安全的,在多執行緒下可能會創建出多個物件。

那接下來,我們就應該想辦法處理這種情況了。

  • 通過在全域性訪問點新增 synchronized 關鍵字處理
// 3. 設定全域性訪問點
public synchronized static LazySingleton getInstance() {
    if (lazySingleton == null) {
        lazySingleton = new LazySingleton();
    }
    return lazySingleton;
}

如上問題是處理了,但是出現了新的問題,該方法訪問時會加鎖,導致訪問效率降低,但是隻要是判斷和建立物件的時候加鎖即可,大概率情況下,該物件已經創建出來,併發訪問也是沒有什麼問題的。為了實現這個目的,我們又提出了“Double Check 雙重檢查方案”

  • 廢話不多說,上程式碼。
public class LazyDoubleCheckSingleton {
    private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;

    private LazyDoubleCheckSingleton() {}

    public static LazyDoubleCheckSingleton getInstance() {
        if (lazyDoubleCheckSingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class) {
                if (lazyDoubleCheckSingleton == null) {
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}
  • 此程式碼便實現了在大概率情況下,lazyDoubleCheckSingleton 已經不為空,也就不需要獲取到鎖,可以實現多執行緒併發訪問。

但是如上程式碼還是有一些問題的,因為問題很難復現,也就不做演示。問題是由大名鼎鼎的“指令重排序”引起的。

  • 來大概說明一下原理,可能不是很準確,但是主要以理解這個問題為目的。

  • 其實建立物件(new LazyDoubleCheckSingleton())這個操作在底層我們可以看作三個步驟:

    • memory = allocate(); // 1:分配物件的記憶體空間
    • ctorInstance(memory); // 2:初始化物件
    • lazyDoubleCheckSingleton = memory; // 3:設定 lazyDoubleCheckSingleton 指向剛分配的記憶體地址
  • 針對這個問題,Java 語言規範中是有要求的,就是必須遵守 intra-thread semantics (執行緒內語義),保證重排序不會改變單執行緒內的程式執行結果。

  • 但是在上述例子中,2、3步驟可能會出現重排序,也就是可能出現,先指向記憶體地址,再初始化物件,此時,lazyDoubleCheckSingleton 不為空,但是物件還未初始化完成。問題也就出現了。並且此時重排序操作並不會違反 intra-thread semantics,因為在單執行緒的執行下,此類重排序是不會影響最終結果的。

上一個圖來說明一下指令重排序引起的問題吧

img

  • 此時便會發生:執行緒0中物件未初始化完成,執行緒1就訪問了物件。

那問題來了,也就該處理了。

  • 針對以上問題,我們處理思路其實有兩種:
    • 不允許步驟 2、3 進行重排序。
    • 允許步驟 2、3 進行重排序,但是這個重排序過程不能讓其他執行緒看到。

不允許步驟 2、3 進行重排序

  • 只需要物件新增 volatile 關鍵字即可。
private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
  • 具體其中的原理,會在其他內容中進行分析,不是此次的重點。

允許步驟 2、3 進行重排序,但是這個重排序過程不能讓其他執行緒看到。

基於靜態內部類的解決方案

img

public class StaticInnerClassSingleton {
    // InnerClass 物件鎖
    private static class InnerClass {
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }

    private StaticInnerClassSingleton() {}

    public static StaticInnerClassSingleton getInstance() {
        return InnerClass.staticInnerClassSingleton;
    }
}

到此為止,咱們的懶漢式先告一段落啊。。。喪心病狂呀,有木有。。。

6. 那咱們就再來玩玩餓漢式

public class HungrySingleton {
    private final static HungrySingleton hungrySingleton;

    static {
        hungrySingleton = new HungrySingleton();
    }

    private HungrySingleton() {}

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }
}
  • 這個東西在多執行緒下就好點了,因為餓漢式是在類初始化的時候便把物件建立好了,所以也不需要判斷物件是不是空,當然,在多執行緒下也就沒那麼多需要我們考慮的了。

7. 然後,然後,咱們再來看看序列化和反序列化的情況下,單例模式有沒有什麼問題呢。

  • 因序列化問題與懶漢式還是餓漢式實現無關,以下便以餓漢式程式碼為例展示。

餓漢式

  • 首先,我們的單例類實現序列化
public class HungrySingleton implements Serializable {
    // ...
}
  • 然後我們來寫一個測試程式碼
public class MainTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        HungrySingleton instance = HungrySingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.txt"));
        oos.writeObject(instance);

        File file = new File("test.txt");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));

        HungrySingleton newInstance = (HungrySingleton) ois.readObject();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}
  • 我們來看一下執行結果

img

  • 哈哈哈哈,瞬間窒息了,有木有。。。

針對上面問題,咱們來看一看原始碼,找一找原因啊。

  • 如下是跟蹤原始碼的過程,我只做簡單截圖,有興趣可自行研究(哈哈哈,或者你可以找我呀,我們一起研究)。

img

img

img

img

看到這兒,我感覺你應該也就知道了,desc.isInstantiable()方法返回了true,所以通過反射new了一個新的物件,導致讀出的物件與寫入的物件不是同一個物件。

那你一定想問我,那怎麼處理呢,彆著急啊,接著往下看。

img

img

  • 這個變數的初始化,可以直接通過查詢看到。

img

這不就清楚了嘛,有readResolve()方法的時候,直接通過呼叫該方法返回了單例物件,那我們處理起來也就簡單了,為我們的單例類新增一個方法即可。

private Object readResolve() {
    return hungrySingleton;
}
  • 然後重新直接測試程式碼,會出現如下結果。

img

8. 序列化和序列化的問題說完了,咱們再來看看反射的問題吧,畢竟反射我們用的還是很多的,通過反射去建立一個物件也是常用的操作。

該問題針對兩種方式是不一樣的,我們先來看看餓漢式的表現。

  • 我們來寫個測試程式碼
public class MainTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = HungrySingleton.class;
        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}
  • 看看執行結果

img

有一種五雷轟頂的感覺了沒,彆著急,彆著急,咱們慢慢搞啊,雖然花點時間,但是能搞到很多東西的。

既然問題出來了,那怎麼處理呢?其實處理也簡單,因為反射是講私有構造方法許可權進行了開放,那我們在私有構造中新增判斷即可。

private HungrySingleton() {
    if (hungrySingleton != null) {
        throw new RuntimeException("單例構造器禁止反射呼叫!");
    }
}
  • 再來執行我們的測試程式碼,可以看到會丟擲以下異常。

img

接下來我們分析分析懶漢式

  • 與餓漢式新增同樣的操作,也是避免不了反射的。
  • 假如先使用getInstance()方法獲取物件,然後使用反射建立物件,是可以丟擲異常的。
  • 但是當先使用反射建立物件,再通過getInstance()方法獲取物件時,便可以獲取到兩個不同的物件,還是避免不了對單例模式的破壞。

最終的結論,懶漢式是無法防止反射攻擊的。

9. 然後估計你就快暈了,你肯定想問,難道以後做一個單例都要考慮這麼多問題嘛,也太墨跡了點吧。那咱們接下來就看看用列舉來實現單例的方法吧。

  • 該方法為Effective Java書中推薦的用法。
  • 該方法完美解決了序列化及反射對單例模式的破壞。

上程式碼

public enum EnumInstance {
    INSTANCE;
    private Object data;

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }

    public static EnumInstance getInstance() {
        return INSTANCE;
    }
}

上面既然說了,完美解決了序列化及反射對單例模式的破壞,那咱們接下來就看看是如何解決的。

解決序列化對單例模式的破壞

  • 我們還是來看ObjectInputStream.readObject()方法

img

img

img

img

  • 可以看出是使用名稱通過反射去獲取到Enum,並沒有建立新的物件,所以獲取到的是同一個物件。

解決反射對單例模式的破壞

  • 來寫一個測試程式碼
public class MainTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = EnumInstance.class;
        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        EnumInstance instance = EnumInstance.getInstance();
        EnumInstance newInstance = (EnumInstance) constructor.newInstance();

        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}
  • 結果

img

  • 來看一下 java.lang.Enum 類,我們可以看到只有一個構造方法,且需要兩個引數。

img

  • 那我們就來傳入兩個引數試一下。

img

  • 最終的結果

img

  • 我們來看一下原因啊,請看 constructor.newInstance() 方法

img

  • 發現其對 Enum 型別進行了處理,不允許通過反射建立 Enum 物件。

  • 至此我們也就明白了,為什麼 Enum 單例可以完美防止序列化及反射對單例模式的破壞了。

OK 了,我們再來搞兩個相關的東西

10. 我們來聊聊容器單例

  • 為了方便,使用 HashMap 來實現一個容器單例

直接走程式碼

public class ContainerSingleton {
    private static Map<String, Object> singletonMap = new HashMap<>();

    private ContainerSingleton() {}

    public static void putInstance(String key, Object instance) {
        if (key != null && !"".equals(key) && instance != null) {
            if (!singletonMap.containsKey(key)) {
                singletonMap.put(key, instance);
            }
        }
    }

    public static Object getInstance(String key) {
        return singletonMap.get(key);
    }
}

針對上述程式碼的說明

  • 因其key 相同,所以最終獲取到的是同一個物件。

  • 但是上述程式碼是執行緒不安全的。在多執行緒情況下,如果兩個執行緒同時判斷 if 條件成立,此時 t1 執行緒 put,t1 執行緒 get;然後 t2 執行緒 put ,t2 執行緒 get 時,t1 執行緒與 t2 執行緒獲取到的物件是不同的。

  • 如果此時容器單例不使用 HashMap,而使用 HashTable 是可以實現執行緒安全的,但是從效能考慮,假如 get 請求多的情況下,HashTable 效率會非常低下。

11. 最後一個,我們來看看 ThreadLocal 執行緒單例怎麼實現

定義一個執行緒單例類

public class ThreadLocalInstance {
    private static final ThreadLocal<ThreadLocalInstance> threadLocalInstanceThreadLocal =
            new ThreadLocal<ThreadLocalInstance>() {
                @Override
                protected ThreadLocalInstance initialValue() {
                    return new ThreadLocalInstance();
                }
            };

    private ThreadLocalInstance() {}

    public static ThreadLocalInstance getInstance() {
        return threadLocalInstanceThreadLocal.get();
    }
}

實現一個執行緒類做測試

public class MyThread implements Runnable {
    @Override
    public void run() {
        ThreadLocalInstance instance = ThreadLocalInstance.getInstance();
        System.out.println(Thread.currentThread().getName() + " " + instance);
    }
}

寫一個測試程式碼來測試一下

public class MainTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
        System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
        System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
        Thread t1 = new Thread(new MyThread());
        Thread t2 = new Thread(new MyThread());
        t1.start();
        t2.start();
        System.out.println("program end.");
    }
}

結果

img

我們今天的討論到現在就結束了。今天主要討論了入如下內容。

  • 基本的單例模式的實現:懶漢式和餓漢式。
  • 針對多執行緒下的單例模式執行緒安全的討論。
  • 序列化和反序列化對單例模式的破壞。
  • 反射對單例模式的破壞。
  • Enum 列舉單例。
  • 單例容器。
  • ThreadLocal 執行緒單例。

朋友們,一