面試官,你要跟我聊單例?那我可有話說了

語言: CN / TW / HK

theme: vuepress highlight: lioshi


單例模式

文章的初衷

問個問題,餓漢式單例的缺點是呼叫時可能會造成記憶體消耗。那麼能講下,它到底是如何消耗記憶體*…的呢?裡面的原理**是什麼呢?

如果你對這個問題存疑,那麼我推薦你看這篇文章,而且可能單例真的不像你想的那麼簡單!

本文目的是對知識的一個總結,俗話說的好,好記性不如爛筆頭。經常總結,也能幫助總結知識點增強記憶。另一個目的是希望能夠科普單例所涉及的知識點。

希望讀了本文之後,再遇到面試官問你單例的相關知識點時,你能夠胸有成竹,讓他對你刮目相看

幾個小問題

  1. 單例模式有幾種寫法
  2. 餓漢式如何保證執行緒安全
  3. 類載入的過程都有什麼,能介紹下每個階段都做了什麼嘛
  4. volatile都有什麼作用,什麼是指令重排序
  5. 靜態內部類單例是如何做到執行緒安全的。它的缺點是什麼
  6. 為什麼說列舉佔記憶體,為什麼列舉不能被反射

看到這裡,你可能會說,這幾個問題有的和單例也沒關係啊!的確,這裡有些問題和單例是沒關係,但是有沒有一種可能,面試官壓根就不是想單純的問你單例的寫法,這些單例引申出的知識點,才是他真正的目的

單例的寫法

首先是上面的第一個問題,單例的寫法。 關於單例的寫法,這裡不再多做介紹了,網上太多的文章了。這裡直接說答案,一共5種寫法。這裡貼一篇垃圾科普文單例模式,今天你用了嘛

直入主題

下面我們每種寫法,都來看一下它的優缺點,以及使用時可能碰到的問題。

1. 餓漢式

我們先來看一下,最簡單的單例寫法餓漢式。
餓漢式的優點是:寫法簡單,且執行緒安全。那麼它的缺點是什麼呢。看下面的寫法就知道了。對比其他文章,我往裡加了一些比較極端的程式碼,方便理解。

```java /* * @author jtl * @date 2021/7/20 11:14 * 餓漢式單例 * 優點:執行緒安全的 * 缺點:由於類載入時就會建立物件。會造成記憶體浪費。 /

public class HungrySingle { private static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle(); // 只要載入HungrySingle類,就會建立50M記憶體的陣列。 private byte[] aaa = new byte[1024102450];

private HungrySingle(){
}

public static HungrySingle getInstance(){
    return S_HUNGRY_SINGLE;
}
// 呼叫test方法時,使用aaa陣列
public void test(){
    for (byte data:aaa){
        data = 127;
    }
}

public static int info(){
    return 2;
}

} ```

餓漢式缺點:

上面的例子中,有點極端,但是確很好的體現了餓漢式的缺點。 1. 上面的例子中有一個aaa陣列,它會在test()方法中被使用。 2. 當我呼叫HungrySingle.info();這個靜態方法時。會執行HungrySingle的類載入 3. 執行類載入時,會執行private static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle();會創建物件。 4. 由於建立物件,會建立aaa陣列,即使我現在沒有呼叫test,不需要使用這個陣列 5. 最終餓漢式單例,就會因為呼叫了一個static方法而建立物件,從而申請不必要的記憶體,導致浪費效能

面試官可能的問題:

由於類載入時就會建立物件,會造成記憶體浪費。

比如例子中,只要建立HungrySingle的物件,會平白無故的建立50m記憶體的陣列。那麼什麼時候會建立物件呢?由於S_HUNGRY_SINGLE是static 修飾的,所以一執行類載入就會建立物件。

這裡就可能就包含面試官想考你的知識點了: 1. 你能描述下類載入的過程嘛? 2. 什麼時候會執行類載入?

什麼是類載入

首先餓漢式涉及到的第一個知識點,就是類載入
類載入是什麼呢,當我們使用一個類的時候,首先要做的是把這個類,也就是我們java檔案編譯出的.class檔案,載入到虛擬機器之中。
類載入分為5個基本步驟: 1. 載入:將class檔案二進位制位元組流的方式載入到記憶體中 2. 驗證:驗證位元組碼的安全性,以防有人篡改位元組碼 3. 準備:靜態變數預設初始值(int型別初始值為0,引用型別為null等),static final修飾的常量在這一步直接賦值(static final修飾的基本資料型別會將結果編譯到位元組碼中) 4. 解析:將符號引用轉換為直接引用 5. 初始化:執行static程式碼塊,初始化static變數,該步驟即為clinit。

經過類載入之後,該類的相關資料會被儲存在,方法區中存放型別資訊的位置。另外,類的生命週期還包括使用和解除安裝,此處講的是類的載入過程,所以沒有把這兩個生命週期寫入。

什麼時候會執行類載入呢?

當JVM執行HungrySingle這個類的相關程式碼的時候,第一件事情就是去檢視方法區中是否存在該類的資訊。如果存在,證明已經載入過HungrySingle,如果不存在,那麼就執行HungrySingle的類載入。

以上面程式碼為例:一旦載入該類,由於static final HungrySingle S_HUNGRY_SINGLE = new HungrySingle(); 的緣故就會在類載入的初始化階段建立物件。又因為建立物件的時候會建立陣列byte[] aaa,這樣就造成了效能浪費。

涉及的知識點:

  1. 什麼時候會執行類載入
  2. 類載入的過程
  3. static修飾的變數什麼時候進行賦值初始化

不推薦的原因:

類載入時會建立該類物件,可能會無意中建立一些佔用記憶體的物件或者陣列,造成效能浪費。


懶漢式

懶漢式單例,可以避免上面餓漢式中,呼叫static方法時就建立物件的這個缺點。同時通過增加synchronized關鍵字,保證了執行緒的安全性

下面是懶漢式的寫法 ```java /* * @author jtl * @date 2021/7/20 11:41 * 懶漢式 * 優點:不會造成記憶體浪費 * 缺點:不加synchronized 會造成執行緒安全問題 * 加 synchronized 會造成效能浪費。 * /

public class LazySingle { private static LazySingle sLazy ;

private LazySingle(){
    System.out.println("懶漢式:"+Thread.currentThread().getName());
}

public static synchronized LazySingle getInstance(){
    if (sLazy==null){
        sLazy = new LazySingle();
    }

    return sLazy;
}

} ```

懶漢式的缺點

懶漢式通過synchronized修飾getInstance方法,來保證了,多個執行緒同時呼叫getInstance時,不會在記憶體中建立多個LazySingle物件,即保證了它的執行緒的安全性。但是由於每次呼叫都會獲取鎖,所以會造成效能上的損耗。

面試官的切入點

面試官可能在問你懶漢式的同時,讓你介紹一下synchronized關鍵字的相關知識點。 一旦提到synchronized這個關鍵字,那就不是一篇文章能夠講清楚的,這裡只提一下,他可能涉及到的知識:

  1. 在多執行緒中,通過鎖不同的物件,來保證執行緒的執行順序。
  2. 鎖的目標,可以是物件,方法,以及class類。
  3. 在位元組碼中,通過ACC_SYNCHRONIZED,以及monitorenter和 monitorexit來實現。
  4. 鎖的四種狀態,無鎖,偏向鎖,輕量級鎖,重量級鎖
  5. 如何實現上述這四種鎖(這四種鎖究竟是如何實現的)
  6. 鎖的升級過程(markword中如何記錄偏向鎖,輕量級鎖,重量級鎖)

這裡著重介紹下位元組碼中如何實現,以及鎖的升級過程(文章最後的圖片)。

這個是上面懶漢式的位元組碼,可以看到synchronized修飾方法時,在位元組碼中變成了ACC_SYNCHRONIZED標記。後面還會看到synchronized修飾物件時,位元組碼中變成monitorenter和monitorexit位元組碼指令。 public static synchronized single.LazySingle getInstance(); descriptor: ()Lsingle/LazySingle; flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED Code: stack=2, locals=0, args_size=0 0: getstatic #33 // Field sLazy:Lsingle/LazySingle; 3: ifnonnull 16 6: new #34 // class single/LazySingle 9: dup 10: invokespecial #39 // Method "<init>":()V 13: putstatic #33 // Field sLazy:Lsingle/LazySingle; 16: getstatic #33 // Field sLazy:Lsingle/LazySingle; 19: areturn LineNumberTable: line 21: 0 line 22: 6 line 25: 16 StackMapTable: number_of_entries = 1 frame_type = 16 /* same */ } SourceFile: "LazySingle.java"

懶漢式涉及的知識點:

  1. 懶漢式如何保證執行緒安全
  2. synchronized相關知識

不推薦的原因:

每次獲取物件時,都要獲取物件鎖。浪費效能。


雙重檢查

如果在面試過程中,被面試官問到雙重檢查單例。那麼volatile一定會成為一個考點。 我們先看一下下面這段程式碼

```java /* * @author jtl * @date 2021/7/20 11:46 * 雙重檢查模式單例 * 優點:執行緒安全 * 缺點:反射可以破壞單例 * 注意:需加volatile,因為 new操作本身不是執行緒安全的。重排序會出現問題 /

public class DCLSingle { private static volatile DCLSingle sDCLSingle; private int price = 8000; private DCLSingle() { System.out.println("雙重檢查模式:" + Thread.currentThread().getName()); }

public static DCLSingle getInstance() {
    if (sDCLSingle == null){
        synchronized (DCLSingle.class){
            if (sDCLSingle ==null){
                sDCLSingle = new DCLSingle();
            }
        }
    }

    return sDCLSingle;
}

} ```

面試的切入點

DCL(Double Check Lock),這個模式其實是推薦的一種模式。它既保證了執行緒安全性,又保證了延時載入(建立物件)。但是這裡有一個關鍵字volatile,當面試官問你DCL的時候,就意味著他可能要問你下面幾個問題: 1. 對volatile熟悉嘛? 2. 這裡的volatile起到了什麼作用? 3. volatile還有其他的功能嗎?

在講volatile前,想問大家一個問題,當我們執行new關鍵字,建立一個物件的時候。在JVM中或者說,在位元組碼層面究竟是什麼樣的? 如果你跟我說,我這天天都在寫功能,誰會在意位元組碼什麼樣啊。那麼也沒問題,你沒看過,那我給你準備好了。下面這段就是上面那段程式碼的位元組碼。讓我們一起看一下。

下面是上面DCL單例中getInstanch方法的位元組碼。讓我們看下當執行new物件操作的時候。位元組碼中到底都有哪些指令。 { public static single.DCLSingle getInstance(); descriptor: ()Lsingle/DCLSingle; flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=0 0: getstatic #41 // Field sDCLSingle:Lsingle/DCLSingle; 3: ifnonnull 37 6: ldc #42 // class single/DCLSingle 8: dup 9: astore_0 10: monitorenter // monitorenter指令獲取鎖 11: getstatic #41 // 將sDCLSingle壓入運算元棧 14: ifnonnull 27 17: new #42 // ① 申請記憶體建立物件 20: dup 21: invokespecial #47 // ② 執行構造方法 Method "<init>":()V 24: putstatic #41 // 將sDCLSingle壓出運算元棧 27: aload_0 // ③ 賦值給sDCLSingle 28: monitorexit // monitorexit指令釋放鎖 29: goto 37 32: astore_1 33: aload_0 34: monitorexit 35: aload_1 36: athrow 37: getstatic #41 // Field sDCLSingle:Lsingle/DCLSingle; 40: areturn Exception table: from to target type 11 29 32 any 32 35 32 any }

指令重排序:

這裡要先普及一個知識,什麼是指令重排序
指令重排序:編譯器在不改變單執行緒程式的執行結果的前提下,可以將指令進行重新排序,以提高執行效率。

正常執行順序

我們從位元組碼中看到三個操作,①②③,正常情況下,位元組碼中我們程式碼的執行順序是: 1. ①申請記憶體建立物件,此時該示例中的price只賦了預設值0 2. ②執行構造方法,此時a會賦值成程式碼中的8。 3. ③將sDCLSingle例項物件指向①中建立的記憶體,這就意味著此時的sDCLSingle物件不為null

上面的執行順序也是正常的預設的執行順序。

重排序後的執行順序

但是有正常的執行順序,就意味著一定會有不正常的執行順序。

如果sDCLSingle中不使用volatile修飾的情況下,編譯器就可能為了優化,從而進行指令重排序。順序就可能從①②③,變成①③②

假設出現極端的狀況,指令變成了①③②。同時出現了兩個執行緒A和B同時執行getInstance操作。 第一個A執行緒在執行new物件時,由於指令重排序,正好執行到了①③操作。這時由於③操作給sDCLSingle賦了值,導致sDCLSingle物件不為null,但是由於沒有執行②,所以sDCLSingle物件中的price=0。恰巧這時的執行緒B執行了getInstance方法。由於sDCLSingle不為null,所以執行緒B直接獲取了,尚未執行初始化操作的sDCLSingle物件。本來price為8000,但是由於該物件還沒有執行操作②沒有設定初始值,執行緒B中的price為0。如果這是一個付款操作,那就變成了本來8000塊的商品,變成了0元購,這屬於妥妥的事故現場啊。

為了避免這種情況,我們的volatile就出場了,volatile的兩大特性: 1. 禁止指令重排序 2. 保證記憶體的可見性

禁止指令重排序,這點可以理解為,為了保證上述程式碼在編譯時順序永遠是①②③,而不會變成①③②。禁止編譯器進行指令重排序,以避免上述的情況。

volatile的可見性,這裡不做過多描述,感興趣的同學可以檢視下小虎牙童鞋的這篇volatile的文章或者上B站看下馬士兵老師的多執行緒的免費課程。講的比較詳細。

DCL涉及的知識點:

  1. volatile的相關知識
  2. 什麼是指令重排序

volatile知識的圖解

image.png

靜態內部類

靜態內部類這種單例,它之所以是執行緒安全的。原因就是JVM載入類的時候是執行緒安全的,我們在呼叫getInstance方法時,會載入Inner內部類,由於JVM保證了同一時間只能有一個執行緒載入相同的類,所以靜態內部類是執行緒安全的。

當我們呼叫HolderSingle.test1()方法時,由於不會建立HolderSingle物件,因此也不存在餓漢式單例的缺點。

```java /* * @author jtl * @date 2021/7/20 11:49 * 靜態內部類單例 * 優點:執行緒安全,因為類載入時是執行緒安全的 * 缺點:反射可以破壞單例 /

public class HolderSingle { private HolderSingle(){ System.out.println("靜態內部類單例:"+Thread.currentThread().getName()); }

public static HolderSingle test1() {
    System.out.println( "---測試程式碼---");
}

public static HolderSingle getInstance() {
    return Inner.sHolder;
}

private static class Inner{
    private static final HolderSingle sHolder = new HolderSingle();
}

} 但是靜態內部類,也有一個缺點就是,可以通過反射來獲取其例項。請看下面的程式碼。java /* * @author jtl * @date 2021/7/20 14:29 * 單例模式測試Test /

class Client { public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { // 靜態內部類,通過反射獲取例項 // 獲取HolderSingle類的構造器 Constructor holderConstructor = HolderSingle.class.getDeclaredConstructor(); // 獲取許可權,可以執行private方法 holderConstructor.setAccessible(true); // 執行構造器,建立物件 HolderSingle holder = holderConstructor.newInstance(null); System.out.println("單例物件:" + holder+"---hashCode:"+holder.hashCode()); } } ```

image.png 通過執行上述程式碼,可以輸出圖片中的語句。因此可以證明,我們可以通過反射獲取靜態內部類單例的物件例項。這與我們單例的概念不符合。因此這也算是他的一個缺點。不過話說回來,這只是較真的一種行為,畢竟都已經使用單例了,那我們肯定不會通過反射來獲取例項。

靜態單例的考點:

  1. 我們可以通過反射,獲取靜態內部類單例物件例項。即反射可以執行私有方法
  2. JVM本身會保證,類載入時的執行緒安全性

列舉單例

列舉單例,相對於上面幾種,可能是知道的比較少的一種單例寫法了。相比於前面幾種方式,它的優點是,完美解決了反射獲取物件例項的這一行為。 ```java /* * @author jtl * @date 2021/7/20 11:57 * 列舉單例模式,可防反射 /

enum EnumSingle { INSTANCE; } ``` 如果我們執行下面這段程式碼,想通過反射來獲取列舉的物件例項,會出現下圖這種情況。

``` /* * @author jtl * @date 2021/7/20 14:29 * 單例模式測試Test /

class Client { public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { // 測試列舉類單例,無法通過反射獲取, Cannot reflectively create enum objects Constructor enumSingleConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);//列舉的建構函式是有參的 enumSingleConstructor.setAccessible(true); EnumSingle enumSingle = enumSingleConstructor.newInstance(null); } } ``` image.png

進擊的面試官

這時候,聰明的面試官可能就要開啟追問模式了: 1. 你能不能跟我說說,為什麼列舉無法通過反射獲取例項呢? 2. 列舉的本質到底是什麼呢 3. 列舉的缺點是什麼,為什麼效能優化時,會建議使用註解來代替列舉

要回答第一個問題,看完下面這段,反射的相關程式碼你就該知道答案了 ```java public final class Constructor extends Executable { private ConstructorAccessor acquireConstructorAccessor() { Constructor<?> root = this.root; ConstructorAccessor tmp = root == null ? null : root.getConstructorAccessor(); if (tmp != null) { constructorAccessor = tmp; } else { // 型別為列舉時,直接拋異常 if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException("Cannot reflectively create enum objects");

        tmp = reflectionFactory.newConstructorAccessor(this);

        if (VM.isJavaLangInvokeInited())
            setConstructorAccessor(tmp);
    }

    return tmp;
}

} ``` 看完上面的程式碼,你就知道為啥Enum不能反射了吧,不是它不想,而是Java它根本不允許啊。

緊接著讓我們看下,Enum的真實面目: 讓我們看下,上述程式碼的位元組碼,你會驚奇的發現,好好的一個列舉,在編譯之後變成了一個繼承了Enum的一個class類。 openjdk version "19.0.1" 2022-10-18 OpenJDK Runtime Environment (build 19.0.1+10-21) OpenJDK 64-Bit Server VM (build 19.0.1+10-21, mixed mode, sharing) [email protected] single % javap EnumSingle.class Compiled from "EnumSingle.java" final class single.EnumSingle extends java.lang.Enum<single.EnumSingle> { public static final single.EnumSingle INSTANCE; public static single.EnumSingle[] values(); public static single.EnumSingle valueOf(java.lang.String); static {}; } 讓我們再來看一下Enum這個抽象類,究竟是何方神聖。這就是為什麼,我們在寫列舉的時候可以直接呼叫name等方法。

``` public abstract class Enum> implements Constable, Comparable, Serializable { private final String name; private final int ordinal;

protected Enum(String name, int ordinal) {
    this.name = name;
    this.ordinal = ordinal;
}

public final String name() {
    return name;
}

public final int ordinal() {
    return ordinal;
}

public String toString() {
    return name;
}

public final boolean equals(Object other) {
    return this==other;
}

} ``` 列舉中的小心思: 讓我們將目光轉移回編譯出的Enum程式碼。細心的小夥伴,可能已經發現了。這個INSTANCE是一個static final修飾的物件啊。這是不是就意味著,每有一個列舉就代表了在編譯之後會出現一個物件。這當然比註解佔記憶體了。

這就是為什麼Google推薦使用註解來取代列舉的原因。因為,每一個列舉編譯之後都會生成一個例項物件。而反觀註解,它的基本型別是什麼,他在記憶體中就佔多少記憶體。 image.png

回顧下列舉單例知識點

如果面試官提到列舉單例的話,那麼他可能想跟你聊的不是列舉單例,而是列舉的實質,大概就是下面這幾個問題: 1. 列舉單例相比於靜態類單例的優點是什麼嘞 2. 列舉的實質是什麼 3. 為什麼效能優化裡,會出現註解替代列舉的說法,其原因是什麼。


單例總結

簡簡單單的五種單例寫法裡,暗藏了多少殺機,看完這篇文章之後,我想面試官應該再也不想問你單例問題了。當然也有例外,如果他非要讓你講一下,synchronized在硬體方面是如何實現的。聽我一句勸,快跑,這個面試官可能是派大星,因為他大概率不是個正常人>.<

話說回來,再看下,單例都涉及哪些知識點:

  1. JVM載入類的機制
  2. volatile 原理
  3. synchronized 相關知識
  4. 靜態內部類是如何保證執行緒安全的
  5. 反射機制,列舉的真實面目,以及列舉消耗記憶體的原因

面試中單例的問題

現在你能回答下圖中的問題了嗎,如果全能回答上來的話,那麼恭喜你。如果還有一些疑問的話,你可能需要再看一遍 >.< 面試中單例的問題.png

2022年最後想說的

今天是2022年12月31日,即將過去的這一年裡,我們經歷了太多,俄烏戰爭經濟寒冬,網際網路大批裁員,疫情解封全員小洋人。在這種情況下,我們只能不斷的學習,來充實自己。希望在新的一年裡,大家都能找到更好的工作,生活的更加開心。

設計模式連線

23種設計模式的相關程式碼,目前還差幾種有時間會補全。Github23種設計模式的demo

鎖升級的過程

末尾引用網上的一張鎖膨脹過程的圖片,感興趣的可以看一下。

Java鎖的膨脹過程.png