設計模式之一單例模式

目錄結構

前言

接下來的系列文章咱們會談設計模式,設計模式不只僅存在Java開發語言中,而是遍佈軟件領域且相當重要,是前輩開發總結的經驗,一種設計思想,一種架構;在軟件開發中,惟一不變的就是需求的變化,開發人員不只要知足當下的功能需求,還要考慮對後續可能的變化,設計的系統就應有良好的拓展性。在公司接手上一任的代碼,繼續開發新功能,若是設計的拓展性很差的話,後期開發會很困難,費時費力,還可能對以前的功能有影響,內心也是忐忑不安,同時也給測試人員添加負擔,改動點增多,測試範圍增大等等,可見設計模式的重要性。java

本文講述較爲簡單的單例模式,單例模式要保證系統中對象惟一,這不是獲取對象方的責任,是對象提供方保證這個對象在系統中就只能存在一個。如何保證對象的惟一性,就要從建立對象的角度,建立對象能夠經過構造方法Clone對象反序列化時建立對象反射四種方式,那麼就須要讓類內部建立惟一對象,不讓外部直接建立,只提供一個方法供外部獲取對象。因此單例模式中第一步構造方法私有,不讓外部new 對象,其次實現單例模式的類不會實現Cloneable接口,則不支持Clone對象;前2種方式都能避免,主要是反序列化和反射機制容易破壞單例。如下咱們來分別討論單例模式的幾種方式和其存在的問題,以及反序列化和反射如何破壞單例,怎樣去避免,如何合理設計單例模式?面試

建立對象四種方式:設計模式

  • 一、構造方法
  • 二、Clone對象
  • 三、反序列化時建立對象
  • 四、反射

建立單例的常見幾種方式:安全

  • 一、懶漢式
  • 二、餓漢式
  • 三、雙檢鎖
  • 四、靜態內部類方式
  • 五、雙檢鎖變式 - CAS自旋鎖
  • 六、枚舉

1、懶漢式

在須要使用的時候,才建立對象(延遲實例化),存在多線程安全問題。多線程

package designpattern.singleton;
/**
 * @author zdd
 * 2020/1/10 5:15 下午
 * Description: 懶漢式建立單例
 */
public class LazyInstantiateTest {
    private  static  LazyInstantiateTest INSTANCE;
    //一、私有構造方法,防止被其餘類建立對象
    private LazyInstantiateTest(){};
    //二、對外提供靜態公共方法獲取單例對象
    public static LazyInstantiateTest getInstance() {
        if(INSTANCE == null) {
            INSTANCE = new LazyInstantiateTest();
        }
        return INSTANCE;
    }
}

2、餓漢式

也稱預加載方式,類在加載初始化時就建立單例對象,餓漢搶食般地建立對象,所以以「餓漢」形容,不存在線程安全問題,可是會佔用內存,類一被加載進來就實例化對象到堆中,可能很長時間才被使用或者未被使用,如此形成資源浪費。架構

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;
    }
}

3、雙檢鎖

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){
                //二、第二次判空是爲了解決多線程安全問題
                if (INSTANCE == null) {
                    INSTANCE = new DoubleCheckTest();
                }
            }
        }
        return INSTANCE;
    }
}

4、靜態內部類

靜態內部類藉助的是類加載機制,內部類只有在被調用的時候才加載進來,實現延遲建立對象,是餓漢式的改進,既避免了初始化就建立對象佔用內存,又能避免懶漢式的線程安全問題。併發

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;
    }
}

5、雙檢鎖變式 - CAS自旋鎖

網上有個面試題測試

面試官問:如何在不使用關鍵字synchronized、Lock鎖的狀況下,保證線程安全地實現單例模式?atom

可以線程安全建立單例,除了枚舉外,有靜態內部類和雙檢鎖方式,雙檢鎖用了關鍵字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();
    }
}

6、枚舉

枚舉類是《Effective Java》書中推薦的實現單例方式,由於其自然的可防止反序列化和反射破解單例的惟一性,保證有且僅有一個對象,

因太簡潔,可讀性不強。

package designpattern.singleton;
/**
 * @author zdd
 * 2020/1/10 6:43 下午
 * Description:
 */
public enum  SingletonEnum{
    INSTANCE;
}

7、存在的問題

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();
        //三、判斷是否爲同一個對象
        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類,類初始化就建立對象爲預加載方式,另外一類爲延遲加載方式;餓漢式、靜態內部類爲預加載方式 ,懶漢式、雙檢鎖、雙檢鎖變式爲延遲加載方式。這裏預加載能夠用以上方法防止暴力反射,延遲加載不行,由於在默認構造方法中首先會對單例對象判空,延遲加載在獲取單例時是沒有建立對象的,這時能夠經過反射建立對象,所以沒法防止反射攻擊,所以推薦的是枚舉方式實現單例,省心省力。

參考博客單例模式的攻擊之反射攻擊

總結

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

相關文章
相關標籤/搜索