設計模式之一單例模式
目錄結構
前言
接下來的系列文章我們會談設計模式,設計模式不僅僅存在Java開發語言中,而是遍及軟體領域且至關重要,是前輩開發總結的經驗,一種設計思想,一種架構;在軟體開發中,唯一不變的就是需求的變化,開發人員不僅要滿足當下的功能需求,還要考慮對後續可能的變化,設計的系統就應有良好的拓展性。在公司接手上一任的程式碼,繼續開發新功能,如果設計的拓展性不好的話,後期開發會很困難,費時費力,還可能對之前的功能有影響,心裡也是忐忑不安,同時也給測試人員新增負擔,改動點增多,測試範圍增大等等,可見設計模式的重要性。
本文講述較為簡單的單例模式,單例模式要保證系統中物件唯一,這不是獲取物件方的責任,是物件提供方保證這個物件在系統中就只能存在一個。如何保證物件的唯一性,就要從建立物件的角度,建立物件可以通過構造方法,Clone物件,反序列化時建立物件,反射四種方式,那麼就需要讓類內部建立唯一物件,不讓外部直接建立,只提供一個方法供外部獲取物件。所以單例模式中第一步構造方法私有,不讓外部new 物件,其次實現單例模式的類不會實現Cloneable介面,則不支援Clone物件;前2種方式都能避免,主要是反序列化和反射機制容易破壞單例。以下我們來分別討論單例模式的幾種方式和其存在的問題,以及反序列化和反射如何破壞單例,怎樣去避免,如何合理設計單例模式?
建立物件四種方式:
- 1、構造方法
- 2、Clone物件
- 3、反序列化時建立物件
- 4、反射
建立單例的常見幾種方式:
- 1、懶漢式
- 2、餓漢式
- 3、雙檢鎖
- 4、靜態內部類方式
- 5、雙檢鎖變式 - CAS自旋鎖
- 6、列舉
一、懶漢式
在需要使用的時候,才建立物件(延遲例項化),存在多執行緒安全問題。
package designpattern.singleton; /** * @author zdd * 2020/1/10 5:15 下午 * Description: 懶漢式建立單例 */ public class LazyInstantiateTest { private static LazyInstantiateTest INSTANCE; //1、私有構造方法,防止被其他類建立物件 private LazyInstantiateTest(){}; //2、對外提供靜態公共方法獲取單例物件 public static LazyInstantiateTest getInstance() { if(INSTANCE == null) { INSTANCE = new LazyInstantiateTest(); } return INSTANCE; } }
二、餓漢式
也稱預載入方式,類在載入初始化時就建立單例物件,餓漢搶食般地建立物件,因此以“餓漢”形容,不存線上程安全問題,但是會佔用記憶體,類一被載入進來就例項化物件到堆中,可能很長時間才被使用或者未被使用,如此造成資源浪費。
package designpattern.singleton; import java.io.Serializable; /** * @author zdd * 2020/1/10 5:31 下午 * Description: 餓漢式實現單例 */ public class HungryTest implements Serializable { private static HungryTest INSTANCE = new HungryTest(); private HungryTest() {}; public static HungryTest getInstance() { return INSTANCE; } }
三、雙檢鎖
package designpattern.singleton;
/**
* @author zdd
* 2020/1/10 5:42 下午
* Description: 雙檢鎖單例
*/
public class DoubleCheckTest {
private static DoubleCheckTest INSTANCE;
private DoubleCheckTest() {}
public static DoubleCheckTest getInstance() {
//1,第一次判空為了提高程式效率
if(INSTANCE ==null) {
//加鎖,這裡使用的監視器物件是該類的位元組碼物件
synchronized (DoubleCheckTest.class){
//2、第二次判空是為了解決多執行緒安全問題
if (INSTANCE == null) {
INSTANCE = new DoubleCheckTest();
}
}
}
return INSTANCE;
}
}
四、靜態內部類
靜態內部類藉助的是類載入機制,內部類只有在被呼叫的時候才載入進來,實現延遲建立物件,是餓漢式的改進,既避免了初始化就建立物件佔用記憶體,又能避免懶漢式的執行緒安全問題。
package designpattern.singleton;
import java.io.Serializable;
/**
* @author zdd
* 2020/1/10 5:55 下午
* Description: 靜態內部類單例
*/
public class StaticInnerClassTest {
//內部類
private static class InstanceInnerClass {
private final static StaticInnerClassTest
INSTANCE = new StaticInnerClassTest();
}
private StaticInnerClassTest(){}
public static StaticInnerClassTest getInstance() {
return InstanceInnerClass.INSTANCE;
}
}
五、雙檢鎖變式 - CAS自旋鎖
網上有個面試題
面試官問:如何在不使用關鍵字synchronized、Lock鎖的情況下,保證執行緒安全地實現單例模式?
能夠執行緒安全建立單例,除了列舉外,有靜態內部類和雙檢鎖方式,雙檢鎖用了關鍵字synchronized,靜態內部類利用的類載入的機制,底層也是含有加鎖操作的。要想實現不用鎖,可以參考迴圈CAS,無阻塞輪詢,利用cas自旋鎖原理。
首先寫一個自旋鎖類
package designpattern.singleton;
import cas.SpinLockTest;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* @author zdd
* 2020/1/10 6:59
* Description: CAS無阻塞自旋鎖
*/
public class CasLock {
static AtomicReference<Thread> atomicReference = new AtomicReference<>();
public static void lock() {
Thread currentThread = Thread.currentThread();
for (;;) {
boolean flag =atomicReference.compareAndSet(null,currentThread);
if(flag) {
break;
}
}
}
public static void unLock() {
Thread currentThread = Thread.currentThread();
Thread momeryThread = atomicReference.get();
//比較記憶體中執行緒物件與當前物件,不相等就丟擲異常,防止未獲取到鎖的執行緒呼叫 unlock
if(currentThread != momeryThread) {
throw new IllegalMonitorStateException();
}
//釋放鎖
atomicReference.compareAndSet(currentThread,null);
}
}
實現雙檢鎖變式單例模式
package designpattern.singleton;
import cas.SpinLockTest;
/**
* @author zdd
* 2020/1/10 6:46
* Description: cas實現單例,實際是cas自旋鎖,在synchronized阻塞式加鎖的改進,無阻塞式加鎖
*/
public class SingletonCasTest {
private static SingletonCasTest INSTANCE;
private static CasLock spinLock = new CasLock();
private SingletonCasTest() {};
public static SingletonCasTest getInstance() {
if(INSTANCE == null) {
spinLock.lock();
if (INSTANCE == null) {
INSTANCE = new SingletonCasTest();
}
spinLock.unLock();
}
return new SingletonCasTest();
}
}
六、列舉
列舉類是《Effective Java》書中推薦的實現單例方式,因為其天然的可防止反序列化和反射破解單例的唯一性,保證有且僅有一個物件,
因太簡潔,可讀性不強。
package designpattern.singleton;
/**
* @author zdd
* 2020/1/10 6:43 下午
* Description:
*/
public enum SingletonEnum{
INSTANCE;
}
七、存在的問題
7.1 執行緒安全
一是需要考慮執行緒安全問題,這是懶漢式存在的問題,為了解決該問題,可以將getInstance() 方法加上synchronized關鍵字或者在方法內部加同步程式碼塊,或者用Lock鎖機制,這樣會導致多執行緒在獲取單例物件時執行緒安全了,但是效率會降低,同步程式碼塊會比同步方法效率更高一些,主要是同步程式碼塊應該儘可能的縮小程式碼塊的包含範圍(標準是恰好包括臨界區部分),粒度越小,併發度才更高。
7.2 反序列問題
二是反序列化問題,在需要將物件序列化與反序列化時,首先讓該單例類實現Serializable介面(標誌介面,無內容,實現類可序列化),然而存在的問題就是在反序列化時會新建立一個物件,這樣就違背了單例模式的物件唯一性。
將物件先轉為位元組寫入到輸入流中(序列化過程),再從輸出流中讀取位元組,再轉換為物件 (反序列化)
程式碼示例如下:
package designpattern.singleton;
import java.io.*;
/**
* @author zdd
* 2020/1/10 7:23 下午
* Description: 反序列化破壞單例物件唯一性
*/
public class DeserializableProblemTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
//先將物件載入到輸入流中,在到輸出流獲取物件,以餓漢式單例為例
HungryTest hungry1 = HungryTest.getInstance();;
HungryTest hungry2 = null;
//1,將單例物件寫入流中
ByteArrayOutputStream ops = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(ops);
oos.writeObject(hungry1);
//2,再從流中讀出,轉換為物件
ByteArrayInputStream ips= new ByteArrayInputStream(ops.toByteArray());
ObjectInputStream ois = new ObjectInputStream(ips);
hungry2 =(HungryTest) ois.readObject();
//3、判斷是否為同一個物件
System.out.println(hungry1 ==hungry2);
}
}
執行結果: 證明反序列化後又新建立了物件
false
解決反序列化問題:在HungryTest類中新增如下方法
//防止反序列化破壞單例
private Object readResolve() {
return INSTANCE;
}
再執行執行結果為 true ,證明是同一個物件,未建立新物件。
為什麼新增一個readResolve 方法就可以防止反序列化建立新的物件呢?
進入ObjectInputStream的 readObject() 可見,下面只列出關鍵程式碼位置,詳細可自己檢視原始碼
首先類要支援序列化,通過反射建立新物件賦值給obj
繼續往下看,這裡有if判斷,滿足3個條件,其中hasReadResolveMethod判斷是否有readResolve方法,有則呼叫該方法,最後obj被readResolve返回物件覆蓋。
那麼readResolveMethod需要滿足什麼要求? 滿足以下3個條件即可
參考部落格:單例模式的攻擊之序列化與反序列化
7.3 反射
三是反射,我們知道Java中反射幾乎是無所不能,你不讓我建立物件,那就暴力反射建立,我們如何防止反射破解單例?
暴力反射破壞單例示例:
package designpattern.singleton;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
/**
* @author zdd
* 2020/1/13 2:49 下午
* Description: 暴力反射破解單例
*/
public class ReflectBreakSingletonTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
//1,獲取單例物件
HungryTest hungry1 = HungryTest.getInstance();
//2, 獲取HungryTest類位元組碼物件
Class<HungryTest> hungryClass= HungryTest.class;
//3,獲取構造器物件
Constructor<HungryTest> hungryConstructor = hungryClass.getDeclaredConstructor();
//4,設定暴力反射為true
hungryConstructor.setAccessible(true);
//5,通過構造器物件呼叫預設構造器建立物件 --> 反射
HungryTest hungry2= hungryConstructor.newInstance();
//6, 判斷兩個物件是否相同
System.out.println(hungry1 == hungry2);
}
}
執行結果: false
證明反射可以破壞單例物件唯一,新建立物件。
如何防止反射對單例的攻擊?
既然反射攻擊是呼叫預設構造器,那麼反射在呼叫構造器時就丟擲異常不讓其建立物件。依然以餓漢式為例,修改預設構造方法,如果反射呼叫就丟擲異常!
private HungryTest() {
if(null !=INSTANCE) {
throw new RuntimeException("不支援反射呼叫預設構造器!");
}
};
問:以上6種單例模式都可以通過在預設構造方法中拋異常防止暴力反射嗎?
答:除去列舉(其天然防止反射),其他5種分為2類,類初始化就建立物件為預載入方式,另一類為延遲載入方式;餓漢式、靜態內部類為預載入方式 ,懶漢式、雙檢鎖、雙檢鎖變式為延遲載入方式。這裡預載入可以用以上方法防止暴力反射,延遲載入不行,因為在預設構造方法中首先會對單例物件判空,延遲載入在獲取單例時是沒有建立物件的,這時可以通過反射建立物件,因此無法防止反射攻擊,因此推薦的是列舉方式實現單例,省心省力。
參考部落格:單例模式的攻擊之反射攻擊
總結
本文從單例模式的幾種方式入手,分析每個的特點及問題,其中它們公共的特點是私有構造方法,再提供一個公開靜態的方法供外部獲取物件;我們在理解這幾種方式原理後,能夠很容易寫出這些單例,分析每種方式存在的問題,以及改進的方式,其中執行緒安全問題,反序列化問題,反射問題應著重注意,如此我們也能較為全面瞭解單例模式。