Java單例模式的最佳實踐?

来源:https://www.cnblogs.com/mylibs/archive/2022/12/08/java-singleton-best-practice.html
-Advertisement-
Play Games

“讀過書,……我便考你一考。茴香豆的茴字,怎樣寫的?”——魯迅《孔乙己》 0x00 大綱 0x01 前言 最近在重溫設計模式(in Java)的相關知識,然後在單例模式的實現上面進行了一些較深入的探究,有了一些以前不曾註意到的發現,遂將其整理成文,以作後用。 單例模式最初的定義出現於《設計模式》(艾 ...


“讀過書,……我便考你一考。茴香豆的茴字,怎樣寫的?”——魯迅《孔乙己》

0x00 大綱

目錄

0x01 前言

最近在重溫設計模式(in Java)的相關知識,然後在單例模式的實現上面進行了一些較深入的探究,有了一些以前不曾註意到的發現,遂將其整理成文,以作後用。

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

其應用場景可以說是十分廣泛,尤其是在涉及到資源管理方面的代碼,像應用配置(實例)、部分工具類或工廠類、JDK里的Runtime等,都有出現單例模式的身影。

0x02 單例的正確性

探討單例模式有多少種實現方式的意義不是很大,因為單例模式的實現方式比茴字的寫法還多,但是正確的實現卻不多,我們不妨將重點放在如何保證單例的正確性上,從而尋求最佳實踐方案。

單例模式的關鍵在於如何保證“一個類僅有一個實例”。首先思考一下創建實例的方式有哪些?在Java語言裡面,有這幾種方式:new關鍵字、clone方法克隆、反序列化、反射。

new關鍵字

public class Main {
    public static void main(String[] args) {
        Singleton instance = new Singleton();
    }
}

如果要保證一個類是單例,則必須阻止用戶通過new關鍵字來隨意創建對象,最簡單粗暴的方法就是將構造方法私有化,然後提供一個靜態方法來進行實例的外部訪問:

public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() { }

    public static Singleton getInstance() {
        return instance;
    }
}

此時就不能在類的外部通過new來創建對象了。

clone方法克隆

clone方法是原型模式中創建複雜對象的方法,在Java中,clone方法是Object基類的方法,因此所有的類都會繼承該方法,但只有實現了Cloneable介面的類才能正常調用clone方法克隆對象實例,否則會拋出類型為CloneNotSupportedException的異常,單例的類要防止用戶通過clone方法克隆就不能實現Cloneable介面。

反序列化

在Java裡面,實現了Serializable介面的類可以通過ObjectOutputStream將其實例序列化,然後再通過ObjectInputStream進行反序列化,而在預設情況下,反序列之後得到的是一個新的實例,這就違背了單例的法則了。幸好JDK的開發人員也想到了這點,再Serializable介面的文檔中有這樣一段描述:

Classes that need to designate a replacement when an instance of it is read from the stream should implement this special method with the exact signature.

ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

意思就是在反序列化時可以通過在類裡面定義readResolve方法來指定反序列化時返回的對象,例如:

public class Singleton implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private static Singleton instance = new Singleton();

    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("Not Allowed.");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }

    private Object readResolve() throws java.io.ObjectStreamException {
        return getInstance();
    }
}

反射

聰明的你也許註意到了,上面的readResolve方法是private的。那麼它是怎麼被調用的呢?答案就是通過反射,想瞭解更詳細的調用過程可以去看看ObjectInputStream類源碼中的readOrdinaryObject方法。

通過反射可以無視private修飾符的限制調用類裡面的各種方法,也就是說用戶可以利用反射來調用我們的私有構造方法,像這樣:

public class Main {
    public static void main(String[] args) throws Exception {
        // 這句代碼無法執行,因為我們的構造方法是private的
        // Singleton singleton = new Singleton();
        // 通過反射來創建實例
        java.lang.reflect.Constructor<Singleton> constructor;
        constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton = constructor.newInstance();
        // 兩個實例不一樣,單例完蛋
        if(singleton != Singleton.getInstance()) {
            System.out.println("哦嚯,完蛋");
        }
    }
}

解決方法是在構造方法裡面判斷類的實例是否已經被創建過,如果已經創建過的,拋出異常從而阻止反射調用。把單例類的代碼修改如下:

public class Singleton implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private static Singleton instance = new Singleton();
    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("Not Allowed.");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }

    /**
     * 顯式指定反序列化時返回的單例對象
     * @return
     * @throws java.io.ObjectStreamException
     */
    private Object readResolve() throws java.io.ObjectStreamException {
        return getInstance();
    }
}

再次通過反射進行對象創建時,就會拋出類型為RuntimeException的異常,從而阻止新實例的創建。

0x03 最佳實踐方案

可以看到,我們為了實現單例模式,加入了一大堆膠水代碼,用於保證其正確性,這一點都不簡潔。那麼有沒有更簡單更有效的方式呢?有,而且已經有人幫我們驗證過了。

Joshua Bloch在《Effective Java》一書中寫道:

使用枚舉實現單例的方法雖然還沒有廣泛採用,但是單元素的枚舉類型已經成為實現Singleton的最佳方法。

我們直接上代碼看看:

public enum EnumSingleton {
    INSTANCE;
    public void doSomething() {
        System.out.println("do something.");
    }
}

就是這麼簡單,再看看調用它的代碼:

public class Main {
    public static void main(String[] args) {
        EnumSingleton.INSTANCE.doSomething();
    }
}

使用枚舉實現單例模式,不僅代碼簡潔,而且可以輕鬆阻止用戶通過new關鍵字、clone方法克隆、反序列化、反射等方式創建重覆實例,還保證線程安全,這一切由JVM替你操辦,不需要添加額外代碼。

0x04 驗證測試

枚舉實現單例模式能不能保證上面的提到的各種屬性呢?我們用代碼逐一驗證一下:

public class Main {
    public static void main(String[] args) throws Exception {
        // TEST-1: 驗證是否單一實例
        EnumSingleton s1 = EnumSingleton.INSTANCE;
        EnumSingleton s2 = EnumSingleton.INSTANCE;
        if (s1.hashCode() != s2.hashCode()) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-1 PASSED.");
        }
        // TEST-2: 驗證反射創建
        java.lang.reflect.Constructor<EnumSingleton> constructor;
        // 註意這裡用的是枚舉的父構造器,因為我們沒有定義構造方法
        constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        boolean passed = false;
        try {
            EnumSingleton s3 = constructor.newInstance("NEW_INSTANCE", 2);
        } catch (Exception ex) {
            // 報錯說明反射不能創建
            passed = true;
        }
        if (!passed) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-2 PASSED.");
        }
        // TEST-3: 驗證反序列化
        EnumSingleton s4 = EnumSingleton.INSTANCE;
        EnumSingleton s5;
        try (java.io.ObjectOutputStream oos = new java.io.ObjectOutputStream(new java.io.FileOutputStream("EnumObject"))) {
            oos.writeObject(s4);
        }
        try (java.io.ObjectInputStream ois = new java.io.ObjectInputStream(new java.io.FileInputStream("EnumObject"))) {
            s5 = (EnumSingleton) ois.readObject();
        }
        if (s4.hashCode() != s5.hashCode()) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-3 PASSED.");
        }
        // TEST-4: 多線程測試
        java.util.concurrent.CountDownLatch begin = new java.util.concurrent.CountDownLatch(10);
        java.util.concurrent.CountDownLatch end = new java.util.concurrent.CountDownLatch(10);
        java.util.Set<EnumSingleton> set = new java.util.HashSet<>(1024);
        java.util.stream.IntStream.range(0, 20).forEach(
                i -> {
                    new Thread(() -> {
                        try {
                            begin.await();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        set.add(EnumSingleton.INSTANCE);
                        System.out.println(System.currentTimeMillis() + ":" + Thread.currentThread().getName() + "->" + EnumSingleton.INSTANCE.hashCode());
                        end.countDown();
                    }).start();
                    begin.countDown();
                }
        );
        end.await();
        if(set.size() != 1) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-4 PASSED.");
        }
    }
}

測試結果:

TEST-1 PASSED.
TEST-2 PASSED.
TEST-3 PASSED.
...
TEST-4 PASSED.

0x05 真的是最佳實踐嗎

在 Java Language Specification 枚舉類型這一章節中,具體闡述了若幹點對於枚舉類型的強制和隱性約束:

An enum declaration specifies a new enum type, a special kind of class type.

It is a compile-time error if an enum declaration has the modifier abstract or final.

An enum declaration is implicitly final unless it contains at least one enum constant that has a class body (§8.9.1).

A nested enum type is implicitly static. It is permitted for the declaration of a nested enum type to redundantly specify the static modifier.

This implies that it is impossible to declare an enum type in the body of an inner class (§8.1.3), because an inner class cannot have static members except for constant variables.

It is a compile-time error if the same keyword appears more than once as a modifier for an enum declaration.

The direct superclass of an enum type E is Enum (§8.1.4).

An enum type has no instances other than those defined by its enum constants. It is a compile-time error to attempt to explicitly instantiate an enum type (§15.9.1).

其中最為突出和有影響是以下兩點:

不能顯式繼承

和常規類一樣,枚舉可以實現介面,並提供公共實現或每個枚舉值的單獨實現,但不能繼承,因為所有的枚舉預設隱式繼承了Enum<E>類型,不能繼承也就意味著喪失了一部分的抽象能力(不能定義abstract方法),雖然可以通過組合的方式變通實現,但這無疑犧牲了擴展性和靈活性。

無法延遲載入

因為枚舉實例化的特殊性,所有的構造器屬性都必須在枚舉創建時指定,無法在運行時通過代碼動態傳遞和構造。

0x06 小結

非枚舉的單例實現除開少數極端場景,在大多數時候下也都夠用了,且保留了OOP的靈活特性,方便日後業務擴展,基於枚舉的單例實現有序列化和線程安全的保證,而且只要幾行代碼就能實現,不失為一種有效的方案,但並不無敵。具體的實現方案還是要根據業務背景和實際情況來進行選擇,畢竟,軟體工程沒有銀彈。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 本文分享自華為雲社區《GaussDB(DWS)字元串、二進位、十六進位互轉》,作者:你是猴子請來的救兵嗎 。 概述 現網中遇到很多小伙伴不清楚字元串與進位之間的轉換方法,其實在GaussDB(DWS)中,進位轉換是非常方便的。這次就來對不同的場景一一進行解析,整理出來供大家翻閱參考。 字元串&二進位 ...
  • 作者:謝澤華 背景 眾所周知單個機房在出現不可抗拒的問題(如斷電、斷網等因素)時,會導致無法正常提供服務,會對業務造成潛在的損失。所以在協同辦公領域,一種可以基於同城或異地多活機制的高可用設計,在保障數據一致性的同時,能夠最大程度降低由於機房的僅單點可用所導致的潛在高可用問題,最大程度上保障業務的用 ...
  • CDC CDC 是 Change Data Capture(變更數據獲取)的簡稱。核心思想是,監測並捕獲資料庫的變動(包括數據或數據表的插入、更新以及刪除等),將這些變更按發生的順序完整記錄下來,寫入到消息中間件中以供其他服務進行訂閱及消費。 CDC 的種類 CDC 主要分為基於查詢和基於 Binl ...
  • 分析服務 ◆ 游戲行業新增“區服分析”埋點模板及分析報告,支持開發者分伺服器查看用戶付費、留存等指標,可進一步評估不同伺服器的玩家質量; ◆ 新增營銷活動報告,可查看廣告任務帶來的曝光、點擊相關信息,讓營銷推廣活動的前端效果一目瞭然; ◆ 新增Web歸因及會話級歸因,以及帶來用戶流量後的行為分析,滿 ...
  • 前幾天在B站刷到尼爾後突發奇想,就想給尼爾做一個簡單的小網站,在思考如何體現尼爾的世界觀的時候想到了使用時間線的方式,將所有時間的事件羅列起來。所以就試著做了一下,這種方式可以很直觀的表現一些歷史上發生的事情,歷史相關主題的一些網站應該可以參考一下 首先來看效果 以上都是游戲里的一些歷史,簡單的設計 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 本篇文章主要總結了微信小程式開發,獲取用戶信息的整個流程步驟。補充了網上很多碎片化的代碼,本人梳理了思路寫下了這篇文章。 思路 1、在js文件中,設置userinfo、hasUserinfo、canIUseGetUserProfile數據 ...
  • ##canvas自適應文字長度,旋轉角度生成水印背景圖 設置canvas字體大小後,通過ctx.measureText(text).width獲取兩行文字的寬度text1,text2,取最大寬度為文本框寬度textWidth 設置兩行文字間距,可得文本框高度:textHeight=2*fontsiz ...
  • 我們如果要在伺服器上發佈https前端應用和WebAPI的應用,那麼我們就需要用到https證書了。我們一般發佈的應用的雲伺服器上,都會提供一定量的相關的免費證書(一般為20個)供我們使用,每個一年期限,到期再續即可,一般情況下基本上滿足要求了,本篇隨筆介紹如何基於雲服務提供商的免費證書,在伺服器上... ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...