設計模式——單例模式
- 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一個對象並非一個原子操作,可分為三步:
- 分配記憶體空間
- 執行構造方法,初始化對象
- 把這個對象指向空間
由於new對象並不是一個原子操作,所以可能發生指令重排,執行順序可能是123,也可能是132,假如指令執行順序變成了132:
- 假如A進程剛進來,先分配記憶體空間,再把對象指向這個空間
- 此時進來一個執行緒B,由於LAZY已經指向了一個空間,它會認為對象不為null,所以會直接返回
- 此時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)
*/
這樣說明反射確實無法破解枚舉的單例
總結
實現單例模式有四種方式,餓漢式、懶漢式、靜態內部類、枚舉。
餓漢式:
- 執行緒安全
- 由於在類載入的時候初始化,浪費記憶體
懶漢式:
- 要想執行緒安全得加鎖,但加鎖就會影響效率,但getInstance方法由於調用機會不多,所以影響不是很大
- 第一次調用才初始化,避免記憶體的浪費。
靜態內部類:
- 執行緒安全
枚舉:
- 執行緒安全
- 絕對防止多次實例化
- 自動支援序列化