輕松理解設計模式(創建型):1、單例模式


前言

設計模式,是一套被反復使用、多數人知曉的、經過分類編目的、代碼設計經驗的總結。它描述了在軟件設計過程中的一些不斷重復發生的問題,以及該問題的解決方案。也就是說,它是解決特定問題的一系列套路,是前輩們的代碼設計經驗的總結,具有一定的普遍性,可以反復使用。其目的是為了提高代碼的可重用性、代碼的可讀性和代碼的可靠性。

經過匯總的23種設計模式它是總結了面向對象設計當中最有價值的經驗。對之前來講可能是對其中部分設計模式還是相對來說熟悉的但仔細琢磨還是會有些疑問,正好在目前相對來說有更多的業余時間,可以來一次重新學習設計模式!

設計模式的一篇單例模式,內容包含2點一是模式的定義與目的,二是Java具體實現與疑問

定義

對於單例模式大家應該還算比較熟悉,可能很多時候需要手寫啊啥的。

先還是體會下最初的定義

單例模式最初的定義出現於《設計模式》(艾迪生維斯理, 1994):“保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。”

我們可以從中得到兩個信息:

  1. 保證一個類僅僅只能有一個實例

    也就是不能被其他外界實例化。那么構造方法得私有private,且對象此實例屬於類也就是static成員存在

  2. 提供這個實例的全局訪問點

    提供public static方法給外界獲取此成員

那么為什么要設計這樣的單例類呢?

  1. 比如網站點登錄會有一個登錄窗口,把這個登錄窗口當成一個對象。那么每次點擊登錄都要創建這樣一個登錄窗口嗎? 用完之后再銷毀掉么?同樣還有訪問數據庫,需要建立連接使用完畢銷毀連接,但每次就是使用的這樣一個一模一樣的東西。

  2. 再比如全局的計數器,如果一個計數器,每次獲取的是不一樣的對象,那就沒作用了。

兩點:1.減少開銷、2.共享資源

餓漢式

綜上定義所說實現單例模式的類有一下幾點:

  1. 構造方法私有
  2. 作為靜態成員存在
  3. 提供公開方法獲取

那么很容易寫出以下代碼,就是熟悉的餓漢的實現

public class Single{
    private static final Single single = new Single();
    private Single(){}
    public static Single getSingle(){
        return single;
    }
}

懶漢式

對於單例的餓漢與懶漢大家也應該都清楚,一個是這個類加載的時候實例就創建了,另一個是等去調用getSingle()才去new,就像下面這樣

public class Single{
    private static Single single;
    private Single(){}
    public static Single getSingle(){
        if(single == null){
            single = new Single();
        }
        return single;
    }
}

好處就是避免了一開始的空間占用。問題就在於創建對象需要兩步,一步是判斷第二步是創建。因此存在線程安全問題導致多次創建違背了單例,如下:

image-20210905182509558

image-20210905181938236

可以看的到在前幾個結果已經出現,新實例的情況.所以這個獲取的這個操作是存在線程安全問題的。

那解決也很簡單比如:

public class Single{
    private static Single single;
    private Single(){}
    public synchronized static Single getSingle(){
        if(single == null){
            single = new Single();
        }
        return single;
    }
}

public class Single{
    private static Single single;
    private Single(){}
    public static Single getSingle(){
        synchronized(Single.class){
            if(single == null){
                single = new Single();
            }
        }
        return single;
    }
}

也就是加上synchronized保證代碼塊的同步.
但其實也就是第一次被外界獲取是需要創建唯一的實例的,之后就都是返回已經存在的唯一實例,為了第一次的安全性鎖住了全部包括之后每次獲取都要走synchronized.是否是同步范圍過大了?是不是沒有這個必要?

DCL實現

了解多線程的都會熟悉雙重檢測鎖(Double Check Lock)的寫法,在這里一樣
DCL的單例實現

public class Single{
    private static Single single;
    private Single(){}
    public static Single getSingle(){
        if(single == null){
            synchronized(Single.class){
                if(single == null){
                    single = new Single();
                }
            }
        }
        return single;
    }
}

其實就是在synchronized外面再判斷一次,這樣就保證實例創建完之后只用走個判斷就返回不用保證同步,而因為首次創建進去的一批線程會在后面經過同步塊與判空來讓唯一的一個線程去創建.

了解過單例實現的,都知道對於這個實例成員還需加上volatile修飾,知道對於new對象並不是原子的,而是有大概如下的三步:

  1. 開辟空間
  2. 初始化對象到空間
  3. 將空間地址進行引用

image-20210909182308932
當2與3步驟進行調換,也就是圖上的字節碼,21與24的地方。當完成字段賦值,判斷則不為空,但如果21與24的指令交換了順序,那么字段不為空時對象並沒初始化。由於雙重檢測鎖的實現第一個判斷是開放的,也就是在一個線程在創建對象的過程中,另一個線程可以經過判斷如果不為空直接返回.

private static volatile Single single;

在后面疑問章節,可以不使用volatile達到同樣的目的么?

內部類實現

懶漢式實現在使用到實例成員時才創建,那么除了像上面一樣獲取的時候進行判空來進行延遲創建,其實對於Java來說還有另一種方式就是內部類.

public class Single{
    private Single(){}
    private static class CreateSingle{
        private static final Single SINGLE = new Single()
    } 
    public static Single getSingle(){
        return CreateSingle.SINGLE;
    }
}

這個是利用了Java內部類的特性:

  1. 外部類可以訪問內部的私有成員
  2. 類被加載時不會加載其中的內部類,只有內部類被訪問使用時才加載

那么這樣就做到了延遲加載也就是懶漢式的單例,並且沒有多步驟操作,是自然的線程安全.

枚舉

既然上面已經有較好的單例實現,為啥還要說枚舉?

它是Java特性天然的單例模式

public enum EnumSingle {
    SINGLE;
}

大家都知道枚舉類型,是把實例確定了的類。如果使用枚舉類型就限制了對應的值。因為對於枚舉的實例是在枚舉類里列出來確定好了,並且構造器也是私有的。

下面是枚舉類反編譯的刪減版(其實就是去掉了一些方法,只留下構造器與與成員實例)

public final class EnumSingle extends Enum{
    public static final EnumSingle SINGLE = new EnumSingle("SINGLE", 0);
    private EnumSingle(String s, int i){
        super(s, i);
    }
}

疑問

在開篇之前,是覺得這東西是很熟悉的。最終梳理完了感覺還是有很多疑問的,下面列出來疑問點以及我的想法:

  1. 為什么說單例的最佳實現是無狀態的?(很多地方看到這句)

什么是無狀態? 就是說類不具有成員屬性信息,或者說沒有可修改的成員屬性。那么無狀態的單例也就是完全的並發安全的,沒有可供修改的內容,獲取實例只是使用其中的方法,這樣一般用來做工具類。第二種就是狀態的單例,共享資源。比如全局計數器,那就要去保證線程安全提供安全的操作方法。

  1. 我們知道Java里面有發射機制,這些單例實現會被破壞么?

如果使用反射的話是確實可以去破壞,通過反射可以獲取構造器來創建新對象,比如像下面:

// 1.獲取無參構造器
Constructor<Single> constructor = Single.class.getDeclaredConstructor();
// 2.關閉私有訪問
constructor.setAccessible(true);
// 3.調用兩次構造器獲取對象
Single single1 = constructor.newInstance();
Single single2 = constructor.newInstance();
// 4.測試
System.out.println(single1);
System.out.println(single2);

反射能去隨意的使用單例類的構造器,因此確實可以破壞。但我們可以去點開newInstance方法

image-20210907212626845

可以很驚喜的發現,如果是枚舉類型就不允許通過反射來執行構造器,會拋出異常

throw new IllegalArgumentException("Cannot reflectively create enum objects");
  1. 枚舉這種方式它是屬於餓漢式還是懶漢式?

我們可以通過枚舉實際的一個class代碼,枚舉的成員實際都是static final的、一開始就創建的。是屬於餓漢式的。

public static final EnumSingle SINGLE = new EnumSingle("SINGLE", 0);

但在查找資料時發現有的博客上寫的是枚舉是懶漢,理由是一句實際運行時會延遲加載。如果按照最終的執行的class反編譯的代碼它一定就是餓漢。拋開執行代碼看看內存分析是否一致

測試結果沒問題和預期一樣,類加載就會創建實例。枚舉是餓漢。

  1. 經過序列化之后還是同一個實例么?

其實我們大概都知道,進行序列化與反序列化它是一個深拷貝的過程,是產生另外一個屬性內容都相同的新對象,所以如果一個單例類它可以被序列化,那么確實可以打破單例得到新對象。因此對於任何對象按照下面結果都是false

// 進行序列化
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("tempFile"));
out.writeObject(Single.getSingle());
File file = new File("tempFile");
// 還原
ObjectInputStream input =  new ObjectInputStream(new FileInputStream(file));
Object newInstance = input.readObject();
//判斷是否是同一個對象
System.out.println(newInstance == Single.getSingle());

但經過測試發現枚舉是與眾不同的

image-20210908223340198

Java規范字規定,每個枚舉類型及其定義的枚舉變量在JVM中都是唯一的,因此在枚舉類型的序列化和反序列化上,Java做了特殊的規定。在序列化的時候Java僅僅是將枚舉對象的name屬性輸到結果中,反序列化的時候則是通過java.lang.Enum的valueOf()方法來根據名字查找枚舉對象。也就是說,序列化的時候只將DATASOURCE這個名稱輸出,反序列化的時候再通過這個名稱,查找對應的枚舉類型,因此反序列化后的實例也會和之前被序列化的對象實例相同 —— 《Java編程思想》

抱着一探究竟的想法看了跟着看了readObject如下圖,就想上面描述的一樣,通過枚舉的name屬性獲取到枚舉類里存在的實例。
image-20210908225725870
image-20210908225810282

  1. DCL實現可以不使用volatile么?

理論上來講是可以的,畢竟加這個volatile也是出於理論上,目前沒有找到測試方法。

那么不加volatitle怎么保證不會拿到為初始化好的對象呢?很簡單雖然對於JVM確實會可能進行指令的順序調整提高效率,前提是調換順序不影響結果才可能被重排序。

為了讓其不被重排序除了volatile修飾成員之外。是不是可以讓創建對象之后被依賴,通過依賴關系讓JVM不對其進行重排序。將最終創建完整的對象再直接賦值給單例字段single

下面是我的猜想,讓try-catch限制創建對象的指令完成之后再進行下面賦值操作。也就是原子操作給single。保證single字段不為空時,一定是個完整的對象。

try{
  Single temp = new Single();
}catch(Exception e){

}
single = temp;

當然這是猜想,關於指令重排序怎么測試出來,怎么追蹤到JVM里面去看最終操作。目前沒有找到合適的方法。

總結

首先就是這種單例設計的思想,是比較好理解的全局唯一的對象。第二就是我們對這種思想落實到具體編碼實現需要考慮的點:1.需要懶漢還是餓漢式?2.是否需要保證是否線程安全?3.是否有必要防反射序列化等等。對於優缺點而言就是有得必有舍得問題,比如它不能被繼承不好擴展等。這些缺點就是為了實現單例得思想帶來的因此必須是不能的。所以也不算缺點而是說這個使用場景是不是滿足了使用單例的需要。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM