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

Ethan_Wong發表於2022-03-26

單例模式可以說是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>

參考資料

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

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

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

相關文章