別再用懶漢模式了——從JVM的角度看單例模式

llldddbbb發表於2019-03-22

網上結論:

我們先來看看網上普遍的結論:

所謂“懶漢式”與“餓漢式”的區別,是在與建立單例物件的時間的不同。
“懶漢式”是在你真正用到的時候才去建這個單例物件
“餓漢式是在類建立的同時就已經建立好一個靜態的物件,不管你用的用不上,一開始就建立這個單例物件

先不說結論,看看下文

程式碼實現:

餓漢式

public class Singleton1 {

    private final static Singleton1 singleton = new Singleton();

    private Singleton1() {
        System.out.println("餓漢式單例初始化!");
    }
    public static Singleton1 getSingleton () {
        return singleton;
    }

}
複製程式碼

在類靜態變數裡直接new一個單例

懶漢式

public class Singleton2 {
    
    private volatile static Singleton2 singleton; // 5

    private Singleton2() {
        System.out.println("懶漢式單例初始化!");
    }
    public  static Singleton2 getInstance () {
        if(singleton ==null) {  // 1
            synchronized(Singleton2.class) {  // 2
                if(singleton == null) {  // 3
                    singleton =  new Singleton2(); //4
                }
            }
        }
        return singleton;
    }

}

複製程式碼

程式碼1 處的判空是為了減少同步方法的使用,提高效率
程式碼2,3 處的加鎖和判空是為了防止多執行緒下重複例項化單例。
程式碼5 處的volatile是為了防止多執行緒下程式碼4 的指令重排序

測試方法

建立一個Test測試類

 public class Test {
    public static void main(String[] args) throws IOException {
        // 懶漢式
        Singleton1 singleton1 = Singleton1.getInstance();
        // 餓漢式
        Singleton2 singleton2 = Singleton2.getInstance();
    }
}
複製程式碼

執行結果

image.png

從結果上看沒啥毛病,那我們來加個斷點試試。按照以往的認知,餓漢單例是在類載入的時候的例項化,那麼執行main方法應該會輸出餓漢單例的初始化,我們來看看結果:

public static void main(String[] args) throws IOException {
        System.in.read();
        // 餓漢式
        Singleton1 singleton1 = Singleton1.getInstance();
        // 懶漢式
        Singleton2 singleton2 = Singleton2.getInstance();
}
複製程式碼

此時執行結果:

image.png

如圖是沒有結果的,餓漢單例怎麼沒有例項化呢?原來餓漢單例是在本類載入的時候才例項化的,在斷點的時候還沒有載入餓漢單例。 我們來詳細複習一下類載入:

類的載入分為5個步驟:載入、驗證、準備、解析、初始化

初始化就是執行編譯後的< cinit>()方法,而< cinit>()方法就是在編譯時將靜態變數賦值和靜態塊合併到一起生成的。

所以說,“餓漢模式”的建立物件是在類載入的初始化階段進行的,那麼類載入的初始化階段在什麼時候進行呢?jvm規範規定有且只有以下7種情況下會進行類載入的初始化階段:

  • 使用new關鍵字例項化物件的時候
  • 設定或讀取一個類的靜態欄位(被final修飾,已在編譯器把結果放入常量池的靜態欄位除外)的時候
  • 呼叫一個類的靜態方法的時候
  • 使用java.lang.reflect包的方法對類進行反射呼叫的時候
  • 初始化一個類的子類(會首先初始化父類)
  • 當虛擬機器啟動的時候,初始化包含main方法的主類
  • 當使用jdk1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則需要先觸發其初始化。

  綜上,基本來說就是隻有當你以某種方式呼叫了這個類的時候,它才會進行初始化,而不是jvm啟動的時候就初始化,而jvm本身會確保類的初始化只執行一次。那如果不使用這個單例物件的話,記憶體中根本沒有Singleton例項物件,也就是和“懶漢模式”是一樣的效果

  當然,也有一種可能就是單例類裡除了getInstance()方法還有一些其他靜態方法,這樣當呼叫其他靜態方法的時候,也會初始化例項,但是這個很容易解決,只要加個內部類就行了:

public class Singleton {
    private static class LazyHolder {
       private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance () {
        return LazyHolder.INSTANCE;
    }
}
複製程式碼

總結

  網上的結論普遍說單例過早佔用資源,而推薦使用“懶漢模式”,但他們忽略了單例何時進行類載入,經過以上分析,“懶漢模式”實現複雜而且沒有任何獨佔優點,“餓漢模式”完勝。“餓漢模式”使用場景推薦:

  • 當單例類裡有其他靜態方法的時候,推薦使用靜態內部類的形式。
  • 當單例類裡只有getInstance()方法的時候,推薦直接new一個靜態的單例物件。

更新:

關於列舉類的:這裡做個測試:

public enum  SingletonEnum {
    INSTANCE;

    public SingletonEnum getInstance() {
        return INSTANCE;
    }

    SingletonEnum() {
        System.out.println("列舉類單例例項化啦");
    }

    public static void test() {
        System.out.println("測試呼叫列舉類的靜態方法");
    }
}
複製程式碼

測試類:


public static void main(String[] args) throws IOException {
        SingletonEnum.test();
        System.in.read();
        SingletonEnum singletonEnum=SingletonEnum.INSTANCE;

    }
複製程式碼

由此得出結論,列舉類的單例和普通的“餓漢模式”一樣,都是在類載入(呼叫靜態方法)的時候初始化。但是列舉類的另一個優點是能預防反射和序列化,因此再次得出結論

  • 當單例類裡有其他靜態方法的時候,推薦使用靜態內部類的形式。
  • 當單例類裡只有getInstance()方法的時候,推薦直接new一個靜態的單例物件。
  • 當需要防止反射和序列化破壞單例的時候,推薦用列舉類的單例模式

相關文章