1. 程式人生 > >設計模式之一單例模式

設計模式之一單例模式

目錄結構

前言

接下來的系列文章我們會談設計模式,設計模式不僅僅存在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類,類初始化就建立物件為預載入方式,另一類為延遲載入方式;餓漢式、靜態內部類為預載入方式 ,懶漢式、雙檢鎖、雙檢鎖變式為延遲載入方式。這裡預載入可以用以上方法防止暴力反射,延遲載入不行,因為在預設構造方法中首先會對單例物件判空,延遲載入在獲取單例時是沒有建立物件的,這時可以通過反射建立物件,因此無法防止反射攻擊,因此推薦的是列舉方式實現單例,省心省力。

參考部落格:單例模式的攻擊之反射攻擊

總結

本文從單例模式的幾種方式入手,分析每個的特點及問題,其中它們公共的特點是私有構造方法,再提供一個公開靜態的方法供外部獲取物件;我們在理解這幾種方式原理後,能夠很容易寫出這些單例,分析每種方式存在的問題,以及改進的方式,其中執行緒安全問題,反序列化問題,反射問題應著重注意,如此我們也能較為全面瞭解單例模式。