10.17 單例模式,你真的寫對了嗎?

作者:何甜甜在嗎
來源:juejin.im/post/5d8cc45ae51d4577ef53de12

看公司代碼的時候發現項目中單例模式應用挺多的,並且發現的兩處單例模式用的還是不同的方式實現的,那麼單例模式到底有幾種寫法呢?單例模式看似很簡單,但是實際寫起來卻問題多多。

本文大綱

  • 什麼是單例模式
  • 餓漢式創建單例對象
  • 懶漢式創建單例對象
  • 單例模式的優缺點
  • 單例模式的應用場景

什麼是單例模式

確保某個類只有一個實例,而且自行實例化並向整個系統提供這個實例,並且有兩種創建方式,一種是餓漢式創建,另外一種是懶漢式創建

餓漢式創建單例模式

餓漢式創建就是在類加載時就已創建好對象,而不是在需要時在創建對象

public class HungrySingleton {
private static HungrySingleton hungrySingleton = new HungrySingleton();
/**
* 私有構造函數,不能被外部所訪問
*/
private HungrySingleton() {}
/**
* 返回單例對象
* */
public static HungrySingleton getHungrySingleton() {
return hungrySingleton;
}
}

說明:

  • 構造函數私有化,保證外部不能調用構造函數創建對象,創建對象的行為只能由這個類決定
  • 只能通過getHungrySingleton方法獲取對象
  • HungrySingleton對象已經創建完成【在類加載時創建】

缺點:

  • 如果getHungrySingleton一直沒有被使用到,有點浪費資源


優點:

  • 由ClassLoad保證線程安全


懶漢式創建單例模式

懶漢式創建就是在第一次需要該對象時在創建

存在錯誤的懶漢式創建單例對象 根據定義很容易在上面餓漢式的基礎上進行修改

public class LazySingleton {
private static LazySingleton lazySingleton = null;
/**
* 構造函數私有化
* */
private LazySingleton() {
}
private static LazySingleton getLazySingleton() {
if (lazySingleton == null) {
return new LazySingleton();
}
return lazySingleton;
}
}

說明:

  • 構造函數私有化
  • 當需要時【getLazySingleton方法調用時】才創建

嗯,好像沒什麼問題,但是當有多個線程同時調用getLazySingleton方法時,此時剛好對象沒有初始化,兩個線程同時通過lazySingleton == null的校驗,將會創建兩個LazySingleton對象。必須搞點手段使getLazySingleton方法是線程安全的

synchronize或Lock

很容易想到使用synchronize或Lock對方法進行加鎖 使用synchronize:

單例模式,你真的寫對了嗎?

單例模式,你真的寫對了嗎?

這兩種方式雖然保證了線程安全,但是性能較差,因為線程不安全主要是由這段代碼引起的:

if (lazyLockSingleton == null) {
lazyLockSingleton = new LazyLockSingleton();
}

給方法加鎖無論對象是否已經初始化都會造成線程阻塞。如果對象為null的情況下才進行加鎖,對象不為null的時候則不進行加鎖,那麼性能將會得到提升,雙重鎖檢查可以實現這個需求

雙重鎖檢查

在加鎖之前先判斷lazyDoubleCheckSingleton == null是否成立,如果不成立直接返回創建好的對象,成立在加鎖

單例模式,你真的寫對了嗎?

說明:

為什麼需要對lazyDoubleCheckSingleton添加volatile修飾符

因為lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();不是原子性的,分為三步:

  • 為lazyDoubleCheckSingleton分配內存
  • 調用構造函數進行初始化
  • 將lazyDoubleCheckSingleton對象指向分配的內存【執行完這步lazyDoubleCheckSingleton將不為null】

為了提高程序的運行效率,編譯器會進行一個指令重排,步驟2和步驟三進行了重排,線程1先執行了步驟一和步驟三,執行完後,lazyDoubleCheckSingleton不為null,此時線程2執行到if (lazyDoubleCheckSingleton == null),線程2將可能直接返回未正確進行初始化的lazyDoubleCheckSingleton對象。

出錯的原因主要是lazyDoubleCheckSingleton未正確初始化完成【寫】,但是其他線程已經讀取lazyDoubleCheckSingleton的值【讀】,使用volatile可以禁止指令重排序,通過內存屏障保證寫操作之前不會調用讀操作【執行if (lazyDoubleCheckSingleton == null)】

缺點:

  • 為了保證線程安全,代碼不夠優雅過於臃腫


靜態內部類

public class LazyStaticSingleton {
/**
* 靜態內部類
* */
private static class LazyStaticSingletonHolder {
private static LazyStaticSingleton lazyStaticSingleton = new LazyStaticSingleton();
}
/**
* 構造函數私有化
* */
private LazyStaticSingleton() {
}
public static LazyStaticSingleton getLazyStaticSingleton() {
return LazyStaticSingletonHolder.lazyStaticSingleton;
}
}

靜態內部類在調用時才會進行初始化,因此是懶漢式的,LazyStaticSingleton lazyStaticSingleton = new LazyStaticSingleton();看似是餓漢式的,但是隻有調用getLazyStaticSingleton時才會進行初始化,線程安全由ClassLoad保證,不用思考怎麼加鎖

前面幾種方式實現單例的方式雖然各有優缺點,但是基本實現了單例線程安全的要求。但是總有人看不慣單例模式勤儉節約的作用,對它進行攻擊。對它進行攻擊無非就是創建不只一個類,java中創建對象的方式有new、clone、序列化、反射。構造函數私有化不可能通過new創建對象、同時單例類沒有實現Cloneable接口無法通過clone方法創建對象,那剩下的攻擊只有反射攻擊和序列化攻擊了

反射攻擊:

public class ReflectAttackTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
//靜態內部類
LazyStaticSingleton lazyStaticSingleton = LazyStaticSingleton.getLazyStaticSingleton();
//通過反射創建LazyStaticSingleton
Constructor<lazystaticsingleton> constructor = LazyStaticSingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
LazyStaticSingleton lazyStaticSingleton1 = constructor.newInstance();
//打印結果為false,說明又創建了一個新對象
System.out.println(lazyStaticSingleton == lazyStaticSingleton1);
//synchronize
LazySynchronizeSingleton lazySynchronizeSingleton = LazySynchronizeSingleton.getLazySynchronizeSingleton();
Constructor<lazysynchronizesingleton> lazySynchronizeSingletonConstructor = LazySynchronizeSingleton.class.getDeclaredConstructor();
lazySynchronizeSingletonConstructor.setAccessible(true);
LazySynchronizeSingleton lazySynchronizeSingleton1 = lazySynchronizeSingletonConstructor.newInstance();
System.out.println(lazySynchronizeSingleton == lazySynchronizeSingleton1);
//lock
LazyLockSingleton lazyLockSingleton = LazyLockSingleton.getLazyLockSingleton();
Constructor<lazylocksingleton> lazyLockSingletonConstructor = LazyLockSingleton.class.getConstructor();
lazyLockSingletonConstructor.setAccessible(true);
LazyLockSingleton lazyLockSingleton1 = lazyLockSingletonConstructor.newInstance();
System.out.println(lazyLockSingleton == lazyLockSingleton1);
//雙重鎖檢查
LazyDoubleCheckSingleton lazyDoubleCheckSingleton = LazyDoubleCheckSingleton.getLazyDoubleCheckSingleton();
Constructor<lazydoublechecksingleton> lazyDoubleCheckSingletonConstructor = LazyDoubleCheckSingleton.class.getConstructor();
lazyDoubleCheckSingletonConstructor.setAccessible(true);
LazyDoubleCheckSingleton lazyDoubleCheckSingleton1 = lazyDoubleCheckSingletonConstructor.newInstance();
System.out.println(lazyDoubleCheckSingleton == lazyDoubleCheckSingleton1);
}
}
/<lazydoublechecksingleton>/<lazylocksingleton>/<lazysynchronizesingleton>/<lazystaticsingleton>

基於靜態內部類和基於synchronize加鎖創建單例對象的方式都可以通過反射的方式創建新對象,存在反射攻擊,其餘幾種創建單例對象的方式使用反射創建新對象將會報錯。針對存在的反射攻擊根據網上提供的思路在搶救一下,搶救姿勢如下:

 private LazySynchronizeSingleton() {
//flag為線程間共享,進行加鎖控制
synchronized (LazySynchronizeSingleton.class) {
if (flag == false) {
flag = !flag;
} else {
throw new RuntimeException("單例模式被攻擊");
}
}
}

構造函數只能調用一次,調用第二次將拋出異常,通過flag來判斷構造函數是否已經被調用過一次了。但是我們仍可以通過反射修改flag的值:

//調用反射前將flag設置為false
Field flagField = lazySynchronizeSingleton.getClass().getDeclaredField("flag");
flagField.setAccessible(true);
flagField.set(lazySynchronizeSingleton, false);

搶救失敗,你可能想通過final修飾禁止修改,但是反射可以先去除final,在加上final修改值,對於反射攻擊,無力迴天,只能選擇不適用存在反射攻擊的單例創建方式

反序列化攻擊:

public class SerializableAttackTest {
public static void main(String[] args) {
//懶漢式
HungrySingleton hungrySingleton = HungrySingleton.getHungrySingleton();
//序列化
byte[] serialize = SerializationUtils.serialize(hungrySingleton);
//反序列化
HungrySingleton hungrySingleton1 = SerializationUtils.deserialize(serialize);

System.out.println(hungrySingleton == hungrySingleton1);
//雙重鎖
LazyDoubleCheckSingleton lazyDoubleCheckSingleton = LazyDoubleCheckSingleton.getLazyDoubleCheckSingleton();
byte[] serialize1 = SerializationUtils.serialize(lazyDoubleCheckSingleton);
LazyDoubleCheckSingleton lazyDoubleCheckSingleton11 = SerializationUtils.deserialize(serialize1);
System.out.println(lazyDoubleCheckSingleton == lazyDoubleCheckSingleton11);
//lock
LazyLockSingleton lazyLockSingleton = LazyLockSingleton.getLazyLockSingleton();
byte[] serialize2 = SerializationUtils.serialize(lazyLockSingleton);
LazyLockSingleton lazyLockSingleton1 = SerializationUtils.deserialize(serialize2);
System.out.println(lazyLockSingleton == lazyLockSingleton1);
//synchronie
LazySynchronizeSingleton lazySynchronizeSingleton = LazySynchronizeSingleton.getLazySynchronizeSingleton();
byte[] serialize3 = SerializationUtils.serialize(lazySynchronizeSingleton);
LazySynchronizeSingleton lazySynchronizeSingleton1 = SerializationUtils.deserialize(serialize3);
System.out.println(lazySynchronizeSingleton == lazySynchronizeSingleton1);
//靜態內部類
LazyStaticSingleton lazyStaticSingleton = LazyStaticSingleton.getLazyStaticSingleton();
byte[] serialize4 = SerializationUtils.serialize(lazySynchronizeSingleton);
LazyStaticSingleton lazyStaticSingleton1 = SerializationUtils.deserialize(serialize4);
System.out.println(lazyStaticSingleton == lazyStaticSingleton1);
}
}

打印結果都為false,都存在反序列化攻擊

對於反序列化攻擊,還是有有效的搶救方式的,搶救姿勢如下:

private Object readResolve() {
return lazySynchronizeSingleton;
}

添加readResolve方法並返回創建的單例對象,至於搶救的原理,可以通過跟進SerializationUtils.deserialize的代碼可知

上述實現單例對象的方式既要考慮線程安全、又要考慮攻擊,而通過枚舉創建單例對象完全不用擔心這些問題

枚舉

public enum EnumSingleton {
INSTANCE;
public static EnumSingleton getEnumSingleton() {
return INSTANCE;
}
}

代碼實現也相當優美,總共才8行代

實現原理:枚舉類的域(field)其實是相應的enum類型的一個實例對象

可以參考:

https://stackoverflow.com/questions/26285520/implementing-singleton-with-an-enum-in-java

枚舉攻擊測試:

public class EnumAttackTest {
public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
EnumSingleton enumSingleton = EnumSingleton.getEnumSingleton();
//序列化攻擊
byte[] serialize4 = SerializationUtils.serialize(enumSingleton);
EnumSingleton enumSingleton2 = SerializationUtils.deserialize(serialize4);
System.out.println(enumSingleton == enumSingleton2);
//反射攻擊
Constructor<enumsingleton> enumSingletonConstructor = EnumSingleton.class.getConstructor();
enumSingletonConstructor.setAccessible(true);
EnumSingleton enumSingleton1 = enumSingletonConstructor.newInstance();
System.out.println(enumSingleton == enumSingleton1);
}
}
/<enumsingleton>

反射攻擊將會拋出異常,序列化攻擊對它無效,打印結果為true,用枚舉創建單例對象真的是無懈可擊

單例模式的優點

  • 只創建了一個實例,節省內存開銷
  • 減少了系統的性能開銷,創建對象回收對象對性能都有一定的影響
  • 避免對資源的多重佔用
  • 在系統設置全局的訪問點,優化和共享資源優化

總結一下就是節約資源、提升性能

單例模式的缺點

  • 不適用於變化的對象
  • 單例模式中沒有抽象層,擴展有困難
  • 與單一原則衝突。一個類應該只實現一個邏輯,而不關心它是否單例,是不是單例應該由業務決定

單例模式的應用場景

  • Spring IOC默認使用單例模式創建bean
  • 創建對象需要消耗的資源過多時
  • 需要定義大量的靜態常量和靜態方法的環境,比如工具類【感覺是最常見應用場景】

小結

總共介紹了六種正確創建單例對象的方式,推薦使用餓漢式創建單例對象的方式,如果對資源使用有要求,則推薦使用靜態內部類【注意反序列化攻擊】,其他方式在保證線程安全的同時對性能將會有影響。枚舉類其實是非常不錯的,線程安全、不存在反射攻擊和反序列化攻擊,但是感覺這種創建單例方式應用較少,公司代碼中使用的是雙重鎖檢查和靜態內部類【存在反序列化攻擊】創建單例方式,甚至之前出去面試時面試官讓寫一個單例,我使用的是枚舉方式,面試官都不知道有這種方式

完整例子代碼+測試代碼:

https://github.com/TiantianUpup/design-patterns


分享到:


相關文章: