設計模式學習筆記(四)單例模式的實現方式和使用場景

單例模式可以說是Java中最簡單的設計模式,也是技術面試中頻率極高的面試題。因為它不僅涉及到設計模式,還包括了關於線程安全、內存模型、類加載等機制。所以下面就來分別從單例模式的實現方法和應用場景來介紹一下單例模式

一、單例模式介紹

1.1 單例模式是什麼

單例模式也就是指在整個運行時域中,一個類只能有一個實例對象。

那麼為什麼要有單例模式呢?這是因為有的對象的創建和銷毀開銷比較大,比如數據庫的連接對象。所以我們就可以使用單例模式來對這些對象進行復用,從而避免頻繁創建對象而造成大量的資源開銷。

1.2 單例模式的原則

為了到達單例這個全局唯一的訪問點的效果,必須要讓單例滿足以下原則:

  1. 阻止類被通過常規方法實例化(私有構造方法)
  2. 保證實例對象的唯一性(以靜態方法或者枚舉返回實例)
  3. 保證在創建實例時的線程安全(確保多線程環境下實例只有一個)
  4. 對象不會被外界破壞(確保在有序列化、反序列化時不會重新構建對象)

二、單例模式的實現方式

關於單例模式的寫法,網上歸納的已經有很多,但是感覺大多數只是列出了寫法,不去解釋為什麼這樣寫的好處和原理。我偶然在B站看了寒食君歸納的單例模式總結思路還不錯,故這裡借鑒他的思路來分別說明這些單例模式的寫法。

按照單例模式中是否線程安全、是否懶加載和能否被反射破壞可以分為以下的幾類

2.1 懶加載

2.1.1 懶加載(線程不安全)

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**初始化對象為null**/
    private static Singleton instance = null;

    public static Singleton getInstance() {
        //判斷是否被構造過,保證對象的唯一
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

從上面我們可以看到,通過public class Singleton我們可以全局訪問該類;通過私有化構造方法,能夠避免該對象被外界類所創建;以及後面的getInstance方法能夠保證創建對象實例的唯一。

但是我們可以看到,這個實例不是在程序啟動後就創建的,而是在第一次被調用後才真正的構建,所以這樣的延遲加載也叫做懶加載

然而我們發現getInstance這個方法在多線程環境下是線程不安全的—如果有多個線程同時執行該方法會產生多個實例。那麼該怎麼辦呢?我們想到可以將該方法變成線程安全的,加上synchronized關鍵字。

2.1.2 懶加載(線程安全)

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**初始化對象為null**/
    private static Singleton instance;
    
	//判斷是否被構造過,保證對象的唯一,而且synchronize也能保證線程安全
    public synchronized static Singleton getInstance() {
        
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

但是我們知道,如果一個靜態方法被synchronized所修飾,會把當前類的class 對象鎖住,會增大同步開銷,降低程序的執行效率。所以可以從縮小鎖粒度角度去考慮,把synchronized放到方法裏面去,也就是讓其修飾同步代碼塊,如下所示:

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**初始化對象為null**/
    private static Singleton instance;
    
    public static Singleton getInstance() { 
        if (instance == null) {
            //利用同步代碼塊,鎖的是當前實例對象
            synchronized(Singleton.class) {
                instance = new Singleton();
            }
            
        }
        return instance;
    }
}

但是這個時候,我們發現if(instance == null)是沒有鎖的,所以當兩個線程都執行到該語句並都判斷為true時,還是會排隊創建新的對象,那麼有沒有新的解決方式?

2.1.3 懶加載(線程安全,雙重檢測鎖)

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**初始化對象**/
    private static Singleton instance;

    public static Singleton getInstance() {
        //第一次判斷
        if (instance == null) {
            synchronized (Singleton.class) {
                //第二次判斷
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

我們在上一節的代碼上再加上一次判斷,就是雙重檢測鎖(Double Checked Lock, DCL)。但是上述代碼也存在一些問題,比如在instance = new Singleton() 這行代碼中,它並不是一個原子操作,實際上是有三步:

  • 給對象實例分配內存空間

  • new Singleton() 調用構造方法,初始化成員字段

  • instance對象指向分配的內存空間

所以會涉及到內存模型中的指令重排,那麼這個時候可以用 volatile關鍵字來修飾 instance對象,防止指令重排,寫出如下代碼:

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**初始化對象,加上volatile防止指令重排**/
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        //第一次判斷
        if (instance == null) {
            synchronized (Singleton.class) {
                //第二次判斷
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

此外,我們也可以嘗試使用一些樂觀鎖的方式達到線程安全的效果,比如CAS。

2.1.4 懶加載(線程安全,CAS樂觀鎖)

public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
    private static Singleton instance;
    
    private Singleton(){}
    public static final Singleton getInstance() {
        for(;;) {
            Singleton instance = INSTANCE.get();
            if(instance != null) {
                return instance;
            }
            instance = new Singleton();
            if(INSTANCE.compareAndSet(null, instance)) {
                return instance;
            }
        }
    }
}

CAS 是一種樂觀鎖,依賴於底層硬件的實現,相對於鎖它沒有線程切換和阻塞的額外消耗,可以支持較大的並發度,但是如果忙等待一直執行不成功,也會對CPU造成較大的執行開銷。

2.2 餓漢(線程安全)

不同於懶加載的延遲實現實例,我們也可以在程序啟動時就加載好單例對象:

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**直接獲取實例對象**/
    private static Singleton instance = new Singleton();
    
    public static Singleton getInstance() {
        return instance;
    }
}

這樣的好處是線程安全,單例對象在類加載時就已經被初始化,當調用單例對象時只是把早已經創建好的對象賦值給變量。缺點就是如果一直沒有調用該單例對象的話,就會造成資源浪費。除此之外還有其他的實現方式。

2.3 靜態內部類

public class Singleton {
    /**保證構造方法私有,不被外界類所創建**/
    private Singleton() {}
    /**利用靜態內部類獲取單例對象**/
    private static class SingletonInstance {
        private static final Singleton instance = new Singleton();
    }

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

靜態內部類的方法結合了餓漢方式,它們都採用了類加載機制來保證當初始化實例時只有一個線程執行,從而保證了多線程下的安全操作。原因就是JVM在類初始化階段時會創建一個鎖,該鎖可以保證多個線程同步執行類初始化工作。

但是靜態內部類不會在程序啟動時創建單例對象,它是在外界調用 getInstance方法時才會裝載內部類,從而完成單例對象的初始化工作,不會造成資源浪費。

然而這種方法也存在缺點,它可以通過反射來進行破壞。下面就該提到枚舉方式了

2.4 枚舉

枚舉是《Effective Java》作者推薦的單例實現方式,枚舉只會裝載一次,無論是序列化、反序列化、反射還是克隆都不會新創建對象。因此它也不會被反射所破壞。

public class Singleton {
    INSTANCE;
}

所以這種方式是線程安全的,而且無法被反射而破壞

三、單例模式的應用場景

3.1 Windows 任務管理器

在一個windows 系統中只有一個任務管理器,這就是一種單例模式的應用。

3.2 網站的計數器

因為計數器的作用,就必須保證計數器對象保證唯一

3.3 JDK中的單例

3.3.1 java.lang.Runtime

Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the getRuntime method.

An application cannot create its own instance of this class.

每個java程序都含有唯一的Runtime實例,保證實例和運行環境相連接。當前運行時可以通過getRuntime方法獲得

我們來看看具體的代碼:

public class Runtime {
    private static Runtime currentRuntime = new Runtime();
    
    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {}

我們發現這就是單例模式的餓漢加載方式。

3.3.2 java.awt.Desktop

類似的,在java.awt.Desktop中也存在單例模式的使用,比如:

public class Desktop {

    private DesktopPeer peer;
    
    private Desktop() {
        peer = Toolkit.getDefaultToolkit().createDesktopPeer(this);
    }
	//懶加載
    public static synchronized Desktop getDesktop(){
        if (GraphicsEnvironment.isHeadless()) throw new HeadlessException();
        if (!Desktop.isDesktopSupported()) {
            throw new UnsupportedOperationException("Desktop API is not " +
                                                    "supported on the current platform");
        }

        sun.awt.AppContext context = sun.awt.AppContext.getAppContext();
        Desktop desktop = (Desktop)context.get(Desktop.class);

        if (desktop == null) {
            desktop = new Desktop();
            context.put(Desktop.class, desktop);
        }

        return desktop;
    }

這種方法就是一種延遲加載的方式。

3.4 Spring Bean 作用域

比較常見的就是Spring Bean作用域里的單例了,這個比較常見,可以通過配置文件進行配置:

<bean class="..."></bean>

參考資料

//www.zhihu.com/search?type=content&q=單例模式

//www.bilibili.com/video/BV1pt4y1X7kt?spm_id_from=333.337.search-card.all.click

//www.jianshu.com/p/137e65eb38ce