Java 單例模式從入門到入墳(全解全析)

代碼地址:https://github.com/gaohanghang/leetcode

一,什麼是單例模式

二,介紹

這兩個可以先不看,都是概念性的東西,直接看後面的就行,當然看了也可以 🐶

單例模式爲什麼那麼常問?

是因爲這個題目可以問到很多知識點。比如線程安全、類加載機制、synchronized 的原理、volatile 的原理、指令重排與內存屏障、枚舉的實現、反射與單例模式、序列化如何破壞單例、CAS、CAS 的 ABA 問題、Threadlocal 等知識。一般情況下,只需要從單例開始問起,大概就可以完成一場面試的整個流程,把想問的東西都問完,可以比較全面的瞭解一個面試者的水平。——

Java架構師聯盟

image.png

一,什麼是單例模式

單例模式即一個 JVM 內存中只存在一個類的對象實例。

image

https://refactoringguru.cn/design-patterns/singleton

單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。

這種模式涉及到一個單一的類,該類負責創建自己的對象,同時確保只有單個對象被創建。這個類提供了一種訪問其唯一的對象的方式,可以直接訪問,不需要實例化該類的對象。

注意:

  • 1、單例類只能有一個實例。
  • 2、單例類必須自己創建自己的唯一實例。
  • 3、單例類必須給所有其他對象提供這一實例。

二,介紹

意圖:保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。

主要解決:一個全局使用的類頻繁地創建與銷燬。

何時使用:當您想控制實例數目,節省系統資源的時候。

如何解決:判斷系統是否已經有這個單例,如果有則返回,如果沒有則創建。

關鍵代碼:構造函數是私有的。

應用實例:

  • 1、一個班級只有一個班主任。
  • 2、Windows 是多進程多線程的,在操作一個文件的時候,就不可避免地出現多個進程或線程同時操作一個文件的現象,所以所有文件的處理必須通過唯一的實例來進行。
  • 3、一些設備管理器常常設計爲單例模式,比如一個電腦有兩臺打印機,在輸出的時候就要處理不能兩臺打印機打印同一個文件。

優點:

  • 1、在內存裏只有一個實例,減少了內存的開銷,尤其是頻繁的創建和銷燬實例(比如管理學院首頁頁面緩存)。
  • 2、避免對資源的多重佔用(比如寫文件操作)。

缺點:沒有接口,不能繼承,與單一職責原則衝突,一個類應該只關心內部邏輯,而不關心外面怎麼樣來實例化。

使用場景:

  • 1、要求生產唯一序列號。
  • 2、WEB 中的計數器,不用每次刷新都在數據庫里加一次,用單例先緩存起來。
  • 3、創建的一個對象需要消耗的資源過多,比如 I/O 與數據庫的連接等。

注意事項:getInstance() 方法中需要使用同步鎖 synchronized (Singleton.class) 防止多線程同時進入造成 instance 被多次實例化。

三,實現

img

3.1 懶漢式,線程不安全(不推薦使用)

單例模式.002.jpeg

這種方式是最基本的實現方式,這種實現最大的問題就是不支持多線程。因爲沒有加鎖 synchronized,所以嚴格意義上它並不算單例模式。這種方式 lazy loading 很明顯,不要求線程安全,在多線程不能正常工作。

3.1.1 視頻講解

https://www.bilibili.com/video/BV1ff4y1X7v7

3.1.2 代碼

/**
 * @Description 懶漢式 , 線程不安全
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:07
 **/
public class Singleton {

    private static Singleton instance;

    // 構造器私有,其他類就無法通過new Singleton() 來創建對象實例了
    private Singleton() {
    }

    // 獲取實例的方法
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

3.2 懶漢式,線程安全(不推薦使用)

單例模式.003.jpeg

這種方式具備很好的 lazy loading,能夠在多線程中很好的工作,但是,效率很低,99% 情況下不需要同步。優點:第一次調用才初始化,避免內存浪費。缺點:必須加鎖 synchronized 才能保證單例,但加鎖會影響效率。getInstance() 的性能對應用程序不是很關鍵(該方法使用不太頻繁)。

3.2.1 視頻講解

https://www.bilibili.com/video/BV17K4y1v7md

3.2.2 代碼

/**
 * @Description 懶漢式 , 線程安全
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:10
 **/
public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

3.3 餓漢式(推薦使用)

image.png

這種方式比較常用,但容易產生垃圾對象。優點:沒有加鎖,執行效率會提高。缺點:類加載時就初始化,浪費內存。它基於 classloader 機制避免了多線程的同步問題,不過,instance 在類裝載時就實例化,雖然導致類裝載的原因有很多種,在單例模式中大多數都是調用 getInstance 方法, 但是也不能確定有其他的方式(或者其他的靜態方法)導致類裝載,這時候初始化 instance 顯然沒有達到 lazy loading 的效果。

3.3.1 視頻講解

https://www.bilibili.com/video/BV1XD4y1m72M/

3.3.2 代碼

/**
 * @Description 餓漢式
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:12
 **/
public class Singleton {

    private static Singleton instance = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return instance;
    }

}

3.4 餓漢 變種

單例模式.005.jpeg

表面上看起來差別挺大,其實更第三種方式差不多,都是在類初始化即實例化instance。

3.4.1 視頻講解

https://www.bilibili.com/video/BV1yi4y137vP/

3.4.2 代碼

/**
 * @Description 餓漢,變種
 * 表面上看起來差別挺大,其實更第三種方式差不多,都是在類初始化即實例化instance。
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:14
 **/
public class Singleton {

    private static Singleton instance = null;

    static {
        instance = new Singleton();
    }

    private Singleton() {}

    public static Singleton getInstance() {
        return instance;
    }

}

3.5 靜態內部類(推薦使用)

image.png

這種方式能達到雙檢鎖方式一樣的功效,對靜態域使用延遲初始化,但實現更簡單。這種方式只適用於靜態域的情況,雙檢鎖方式可在實例域需要延遲初始化時使用。

3.5.1 視頻講解

https://www.bilibili.com/video/BV19A411Y7Mz

3.5.2 代碼

/**
 * @Description 靜態內部類
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:16
 **/
public class Singleton {

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

    private Singleton() {
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

}

3.6 枚舉(推薦使用)單例模式.007.jpeg

這種實現方式還沒有被廣泛採用,但這是實現單例模式的最佳方法。它更簡潔,自動支持序列化機制,絕對防止多次實例化。這種方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不僅能避免多線程同步問題,而且還自動支持序列化機制,防止反序列化重新創建新的對象,絕對防止多次實例化。不過,由於 JDK1.5 之後才加入 enum 特性,用這種方式寫不免讓人感覺生疏,在實際工作中,也很少用。

3.6.1 視頻講解

https://www.bilibili.com/video/BV1kK4y1v7HD

3.6.2 代碼

/**
 * @Description 枚舉
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:18
 **/
public enum  Singleton {

    INSTANCE;

    public void whateverMethod() {
        System.out.println("哈哈");
    }

}

測試類 Test.java

public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Singleton.INSTANCE.whateverMethod();
        // 簡單引用
        Singleton instance0 = Singleton.INSTANCE;
        Singleton instance1 = Singleton.INSTANCE;
        System.out.println("instance0===" + instance0.hashCode());
        System.out.println("instance1===" + instance1.hashCode());
        // 反射測試
        Class clazz = Singleton.class;
        Singleton instance2 = (Singleton) Enum.valueOf(clazz, "INSTANCE");
        Singleton instance3 = (Singleton) Enum.valueOf(clazz, "INSTANCE");
        System.out.println("instance2===" + instance2.hashCode());
        System.out.println("instance3===" + instance3.hashCode());
        // 序列化測試
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File("test")));
        oos.writeObject(instance0);
        oos.close();
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("test")));
        Singleton instance4 = (Singleton) ois.readObject();
        ois.close();
        ObjectInputStream ois1 = new ObjectInputStream(new FileInputStream(new File("test")));
        Singleton instance5 = (Singleton) ois1.readObject();
        ois1.close();
        System.out.println("instance4===" + instance4.hashCode());
        System.out.println("instance5===" + instance5.hashCode());
    }

}

運行結果:

哈哈
instance0===1927950199
instance1===1927950199
instance2===1927950199
instance3===1927950199
instance4===1927950199
instance5===1927950199

3.7 雙重檢驗鎖(推薦使用)

單例模式.008.jpeg

這種方式採用雙鎖機制,安全且在多線程情況下能保持高性能。getInstance() 的性能對應用程序很關鍵。

3.7.1 視頻講解

https://www.bilibili.com/video/BV1f5411a7sh/

3.7.2 代碼

package a0算法面試題.單例設計模式.單例模式的七種寫法.a7;

/**
 * @Description 雙重校驗鎖
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:19
 **/
public class Singleton {

    /*
        volatile 修飾,
        singleton = new Singleton() 可以拆解爲3步:
        1、分配對象內存(給singleton分配內存)
        2、調用構造器方法,執行初始化(調用 Singleton 的構造函數來初始化成員變量)。
        3、將對象引用賦值給變量(執行完這步 singleton 就爲非 null 了)。
        若發生重排序,假設 A 線程執行了 1 和 3 ,還沒有執行 2,B 線程來到判斷 NULL,B 線程就會直接返回還沒初始化的 instance 了。

        volatile 可以避免重排序。
     */
    private volatile static Singleton singleton;

    private Singleton() {
    }

    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

}

四,單例模式的實際例子

內容來自《設計模式(Java版)》

任務描述:在整個項目中需要一個共享訪問點或共享數據,例如,一個Web頁面上的計數器,可以不用把每次刷新都記錄到數據庫中,使用單例模式保持計數器的值,並確保是線程安全的。

下述代碼用於實現該任務描述,使用單例模式記錄訪問次數。

首先編寫一個單例模式類 GlobalNum,其代碼如下所示。

GlobalNum.java

**
**

public class GlobalNum {

    private static GlobalNum gn = new GlobalNum();

    private int num = 0;

    public static GlobalNum getInstance() {
        return gn;
    }

    public synchronized int getNum() {
        return ++num;
    }

}

上述代碼中創建一個餓漢式單例類GlobalNum,其中getNum()方法用於返回訪問次數,並且使用synchronized對該方法進行線程同步。

編寫一個測試代碼,用於訪問GlobalNum單例類,其代碼如下所示。

SingleDemo.java

**
**

public class SingleDemo {

    // 測試單例模式
    public static void main(String[] args) {
        // 創建線程A
        NumThread threadA = new NumThread("線程A");

        // 創建線程B
        NumThread threadB = new NumThread("線程B");

        // 啓動線程
        threadA.start();
        threadB.start();
    }

}

// 線程類
class NumThread extends Thread{

    private String threadName;

    public NumThread(String name) {
        threadName = name;
    }

    // 重新線程的 run 方法(線程任務)
    @Override
    public void run() {
        GlobalNum gnObj = GlobalNum.getInstance();

        for (int i = 0; i < 5; i++) {
            System.out.println(threadName + "第" + gnObj.getNum() + "次訪問!");
            try {
                this.sleep(1000); // 線程休眠1000毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}

上述代碼在主程序中創建兩個子線程,通過這兩個子線程演示對單例模式下唯一實例的訪問。因爲 GlobalNum 的對象是單例的,所以能夠統一地對線程訪問次數進行統計。由於上述代碼是多線程的,運行結果每次都有可能出現不同,可能的運行結果示意如下。

線程B第2次訪問!
線程A第1次訪問!
線程A第3次訪問!
線程B第4次訪問!
線程A第6次訪問!
線程B第5次訪問!
線程A第7次訪問!
線程B第8次訪問!
線程A第10次訪問!
線程B第9次訪問!

五,問題

5.1 問題1: 爲什麼構造函數要使用 private

image.png

構造器私有,其他類就無法通過 new Singleton() 來創建對象實例

/**
 * @Description 懶漢式 , 線程不安全
 * @Author Gao Hang Hang
 * @Date 2019-09-10 21:07
 **/
public class Singleton {

    private static Singleton instance;

    // 構造器私有,其他類就無法通過new Singleton() 來創建對象實例
    private Singleton() {
    }

    // 獲取實例的方法
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

5.1.1 視頻講解

視頻地址:https://www.bilibili.com/video/BV1X54y1S7F1

https://www.bilibili.com/video/BV1X54y1S7F1

5.2 問題2:雙重校驗鎖—爲什麼使用 volatile 和兩次判空校驗

1 爲什麼要進行兩次非空校驗?

第一個 if 判斷是爲了減少性能開銷

第二個 if 判斷是爲了避免生成多個對象實例。

2 爲什麼要用 volatile 關鍵字?

爲了禁止 JVM 的指令重排,指令重排會導致對象未初始化的情況,造成報錯

image.png

5.2.1 視頻講解

視頻地址:https://www.bilibili.com/video/BV1dA411a7qB

說的比較慢,聲音小也比較小,可以點去blibli觀看,選2倍速播放,再把音量🔊調大點

https://www.bilibili.com/video/BV1dA411a7qB

原文地址:雙重校驗鎖 --使用volatile和兩次判空校驗

5.3 問題3:單例模式中唯一實例爲什麼要用靜態?

5.3.1 視頻講解

https://www.bilibili.com/video/BV1TK4y1v71k/

因爲 getInstance() 是靜態方法,而靜態方法不能訪問非靜態成員變量,所以 instanc 必須是靜態成員變量

public static Singleton getInstance(){
}
那麼爲什麼 getInstance() 是靜態方法?

因爲構造器是私有的,程序調用類中方法只有兩種方式,

① 創建類的一個對象,用該對象去調用類中方法;

② 使用類名直接調用類中方法,格式“類名.方法名()”;

Singleton instance = Singleton.getInstance();

構造函數私有化後第一種情況就不能用,只能使用第二種方法。

// 構造器私有,其他類就無法通過 new Singleton() 來創建對象實例了
private Singleton() {
    
}
爲什麼要私有化構造器呢?

目的是禁止其他程序創建該類的對象

如果構造函數不是私有的,每個人都可以通過 new Singleton() 創建類的實例,因此不再是單例。根據定義,對於一個單例,只能存在一個實例。

5.4 問題4:單例模式中成員變量爲什麼一定要是私有的private

public class Singleton {

    public static Singleton singleton = new Singleton();

    private Singleton() {
    }

    public static void main(String[] args) {
        Singleton.singleton = null;
        System.out.println(Singleton.singleton);
    }

}

image.png

運行結果爲null;

上面可以看做是一個單例模式,下面是調用該類並將單例的成員變量改成null。

萬一有程序員這麼做了,後面的程序員再用這個類時就是空,所以爲了安全不要這麼寫

5.5 問題5:餓漢式和懶漢式的區別?

懶漢式:先天性線程不安全,當真正需要該實例的時候纔去加載,需要我們自己人爲上鎖控制線程安全問題。

餓漢式:先天性線程安全,當我們項目在啓動的時候創建該實例,會導致項目啓動比較慢

5.6 問題6:靜態內部類與雙重校驗鎖的區別?

靜態內部類使用靜態關鍵字去保證我們實例是單例的。

而我們的雙重校驗鎖採用 lock 鎖保證安全的。

5.7 問題7:爲什麼靜態內部類寫法中,靜態類裏面獲取單例對象要用 final 修飾?

用 final 更多的意義在於提供語法約束。畢竟你是單例,就只有這一個實例,不可能再指向另一個實例。instance有了 final 的約束,後面再有人不小心編寫了修改其指向的代碼就會報語法錯誤。

這就好比 @Override 註解,你能保證寫對方法名和參數,那不寫註解也沒問題,但是有了註解的約束,編譯器就會幫你檢查,還能防止別人亂改—— 公衆號《Java課代表》作者

5.8 問題8:單例餓漢式爲什麼沒有線程安全性問題?

在 getInstance() 獲取實例的方法中,沒有對資源進行非原子性操作,instance 在類加載過程中就實例化了

Java的併發編程:線程的安全性問題的分析 這篇文章,我們知道,線程的安全性問題要滿足下面三個條件:

  • 多線程環境下
  • 多個線程共享一個資源
  • 對資源進行非原子性操作

而對於單例餓漢式確不滿足第三個條件,我們可以用下面的Java程序示例來看一下(實現是創建一個餓漢式的類,再用20個線程去調用):

 public class Singleton {
     
    // 私有化構造方法
    private Singleton () {}
 
    private static Singleton instance = new Singleton();
     
    public static Singleton getInstance() {
        return instance;
    }
     
    // 多線程的環境下
    // 必須有共享資源
    // 對資源進行非原子性操作
      
}

在 getInstance() 獲取實例的方法中,沒有對資源進行非原子性操作,我們所獲取的對象都是一樣的,可以用一個MultiThreadMain.java 方法來看一下,創建20個線程去調用:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MultiThreadMain {

    public static void main(String[] args) {

        ExecutorService threadPool = Executors.newFixedThreadPool(20);

        for (int i = 0; i < 20; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + ":" +Singleton.getInstance());
                }
            });
        }

        threadPool.shutdown();

    }
}

image.png

結果是:

pool-1-thread-14:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-15:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-20:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-16:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-18:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-8:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-11:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-2:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-4:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-6:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-1:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-5:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-9:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-13:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-12:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-19:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-7:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-3:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-10:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8
pool-1-thread-17:a0算法面試題.單例設計模式.單例模式的七種寫法.a3.Singleton@342c15c8

可以看到,對於餓漢式的單例模式,是沒有線程安全性問題的。但是餓漢式會造成對資源的浪費,比如說我沒有調用這個 Singleton 類的時候,它已經創建好給我們了。

類加載過程的線程安全性保證

餓漢、靜態內部類、枚舉均是通過定義靜態的成員變量,以保證單例對象可以在類初始化的過程中被實例化。

這其實是利用了 ClassLoader 的線程安全機制。ClassLoader 的 loadClass 方法在加載類的時候使用了 synchronized 關鍵字。

所以, 除非被重寫,這個方法默認在整個裝載過程中都是線程安全的。所以在類加載過程中對象的創建也是線程安全的。

枚舉其實底層是依賴 Enum 類實現的,這個類的成員變量都是 static 類型的,並且在靜態代碼塊中實例化的,和餓漢有點像, 所以他天然是線程安全的,所以,枚舉其實也是藉助了synchronized的

5.9 問題9:以下哪種方式實現的單例是線程安全的

A. 枚舉

B. 靜態內部類

C. 雙檢鎖模式

D. 餓漢式

正確答案:A B C D

第一種:餓漢模式(線程安全)

第二種:懶漢模式 (如果方法沒有synchronized,則線程不安全)

第三種:懶漢模式改良版(線程安全,使用了double-check,即check-加鎖-check,目的是爲了減少同步的開銷)

第四種:利用私有的內部工廠類(線程安全,內部類也可以換成內部接口,不過工廠類變量的作用域要改爲public了。)

5.10 問題10:怎麼不使用 synchronized 和 lock 實現一個線程安全的單例嗎?

以上實現主要用到了兩點來保證單例,一是JVM的類加載機制,另一個就是加鎖了。那麼有沒有不加鎖的線程安全的單例實現嗎?

5.10.1 CAS實現單例

5.10.1.1 什麼是 CAS?

CAS 是一項樂觀鎖技術,當多個線程嘗試使用 CAS 同時更新一個變量時,只有其中一個線程能更新成功,而其它線程都失敗,失敗的線程並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。

5.10.1.2 代碼實現

CAS 實現單例:

public class Singleton {

    // AtomicReference 提供了可以原子的讀寫對象引用的一種機制
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();

    // 私有化構造器
    private Singleton() {
    }

    // 獲取實例的 getInstance() 方法
    public static Singleton getInstance() {
        for(;;) {
            // 從 INSTANCE中 獲取實例
            Singleton singleton = INSTANCE.get();
            // 如果實例不爲空就返回
            if (null != singleton) {
                return singleton;
            }
            // 實例爲空就創建實例
            singleton = new Singleton();
            // compareAndSet() 主要的作用是通過比對兩個對象,然後更新爲新的對象
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }

}

以下爲測試結果:可以看出都是相同的實例

image.png

5.10.1.3 使用 CAS 實現的單例有沒有什麼優缺點呀?

優點:

用 CAS 的好處在於不需要使用傳統的鎖機制來保證線程安全,CAS 是一種基於忙等待的算法,依賴底層硬件的實現,相對於鎖它沒有線程切換和阻塞的額外消耗,可以支持較大的並行度。

缺點:

CAS的一個重要缺點在於如果忙等待一直執行不成功(一直在死循環中),會對 CPU 造成較大的執行開銷。

另外,代碼中,如果 N 個線程同時執行到 singleton = new Singleton(); 的時候,會有大量對象被創建,可能導致內存溢出。

5.10.2 使用 ThreadLocal 實現“單例”模式

標題中單例之所以帶着雙引號,是因爲並不能保證整個應用全局唯一,但是可以保證線程唯一。

5.10.2.1 ThreadLocal是什麼?

ThreadLocal 會爲每一個線程提供一個獨立的變量副本,從而隔離了多個線程對數據的訪問衝突。對於多線程資源共享的問題,同步機制( synchronized )採用了“以時間換空間”的方式,而 ThreadLocal 採用了“以空間換時間”的方式。

同步機制僅提供一份變量,讓不同的線程排隊訪問,而 ThreadLocal 爲每一個線程都提供了一份變量,因此可以同時訪問而互不影響。

5.10.2.2 代碼實現

使用 ThreadLocal 實現單例模式:

public class Singleton {

    private static final ThreadLocal<Singleton> singleton = new ThreadLocal<Singleton>() {
        @Override
        protected Singleton initialValue() {
            return new Singleton();
        }
    };

    // 私有化構造器
    private Singleton(){
    }

    // 獲取實例的方法
    public static Singleton getInstance() {
        return singleton.get();
    }

}

測試類:

public class Test {

    public static void main(String[] args) {
        System.out.println("main thread "+Singleton.getInstance());
        System.out.println("main thread "+Singleton.getInstance());
        System.out.println("main thread "+Singleton.getInstance());

        Thread thread0 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ":" + Singleton.getInstance());
            }
        });
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ":" + Singleton.getInstance());
            }
        });
        thread0.start();
        thread1.start();
    }
}

結果:

**
**

image.png

兩個線程(線程0和線程1)拿到的對象並不是同一個對象,但是同一線程能保證拿到的是同一個對象,即線程單例。

ThreadLocal 這種寫法主要是考察面試者對於 ThreadLocal 的理解,以及是否可以把知識活學活用,但是實際上,這種所謂的"單例",其實失去了單例的意義..

六,破壞單例模式的方式

img

  1. 反射
  2. 序列和反序列化

這裏我以靜態內部類單例來舉例,先看下靜態內部類單例的代碼

public class Singleton {

    // 靜態內部類
    private static class SingletonHolder {
        private static final  Singleton INSTANCE = new Singleton();
    }

    // 私有的構造方法
    private Singleton() {
    }

    // 公有的獲取實例方法
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

}

6.1 反射破壞單例模式

我們來看代碼

public class Test {

    public static void main(String[] args) {
        try {
            // 很無聊的情況下,進行破壞
            Class<?> clazz = Singleton.class;
            // 通過反射拿到私有的構造方法
            Constructor c = clazz.getDeclaredConstructor();
            // 因爲要訪問私有的構造方法,這裏要設爲true,相當於讓你有權限去操作
            c.setAccessible(true);
            // 暴力初始化
            Object o1 = c.newInstance();
            // 調用了兩次構造方法,相當於 new 了兩次
            Object o2 = c.newInstance();
            // 這裏輸出結果爲false
            System.out.println(o1 == o2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

輸出爲false,說明內存地址不同,就是實例化了多次,破壞了單例模式的特性。

image.png

6.2 防止反射破壞單例模式

通過上面反射破壞單例模式的代碼,我們可以知道,反射也是通過調用構造方法來實例化對象,那麼我們可以在構造函數裏面做點事情來防止反射,我們把靜態內部類單例的代碼改造一下,看代碼

public class Singleton {

    // 靜態內部類
    private static class SingletonHolder {
        private static final  Singleton INSTANCE = new Singleton();
    }

    // 私有的構造方法
    private Singleton() {
        // 防止反射創建多個對象
        if(SingletonHolder.INSTANCE != null){
            throw new RuntimeException("不允許創建多個實例");
        }
    }

    // 公有的獲取實例方法
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

}

這樣我們在通過反射創建單例對象的時候,多次創建就會拋出異常

image.png

6.3 序列化破壞單例模式

用序列化的方式,需要在靜態內部類(Singleton) 實現 Serializable 接口,代碼在下面的防止序列化破壞單例模式裏面

這裏我們先來看下序列和反序列的代碼

public class Test {

    public static void main(String[] args) {

        Singleton s1 = null;
        //通過類本身獲得實例對象
        Singleton s2 = Singleton.getInstance();
        FileOutputStream fos = null;

        try {
            // 序列化到文件中
            fos = new FileOutputStream("SeriableSingleton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(s2);
            oos.flush();
            oos.close();

            // 從文件中反序列化爲對象
            FileInputStream fis = new FileInputStream("SeriableSingleton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            s1 = (Singleton) ois.readObject();
            ois.close();
            // 對比結果,這裏輸出的結果爲false
            System.out.println(s1 == s2);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

}

結果爲 false,說明也破壞了單例模式

image.png

6.4 防止序列化破壞單例模式

這裏我們先來看下改造後的代碼,然後分析原理

public class Singleton implements Serializable {

    private static final long serialVersionUID = -4264591697494981165L;

    // 靜態內部類
    private static class SingletonHolder {
        private static final  Singleton INSTANCE = new Singleton();
    }

    // 私有的構造方法
    private Singleton() {
        // 防止反射創建多個對象
        if(SingletonHolder.INSTANCE != null){
            throw new RuntimeException("不允許創建多個實例");
        }
    }

    // 公有的獲取實例方法
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

    // 防止序列化創建多個對象,這個方法是關鍵
    private Object readResolve(){
        return SingletonHolder.INSTANCE;
    }

}

image.png

在執行上面序列和反序列化代碼,輸出 true,是不是一臉懵逼,爲什麼加了一個 readResolve 方法,就能防止序列化破壞單例模式,下面就帶着大家來看下序列化的源碼:

public final Object readObject()throws IOException, ClassNotFoundException{
    if (enableOverride) {
        return readObjectOverride();
    }
    // if nested read, passHandle contains handle of enclosing object
    int outerHandle = passHandle;
    try {
        // 看這裏,看這裏,就是我readObject0
        Object obj = readObject0(false);
        handles.markDependency(outerHandle, passHandle);
        ClassNotFoundException ex = handles.lookupException(passHandle);
        if (ex != null) {
            throw ex;
        }
        if (depth == 0) {
            vlist.doCallbacks();
        }
        return obj;
    } finally {
        passHandle = outerHandle;
        if (closed && depth == 0) {
            clear();
        }
    }
}

然後我們看下 readObject0 這個方法

 private Object readObject0(boolean unshared) throws IOException {
    ...
    //主要是這個判斷
    case TC_OBJECT:
        //然後進入readOrdinaryObject這個方法
        return checkResolve(readOrdinaryObject(unshared));
    ...
}

然後我們看下readOrdinaryObject 這個方法

 private Object readOrdinaryObject(boolean unshared)throws IOException{
        ...
        Object obj;
        try {
            // 這裏判斷是否有無參的構造函數,有的話就調用newInstance()實例化對象
            obj = desc.isInstantiable() ? desc.newInstance() : null; 
        ...
        if (obj != null &&
            handles.lookupException(passHandle) == null &&
            desc.hasReadResolveMethod())
        {
            Object rep = desc.invokeReadResolve(obj);
          ...
    }

這裏的關鍵是 desc.hasReadResolveMethod() ,這段代碼的意思是查看你的單例類裏面有沒有 readResolve 方法,有的話就利用反射的方式執行這個方法,具體是 desc.invokeReadResolve(obj) 這段代碼,返回單例對象。這裏其實是實例化了兩次,只不過新創建的對象沒有被返回而已。如果創建對象的動作發生頻率增大,就意味着內存分配開銷也就隨之增大,這也算是一個缺點吧

七,枚舉實現單例的原理

這種方式是 Effective Java 作者 Josh Bloch 提倡的方式,他認爲單元素的枚舉類型被作者認爲是實現 Singleton 的最佳方法。這種方式不僅能避免多線程同步問題,而且還自動支持序列化機制,防止反序列化和反射攻擊重新創建新的對象,絕對防止多次實例化。

/**
 * 單例模式的枚舉方式實現
 **/
public enum Singleton {

    INSTANCE;

    public void whateverMethod() {
        //do what you want
    }
    
}

這種方式的原理是什麼呢?趁這個機會在這裏好好梳理一下枚舉的概念。

枚舉是 JDK5 中提供的一種語法糖,所謂語法糖就是在計算機語言中添加的某種語法,這種語法對語言的功能並沒有影響,但是但是更方便程序員使用。只是在編譯器上做了手腳,卻沒有提供對應的指令集來處理它。

其實 Enum 就是一個普通的類,它繼承自 java.lang.Enum 類,這個可以通過反編譯枚舉類的字節碼來理解。

使用 javac Singleton.java 得到字節碼文件 Singleton.class 使用 javap Singleton.class 反解析字節碼文件可以得到下面的內容:

public final class Singleton extends java.lang.Enum<Singleton> {
  
    public static final Singleton INSTANCE;
    
    public static Singleton[] values();
    
    public static Singleton valueOf(java.lang.String);
 
    public void whateverMethod();
  
    static {};
    
}

image.png

枚舉其實底層是依賴Enum類實現的,這個類的成員變量都是 static 類型的,並且在靜態代碼塊中實例化的,和餓漢有點像, 所以他天然是線程安全的。

—— https://zhuanlan.zhihu.com/p/140479178

javap 是 jdk 自帶的反解析工具。它的作用就是根據 class 字節碼文件,反解析出當前類對應的 code 區(彙編指令)、本地變量表、異常表和代碼行偏移量映射表、常量池等等信息。

由反編譯後的代碼可知,INSTANCE 被聲明爲 static 的,虛擬機會保證一個類的 <clinit>()方法在多線程環境中被正確的加鎖、同步。所以,枚舉實現在實例化時是線程安全。

另外 Java 規範中規定,每一個枚舉類型及其定義的枚舉變量在 JVM 中都是唯一的,因此在枚舉類型的序列化和反序列化上,Java 做了特殊的規定。在序列化的時候 Java 僅僅是將枚舉對象的 name 屬性輸出到結果中,反序列化的時候則是通過 java.lang.Enum 的 valueOf() 方法來根據名字查找枚舉對象,因此反序列化後的實例也會和之前被序列化的對象實例相同。

八,總結

不建議使用懶漢式,簡單的可以使用餓漢式。涉及到反序列化創建對象時可以使用枚舉方式。如果考慮到延遲加載 的話,可以採用靜態內部類 Holder 的模式。如果對業務需求有特殊要求的時候可以採用雙檢查鎖的單例。

九,Spring 單例 bean 與單例模式的區別

Spring 的單例是相對於容器的,而平常說的單例是相對於JVM的。

一個 JVM 可以有多個 Spring 容器,而且 Spring 中的單例也只是按 bean 的 id 來區分的

Spring 單例 bean 與單例模式的區別在於它們關聯的環境不一樣,單例模式是指在一個JVM 進程中僅有一個實例,而 Spring 單例是指一個 Spring bean 容器 (ApplicationContext) 中相同 id 的 bean 僅有一個實例。

首先看單例模式,在一個 JVM 進程中(理論上,一個運行的 JAVA 程序就必定有自己一個獨立的 JVM)僅有一個實例,於是無論在程序中的何處獲取實例,始終都返回同一個對象,以 Java 內置的 Runtime 爲例(現在枚舉是單例模式的最佳實踐),無論何時何處獲取,下面的判斷始終爲真:

//  基於懶漢模式實現
//  在一個JVM實例中始終只有一個實例
Runtime.getRuntime() == Runtime.getRuntime()

與此相比,Spring的單例 bean 是與其容器(ApplicationContext)密切相關的,所以在一個JVM進程中,如果有多個 Spring 容器,即使是單例 bean,也一定會創建多個實例,代碼示例如下:

//  第一個Spring Bean容器
ApplicationContext context_1 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
Person yiifaa_1 = context_1.getBean("yiifaa", Person.class);
//  第二個Spring Bean容器
ApplicationContext context_2 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
Person yiifaa_2 = context_2.getBean("yiifaa", Person.class);
//  這裏絕對不會相等,因爲創建了多個實例
System.out.println(yiifaa_1 == yiifaa_2);

以下是 Spring 的配置文件:

<!-- 即使聲明瞭爲單例,只要有多個容器,也一定會創建多個實例 -->
<bean id="yiifaa" class="com.stixu.anno.Person" scope="singleton">
    <constructor-arg name="username">
        <value>yiifaa</value>
    </constructor-arg>
</bean>

Spring 中的單例是按 bean 的 id 來區分的

在容器中每個 bean id 對應一個 bean。讓我們舉個例子來理解它。我們有一個 bean 類 Sample。我在 bean 定義中以此類定義了兩個 bean,例如:

<bean id="id1" class="com.example.Sample" scope="singleton">
        <property name="name" value="James Bond 001"/>    
</bean>    
<bean id="id7" class="com.example.Sample" scope="singleton">
        <property name="name" value="James Bond 007"/>    
</bean>

因此,當我嘗試獲取 ID 爲“id1”的 bean 時,spring 容器將創建一個 bean,對其進行緩存並在 id1 引用過的地方返回相同的 bean。如果我嘗試使用 id7 來獲取它,那麼將從 Sample 類創建另一個 bean,每次你使用 id7 引用該bean 時,都將對其進行緩存並返回。

這在單例模式中是不可能發生的。在單例模式中,總是爲每個類創建一個對象。但是在 Spring 中,將範圍設爲Singleton 並不會限制容器從該類創建許多實例。它只是限制了相同 ID 的新對象的再次創建,當一個對象被請求使用相同 ID 時返回了先前創建的對象。

更多內容可以看這篇文章 An Interview Question on Spring Singletons

十,參考資料

[轉+注]單例模式的七種寫法

單例模式

雙重校驗鎖 --使用volatile和兩次判空校驗

https://refactoringguru.cn/design-patterns/singleton

如何正確地寫出單例模式

單例模式中唯一實例爲什麼要用靜態?

面試官:說說對單例模式的理解,最後的枚舉實現我居然不知

單例模式中成員變量爲什麼一定要是私有的private

【Java】枚舉實現單例模式

Java 單例模式的兩種高效寫法

【單例深思】枚舉實現單例原理

關於Enum枚舉單例模式的實現

讓我們來破壞單例模式

Implementing Singleton with an Enum (in Java)

[枚舉實現單例的原理](https://junzhou2016.github.io/2018/08/04/枚舉實現單例原理 /)

爲什麼用枚舉實現的單例模式可以防止反序列化?

面試處處都是坑啊?讓實現線程安全的單例,又不讓用synchronized

折騰Java設計模式之單例模式

我向面試官講解了單例模式,他對我豎起了大拇指

Why are enum singleton serialization safe?

漫畫:如何給女朋友解釋什麼是單例模式?

Java併發編程——AtomicReference,解決併發修改多個屬性

“單例”模式-ThreadLocal線程單例

Spring單例Bean與單例模式的區別

連這些設計模式你都不知道,怎能說精通Spring呢?

An Interview Question on Spring Singletons

Singleton design pattern vs Singleton beans in Spring container

spring怎麼實現單例模式?

https://www.docs4dev.com/docs/zh/spring-framework/4.3.21.RELEASE/reference/beans.html#beans-factory-scopes-singleton

十一,最後

歡迎關注我的公衆號《駭客與畫家》

image.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章