設計模式學習(二):單例模式

2022-11-07 18:02:26

設計模式學習(二):單例模式

作者:Grey

原文地址:

部落格園:設計模式學習(二):單例模式

CSDN:設計模式學習(二):單例模式

單例模式

單例模式是建立型模式。

單例的定義:「一個類只允許建立唯一一個物件(或者範例),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式。」定義中提到,「一個類只允許建立唯一一個物件」。那物件的唯一性的作用範圍是指程序內只允許建立一個物件,也就是說,單例模式建立的物件是程序唯一的(而非執行緒)

為什麼要使用單例

  1. 處理資源存取衝突,比如寫紀錄檔的類,如果不使用單例,就必須使用鎖機制來解決紀錄檔被覆蓋的問題。

  2. 表示全域性唯一類,比如設定資訊類,在系統中,只有一個組態檔,當組態檔載入到記憶體中,以物件形式存在,也理所應當只有一份;唯一 ID 生成器也是類似的機制。如果程式中有兩個物件,那就會存在生成重複 ID 的情況,所以,我們應該將 ID 生成器類設計為單例。

餓漢式

類載入的時候就會初始化這個範例,JVM 保證唯一範例,執行緒安全,但是可以通過反射破壞

方式一

public class Singleton1 {
    private final static Singleton1 INSTANCE = new Singleton1();

    private Singleton1() {
    }

    public static Singleton1 getInstance() {
        return INSTANCE;
    }
}

方式二

public class Singleton2 {
    private static final Singleton2 INSTANCE;

    static {
        INSTANCE = new Singleton2();
    }
    private Singleton2() {
     
    }
    public static Singleton2 getInstance() {
        return INSTANCE;
    }
}

注意:

這種方式不支援延遲載入,如果範例佔用資源多(比如佔用記憶體多)或初始化耗時長(比如需要載入各種組態檔),提前初始化範例是一種浪費資源的行為。最好的方法應該在用到的時候再去初始化。不過,如果初始化耗時長,那最好不要等到真正要用它的時候,才去執行這個耗時長的初始化過程,這會影響到系統的效能,我們可以將耗時的初始化操作,提前到程式啟動的時候完成,這樣就能避免在程式執行的時候,再去初始化導致的效能問題。如果範例佔用資源多,按照 fail-fast 的設計原則(有問題及早暴露),那我們也希望在程式啟動時就將這個範例初始化好。如果資源不夠,就會在程式啟動的時候觸發報錯(比如 Java 中的 PermGen Space OOM ),我們可以立即去修復。這樣也能避免在程式執行一段時間後,突然因為初始化這個範例佔用資源過多,導致系統崩潰,影響系統的可用性。

這兩種方式都可以通過反射方式破壞,例如:

Class<?> aClass=Class.forName("singleton.Singleton2",true,Thread.currentThread().getContextClassLoader());
Singleton2 instance1=(Singleton2)aClass.newInstance();
Singleton2 instance2=(Singleton2)aClass.newInstance();
System.out.println(instance1==instance2);

懶漢式

雖然可以實現按需初始化,但是執行緒不安全, 因為在判斷 INSTANCE == null 的時候,有可能出現一個執行緒還沒有把 INSTANCE初始化好,另外一個執行緒判斷 INSTANCE==null 得到 true,就會繼續初始化

public class Singleton3 {
    private static Singleton3 INSTANCE;

    private Singleton3() {
    }

    public static Singleton3 getInstance() {
        if (INSTANCE == null) {
            // 模擬初始化物件需要的耗時操作
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            INSTANCE = new Singleton3();
        }
        return INSTANCE;
    }
}

為了防止執行緒不安全,可以在 getInstance 方法上加鎖,這樣既實現了按需初始化,又保證了執行緒安全,

但是加鎖可能會導致一些效能的問題:我們給 getInstance()這個方法加了一把大鎖,導致這個函數的並行度很低。量化一下的話,並行度是 1,也就相當於序列操作了。如果這個單例類偶爾會被用到,那這種實現方式還可以接受。但是,如果頻繁地用到,那頻繁加鎖、釋放鎖及並行度低等問題,會導致效能瓶頸,這種實現方式就不可取了。

public class Singleton4 {
    private static Singleton4 INSTANCE;

    private Singleton4() {
    }

    public static synchronized Singleton4 getInstance() {
        if (INSTANCE == null) {
            // 模擬初始化物件需要的耗時操作
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            INSTANCE = new Singleton4();
        }
        return INSTANCE;
    }
}

為了提升一點點效能,可以不給 getInstance() 整個方法加鎖,而是對 INSTANCE 判空這段程式碼加鎖, 但是這樣一來又帶來了執行緒不安全的問題

public class Singleton5 {
    private static Singleton5 INSTANCE;

    private Singleton5() {
    }

    public static Singleton5 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton5.class) {
                // 模擬初始化物件需要的耗時操作
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                INSTANCE = new Singleton5();
            }
        }
        return INSTANCE;
    }
}

Double Check Locking 模式,就是雙加鎖檢查模式,這種方式中,volatile 關鍵字是必需的,目的為了防止指令重排,生成一個半初始化的的範例,導致生成兩個範例。

具體可參考 雙重檢索(DCL)的思考: 為什麼要加volatile?

public class Singleton6 {
    private volatile static Singleton6 INSTANCE;

    private Singleton6() {
    }

    public static Singleton6 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton6.class) {
                if (INSTANCE == null) {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    INSTANCE = new Singleton6();
                }
            }
        }
        return INSTANCE;
    }
}

以下兩種更為優雅的方式,既保證了執行緒安全,又實現了按需載入。

方式一:靜態內部類方式, JVM 保證單例,載入外部類時不會載入內部類,這樣可以實現懶載入

public class Singleton7 {
    private Singleton7() {
    }

    public static Singleton7 getInstance() {
        return Holder.INSTANCE;
    }

    private static class Holder {
        private static final Singleton7 INSTANCE = new Singleton7();
    }

}

方式二: 使用列舉, 這是實現單例模式的最佳方法。它更簡潔,自動支援序列化機制,絕對防止多次範例化,這種方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不僅能避免多執行緒同步問題,而且還自動支援序列化機制,防止反序列化重新建立新的物件,絕對防止多次範例化。

public enum Singleton8 {
    INSTANCE;
}

單例模式的替代方案

使用靜態方法

   // 靜態方法實現方式
public class IdGenerator {
    private static AtomicLong id = new AtomicLong(0);
   
    public static long getId() { 
       return id.incrementAndGet();
    }
}

// 使用舉例
long id = IdGenerator.getId();

使用依賴注入

   // 1. 老的使用方式
   public demofunction() {
     //...
     long id = IdGenerator.getInstance().getId();
     //...
   }
   
   // 2. 新的使用方式:依賴注入
   public demofunction(IdGenerator idGenerator) {
     long id = idGenerator.getId();
   }
   // 外部呼叫demofunction()的時候,傳入idGenerator
   IdGenerator idGenerator = IdGenerator.getInsance();
   demofunction(idGenerator);

執行緒單例

通過一個 HashMap 來儲存物件,其中 key 是執行緒 ID,value 是物件。這樣我們就可以做到,不同的執行緒對應不同的物件,同一個執行緒只能對應一個物件。實際上,Java 語言本身提供了 ThreadLocal 工具類,可以更加輕鬆地實現執行緒唯一單例。不過,ThreadLocal 底層實現原理也是基於下面程式碼中所示的 HashMap 。


public class IdGenerator {
  private AtomicLong id = new AtomicLong(0);

  private static final ConcurrentHashMap<Long, IdGenerator> instances = new ConcurrentHashMap<>();

  private IdGenerator() {}

  public static IdGenerator getInstance() {
    Long currentThreadId = Thread.currentThread().getId();
    instances.putIfAbsent(currentThreadId, new IdGenerator());
    return instances.get(currentThreadId);
  }

  public long getId() {
    return id.incrementAndGet();
  }
}

叢集模式下單例

叢集模式下如果要實現單例需要把這個單例物件序列化並儲存到外部共用儲存區(比如檔案)。程序在使用這個單例物件的時候,需要先從外部共用儲存區中將它讀取到記憶體,並反序列化成物件,然後再使用,使用完成之後還需要再儲存回外部共用儲存區。為了保證任何時刻,在程序間都只有一份物件存在,一個程序在獲取到物件之後,需要對物件加鎖,避免其他程序再將其獲取。在程序使用完這個物件之後,還需要顯式地將物件從記憶體中刪除,並且釋放對物件的加鎖。

如何實現一個多例模式

「單例」指的是一個類只能建立一個物件。對應地,「多例」指的就是一個類可以建立多個物件,但是個數是有限制的,比如只能建立 3 個物件。多例的實現也比較簡單,通過一個 Map 來儲存物件型別和物件之間的對應關係,來控制物件的個數。

單例模式的應用舉例

JDK 的 Runtime 類

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

  public static Runtime getRuntime() {
    return currentRuntime;
  }
  
  /** Don't let anyone else instantiate this class */
  private Runtime() {}
.......
}

還有就是 Spring 中 AbstractBeanFactory 中包含的兩個功能。

功能一,就是從快取中獲取單例 Bean

功能二,就是從 Bean 的範例中獲取物件。

UML 和 程式碼

UML 圖

程式碼

更多

設計模式學習專欄

參考資料