03.05 設計模式——單例模式

關於單例模式,這是面試時最容易遇到的問題。當時以為很簡單的內容,深挖一下,也可以關聯出類加載、序列化等知識。

餓漢式

我們先來看看基本的餓漢式寫法:

<code>public class Hungry {

private static final Hungry instance = new Hungry();

private Hungry() {}

public Hungry getInstance() {
return instance;
}
}
/<code>

優點:寫法簡答,不需要考慮多線程等問題。

缺點:如果該實例從未被用到的話,相當於資源浪費。

static 代碼塊

我們也可以用 static 代碼塊的方式,實現餓漢式:

<code>public class Hungry {

private static final Hungry instance;

static {
instance = new Hungry();
}

private Hungry() {}

public Hungry getInstance() {
return instance;
}
}
/<code>

這就是利用了 static 代碼塊的功能:它是隨著類的加載而執行,只執行一次,並優先於主函數。

懶漢式

我們先來看看基本的懶漢式寫法:

<code>public class Lazy {

private static volatile Lazy instance;

private Lazy(){}

public static Lazy getInstance() {
if (instance == null) {
synchronized (Lazy.class) {
if (instance == null) {
instance = new Lazy();
}
}
}
return instance;
}
}
/<code>

這裡就涉及到了很多知識點,讓我們一一講解。

volatile

<code>這裡使用 volatile,主要是為了禁止指令重排序。

主要就是針對 instance = new Lazy(); 這1行命令,在 JVM 中至少對應3條指令:
1. 給 instance 分配內存空間。
2. 調用 Lazy 的構造方法等來初始化 instance。
3. 將 instance 對象指向分配的內存空間(執行完這一步,instance 就不是 null 了)。

這裡需要注意,JVM 會對指令進行優化排序,就是第 2 步與第 3 步的順序是不一定的,可能是 1-2-3 ,也可能是 1-3-2 。


如果是後者,可能1個線程執行完 1-3 之後,另一個線程進入了
/<code>

以上這一段想必就是大家平常看到的解釋了,原本我對此也是深信不疑的,但是因為本地一直無法復現,因此讓我產生了懷疑。

查閱資料後,可能是和以下兩點有關。

Intel 64/IA-32架構下的內存訪問重排序

指令重排發生在處理器平臺,對於Java來說是看不到的,因為Jvm基於線程棧,所有的讀寫都對應了 store 操作,而Intel 64/IA-32架構下處理器不需要LoadLoad、LoadStore、StoreStore屏障,因此不會發生需要這三種屏障的重排序。所以,store 操作之間是不會重排序的。

JMM

JMM 抽象地將內存分為主內存和本地內存,各個線程有各自的本地內存。

如果2個線程在執行Lazy.getInstance()方法,instance作為 static 修改的變量,處於主內存中,兩個線程會各自複製instance到本地內存中,當線程1執行instance = new Lazy();方法,除非全部結束,否則不會將本地內存中的instance寫回主內存中。

以上也可能是我想錯了,但歡迎大家一起探討。

double-check

為什麼要有雙重檢查呢?

<code>第二個 if 判定:是為了保證當有兩個線程同時通過了第一個 if 判定,一個線程獲取到鎖,生成了 Lazy 的一個實例,然後第二個線程獲取到鎖,如果沒有第二個 if 判斷,那麼此時會再次生成生成 Lazy 的一個實例。
第一個 if 判定:是為了保證多線程同時執行,如果沒有第一個 if 判斷,所有線程都會串行執行,效率低下。
/<code>

靜態內部類

也可以利用靜態內部類來實現:

<code>public class Lazy {

private Lazy() {}

private static class InnerLazy {
private static final Lazy INSTANCE = new Lazy();
}

public static Lazy getInstance() {
return InnerLazy.INSTANCE;
}
}
/<code>

為什麼這樣能實現懶加載呢?

因為只有當調用InnerLazy.INSTANCE時,才會對 InnnerLazy 類進行初始化,然後才會調用 Lazy 的構造方法,這也是由類加載機制保證的:

<code>遇到 new 、getstatic、putstatic 或者 invokestatic 這 4 條字節碼指令時,如果沒有對類進行初始化,則需要先觸發其初始化。
這4個指令對應的 Java 場景是:使用 new 新建一個 Java 對象,訪問或者設置一個類的靜態字段,訪問一個類的靜態方法的時候。
/<code>

優缺點

以上方法的優缺點:

優點:使用的時候才會進行初始化,擁有更好的資源優化。

缺點:

  1. 除去最後一種靜態內部類之外,寫法都比較繁瑣。
  2. 如果使用反射或者反序列化,依舊可以強制生成新的實例。

針對第2點,我們可以舉例子來說明一下:

<code>public class Lazy implements Serializable {

public String name;

private Lazy() {
name = String.valueOf(System.currentTimeMillis());
}

private static class InnerLazy {
private static final Lazy INSTANCE = new Lazy();

}

public static Lazy getInstance() {
return InnerLazy.INSTANCE;
}

public void print() {
System.out.println("Lazy print : " + name);
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException, ClassNotFoundException {
Lazy instance1 = Lazy.getInstance();
instance1.print();

// 反射
Lazy instance3 = Lazy.class.newInstance();
instance3.print();
System.out.println(instance1 == instance3);

// 反序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("file"));
oos.writeObject(instance1);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("file"));
Lazy instance2 = (Lazy) ois.readObject();
instance2.print();
System.out.println(instance1 == instance2);
}
}
/<code>

輸出結果為:

<code>Lazy print : 1583410057762
Lazy print : 1583410057768
false
Lazy print : 1583410057762
false
/<code>

說明反射和反序列化,都會破壞以上寫法的單例特徵。那該如何解決呢?

  1. 針對反射,解決起來比較簡單,可以在構造方法中判斷一下 InnerLazy.INSTANCE ,如果不為 null ,則拋出異常。
  2. 針對反序列化,可以實現接口 Serializable ,重寫 readResolve 方法,返回單例對象 InnerLazy.INSTANCE。

看看修改後的代碼:

<code>package singleton;

import java.io.*;

public class Lazy implements Serializable {

public String name;

private Lazy() {
if (InnerLazy.INSTANCE != null) {
throw new RuntimeException("can not be invoked");
}
name = String.valueOf(System.currentTimeMillis());
}

private static class InnerLazy {
private static final Lazy INSTANCE = new Lazy();
}

public static Lazy getInstance() {
return InnerLazy.INSTANCE;
}

public void print() {
System.out.println("Lazy print : " + name);
}

private Object readResolve() {
return InnerLazy.INSTANCE;
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException, ClassNotFoundException {
Lazy instance1 = Lazy.getInstance();
instance1.print();

// 反序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("file"));
oos.writeObject(instance1);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("file"));
Lazy instance2 = (Lazy) ois.readObject();

instance2.print();
System.out.println(instance1 == instance2);

// 反射
Lazy instance3 = Lazy.class.newInstance();
instance3.print();
System.out.println(instance1 == instance3);
}
}
/<code>

運行結果為:

<code>Lazy print : 1583409803987
Lazy print : 1583409803987
true
Exception in thread "main" java.lang.RuntimeException: can not be invoked
at singleton.Lazy.<init>(Lazy.java:11)
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at java.lang.Class.newInstance(Class.java:442)
at singleton.Lazy.main(Lazy.java:46)
/<init>/<code>

枚舉類

針對上面的缺點,我們也可以用 enum 解決。來看看寫法:

<code>package singleton;

import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;

public enum Singleton {

INSTANCE;

private String name;

private Singleton() {
name = String.valueOf(System.currentTimeMillis());
}

public void print() {

System.out.println("Lazy print : " + name);
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException {
Singleton instance1 = Singleton.INSTANCE;
instance1.print();
// 反序列化
ObjectMapper objectMapper = new ObjectMapper();
String content = objectMapper.writeValueAsString(instance1);
Singleton instance3 = objectMapper.readValue(content, Singleton.class);
System.out.println(instance1 == instance3);
instance3.print();
// 反射
Singleton instance2 = Singleton.class.newInstance();
System.out.println(instance1 == instance2);
instance2.print();
}
}
/<code>

運行結果為:

<code>Lazy print : 1583409004276
true
Lazy print : 1583409004276
Exception in thread "main" java.lang.InstantiationException: singleton.Singleton
at java.lang.Class.newInstance(Class.java:427)
at singleton.Singleton.main(Singleton.java:31)
Caused by: java.lang.NoSuchMethodException: singleton.Singleton.<init>()
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.newInstance(Class.java:412)
... 1 more
/<init>/<code>

首先,枚舉是不能被反射生成實例的,這也就解決了反射破壞單例的問題。

其次,在序列化枚舉類型時,只會存儲枚舉類的引用和枚舉常量的名稱。隨後的反序列化的過程中,這些信息被用來在運行時環境中查找存在的枚舉類型對象,這也就解決了序列化破壞單例的問題。

但需要注意:這種方法屬於餓漢模式,所以有浪費資源的隱患,但如果你的單例對象並不佔用資源,沒有狀態變量,那麼這種方式就很適合你。

總結

以上就是我關於單例模式的一些理解,簡單的問題,也可以關聯出併發、類加載、序列化等重要知識。

有興趣的話可以訪問我的博客或者關注我的公眾號、頭條號,說不定會有意外的驚喜。

https://death00.github.io/


分享到:


相關文章: