本文首發(fā)公眾號:小碼A夢
單例模式是設(shè)計模式中最簡單一個設(shè)計模式,該模式屬于創(chuàng)建型模式,它提供了一種創(chuàng)建實例的最佳方式。
單例模式的定義也比較簡單:一個類只能允許創(chuàng)建一個對象或者實例,那么這個類就是單例類,這種設(shè)計模式就叫做單例模式。
單例模式有哪些好處:
- 類的創(chuàng)建,特別是一個大型的類,只創(chuàng)建一個類,避免內(nèi)存和 CPU 的開銷。
- 降低內(nèi)存使用,減少 GC 次數(shù),避免 GC 的壓力。
- 避免對資源的重復(fù)請求。
- 避免創(chuàng)建多個實例引起系統(tǒng)的混亂或者系統(tǒng)數(shù)據(jù)沖突。
ID 生成器單例類實戰(zhàn)
單例模式,其中的 "例" 表示 "實例" ,一個類需要保證僅有一個實例,并提供一個訪問它的全局訪問點,實現(xiàn)一個單例,需要符合以下幾點要求:
- 構(gòu)造函數(shù)需要設(shè)置 private 權(quán)限,避免外部通過 new 創(chuàng)建實例,通過一個靜態(tài)方法給其他類獲取實例。
- 對象創(chuàng)建需要考慮線程安全問題。
- 需要考慮延遲加載問題。
- 外部類獲取實例需要考慮性能方法。
在電商系統(tǒng)的訂單模塊,每次下單都需要生成新的訂單號。就需要調(diào)用訂單號生成器。
1、 創(chuàng)建一個簡單單例類
public class SnGenerator {
private AtomicLong id = new AtomicLong(0);
// 創(chuàng)建一個 Singleton 對象
private static SnGenerator instance = new SnGenerator();
// 構(gòu)造函數(shù)設(shè)置為 private,類就無法被實例化
private SnGenerator() {}
// 獲取唯一實例
public static SnGenerator getInstance() {
return instance;
}
public long getSn() {
return id.incrementAndGet();
}
}
2、獲取 Singleton 類的唯一實例
public class SingletonTest {
public static void main(String[] args) {
// 編譯報錯,因為 Singleton 構(gòu)造函數(shù)是私有的
//Singleton singleton = new Singleton();
SnGenerator snGenerator = SnGenerator.getInstance();
for (int i = 0; i < 10; i++) {
System.out.println(snGenerator.getSn());
}
}
}
控制臺輸出生成的 id:
1
2
3
4
5
6
7
8
9
10
以上首先創(chuàng)建一個單例類,提供唯一的單例獲取方法 getInstance。SingletonTest 類通過 Singleton.getInstance
獲取實例,獲取到實例,也就獲取到實例所有的方法。示例中調(diào)用 getSn 方法,獲取到唯一的訂單號了。
餓漢單例
餓漢單例實現(xiàn)起來比較簡單,所謂 "餓漢" 重點在餓,開始就需要創(chuàng)建單例。在類加載時,就創(chuàng)建好了實例。所以 instance 實例的創(chuàng)建是線程安全,不存在線程安全問題。但是這種方式不支持延遲加載,類加載時占用的內(nèi)存就比較高。
public class SnGenerator {
private AtomicLong id = new AtomicLong(0);
private static SnGenerator instance = new SnGenerator();
private SnGenerator() {}
public static SnGenerator getInstance() {
return instance;
}
public long getSn() {
return id.incrementAndGet();
}
}
餓漢單例解決線程安全問題,項目啟動時就創(chuàng)建好了實例,就需要考慮創(chuàng)建和獲取實例的線程安全問題。但是不支持延遲,如果實例的占用內(nèi)存比較大,或者實例加載時間比較長,類加載的時候就創(chuàng)建實例,就比較浪費內(nèi)存或者增加項目啟動時間。
對餓漢單例來說,不支持延遲加載,確實是比較浪費內(nèi)存。但是一個實例內(nèi)存相對于一個 Java 項目內(nèi)存占用影響是微乎其微。部署服務(wù)端項目時會分配幾倍于項目啟動占用的內(nèi)存,所以餓漢單例占用內(nèi)存還是可以接受的。而且如果占用內(nèi)存比較大,初始化實例也可以發(fā)現(xiàn)內(nèi)存不足的問題,并及時的處理。避免程序運行后,再去初始化實例,導(dǎo)致系統(tǒng)內(nèi)存溢出,影響系統(tǒng)穩(wěn)定性。
懶漢單例
既然餓漢單例單例不支持延遲加載,那我們就介紹一下支持延遲的加載的單例:懶漢單例。所謂"懶漢"重點在懶,一開始是不會初始化實例,而等到被調(diào)用才會初始化單例。
public class LazySnGenerator {
private AtomicLong id = new AtomicLong(0);
private static LazySnGenerator instance;
// 構(gòu)造函數(shù)設(shè)置為 private,類就無法被實例化
private LazySnGenerator() {}
// 獲取唯一實例
public static LazySnGenerator getInstance() {
if (instance == null) {
instance = new LazySnGenerator();
}
return instance;
}
public long getSn() {
return id.incrementAndGet();
}
}
上面的懶漢單例最開始不會初始化實例,而且等到 getInstance 方法被調(diào)用時,才會時候?qū)嵗@樣支持懶加載的方式,優(yōu)點是不占內(nèi)存。
但是懶漢單例缺點也比較明顯,在多線程環(huán)境下,getInstance 方法不是線程安全的。
打個比方,多個線程同時執(zhí)行到 if (instance == null)
結(jié)果都為 true,進而都會創(chuàng)建實例,所以上面的懶漢單例不是線程安全的實例。
加同步鎖的懶漢單例
懶漢單例存在多線程安全問題,第一想到的就是給 getInstance 添加同步鎖,添加鎖后,保證了線程的安全。
public class LazySnGenerator {
private AtomicLong id = new AtomicLong(0);
private static LazySnGenerator instance;
// 構(gòu)造函數(shù)設(shè)置為 private,類就無法被實例化
private LazySnGenerator() {}
// 獲取唯一實例
public synchronized static LazySnGenerator getInstance() {
if (instance == null) {
instance = new LazySnGenerator();
}
return instance;
}
public long getSn() {
return id.incrementAndGet();
}
}
添加同步鎖后懶漢單例,并發(fā)量下降,如果方法被頻繁使用,頻繁的加鎖、釋放鎖,有很大的性能瓶頸。
雙重檢驗懶漢單例
餓漢單例不支持延遲加載,懶漢單例有性能問題,不支持高并發(fā)。就需要一種既支持延遲加載又支持高并發(fā)的單例,也就是雙重檢驗懶漢單例。對上面的懶漢單例進行優(yōu)化之后,得出如下代碼。
public class LazyDoubleCheckSnGenerator {
private AtomicLong id = new AtomicLong(0);
private static LazyDoubleCheckSnGenerator instance;
// 構(gòu)造函數(shù)設(shè)置為 private,類就無法被實例化
private LazyDoubleCheckSnGenerator() {}
// 雙重檢測
public static LazyDoubleCheckSnGenerator getInstance() {
if (instance == null) {
// 類級別鎖
synchronized (LazyDoubleCheckSnGenerator.class) {
if (instance == null) {
instance = new LazyDoubleCheckSnGenerator();
}
}
}
return instance;
}
public long getSn() {
return id.incrementAndGet();
}
}
雙重檢測首先判斷實例是否為空,如果為空就使用類級別鎖鎖住整個類,其他線程也只能等待實例新建后,才能執(zhí)行 synchronized 代碼塊的代碼,而此時 instance 不為空,就不會繼續(xù)新建實例。從而確保線程安全。getInstance 只會在最開始的時候,性能較差。創(chuàng)建實例之后,后面的線程都不會請求到 synchronized 代碼塊。后續(xù)并發(fā)性能也提高了。
CPU 指令重排可能會導(dǎo)致新建對象并賦值給 instance 之后,還來得及初始化,就會其他線程使用。導(dǎo)致系統(tǒng)報錯,為了解決這個問題,就需要給 instance 成員變量添加 volatile 關(guān)鍵字禁止指令重排。
靜態(tài)內(nèi)部類單例
和雙重檢測單例一樣,靜態(tài)內(nèi)部類既支持延遲加載又支持高并發(fā)。首先看一下代碼實現(xiàn)。
public class SnStaticClass {
private AtomicLong id = new AtomicLong(0);
private static LazySnGenerator instance;
// 構(gòu)造函數(shù)設(shè)置為 private,類就無法被實例化
private SnStaticClass() {}
private static class SingletonHolder{
private static final SnStaticClass instance = new SnStaticClass();
}
// 靜態(tài)內(nèi)部類獲取實例
public synchronized static SnStaticClass getInstance() {
return SingletonHolder.instance;
}
public long getSn() {
return id.incrementAndGet();
}
}
SingletonHolder 是一個靜態(tài)內(nèi)部類,當 SnStaticClass 加載時,并不會加載 SingletonHolder 靜態(tài)內(nèi)部類,也就不會執(zhí)行靜態(tài)內(nèi)部的代碼。在類加載初始化階段,不會執(zhí)行靜態(tài)內(nèi)部類的代碼。只有 getInstance 方法,執(zhí)行 SingletonHolder 靜態(tài)內(nèi)部類,才會創(chuàng)建 SnStaticClass 實例。而 instance 創(chuàng)建的安全性,都是由 JVM 保證的。虛擬機使用加鎖同步機制,保證實例只會創(chuàng)建一次。這種方式不僅實現(xiàn)延遲加載,也保證線程安全。
枚舉單例
枚舉實例單例是一個簡答實現(xiàn)方式,這種方式是通過 Java 枚舉類性本身的特性,來保證實例的唯一和線程的安全。
public enum SnGeneratorEnum {
instance;
private AtomicLong id = new AtomicLong(0);
public long getSn() {
return id.incrementAndGet();
}
}
單例模式的應(yīng)用
在 Java 開發(fā)中,有很多地方使用到了單例模式。比如 JDK、Spring。
JDK
Runtime 類封裝了 Java 運行信息,可以獲取有關(guān)運行時環(huán)境的信息,每個 JVM 進程只有一個運行環(huán)境,只需要一個 Runtime 實例,所以 Runtime 一個單例實現(xiàn)。
public class Runtime {
private static Runtime currentRuntime = new Runtime();
/**
* Returns the runtime object associated with the current Java application.
* Most of the methods of class <code>Runtime</code> are instance
* methods and must be invoked with respect to the current runtime object.
*
* @return the <code>Runtime</code> object associated with the current
* Java application.
*/
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
......省略
}
由以上代碼可知,Runtime 是一個餓漢單例,類加載時就初始化了實例,提供 getRuntime 方法提交單例的調(diào)用。
Spring
大部分 Java 項目都是基于 Spring 框架開發(fā)的,Spring 中 bean 簡單分成單例和多例,其中 bean 的單例實現(xiàn)既不是餓漢單例也不是懶漢單例。是基于單例注冊表實現(xiàn),注冊表就就是一個哈希表,使用一個哈希表存儲 bean 的信息。
/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
singletonObjects 表示一個單例注冊表,key 存儲 bean 的名稱,value 存儲 bean 的實例信息。DefaultSingletonBeanRegistry 類的 getSingleton 方法實現(xiàn) bean 單例,以下摘取主要的的代碼。
public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(beanName, "Bean name must not be null");
// 鎖住注冊表
synchronized (this.singletonObjects) {
// 獲取 bean 信息,不存在就創(chuàng)建一個 bean
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
beforeSingletonCreation(beanName);
boolean newSingleton = false;
boolean recordSuppressedExceptions = (this.suppressedExceptions == null);
try {
// 創(chuàng)建 bean
singletonObject = singletonFactory.getObject();
newSingleton = true;
}
catch (IllegalStateException ex) {
}
catch (BeanCreationException ex) {
}
finally {
}
// 創(chuàng)建好的 bean 存進 map 中
if (newSingleton) {
addSingleton(beanName, singletonObject);
}
}
return singletonObject;
}
}
Spring 獲取 bean,鎖住整個注冊表,首先從 map 中獲取 bean,如果 bean 不存在,就創(chuàng)建一個 bean,并存入 map 中。后續(xù)獲取 bean,獲取到的都是 map 的 bean。并不會創(chuàng)建新的 bean。
總結(jié)
單例模式一個最簡單的一種設(shè)計模式,該設(shè)計模式是一種創(chuàng)建型設(shè)計模式。規(guī)定了一個類只能創(chuàng)建一實例。很多類只需要一個實例,這樣的好處,減少內(nèi)存的占用和 CPU 的開銷,減少 GC 的次數(shù)。同時也減少對資源的重復(fù)使用。
- 以生成訂單系統(tǒng)的訂單號為例,分別介紹幾種單例模式。
- 餓漢單例:線程安全,但不支持延遲加載,不使用也會占用內(nèi)存,比較浪費內(nèi)存。但是類加載時創(chuàng)建實例,可以及時的發(fā)現(xiàn)內(nèi)存不足問題。
- 懶漢單例:支持延遲加載,但是線程不安全。多線程獲取實例,可能會創(chuàng)建多個實例,就需要使用同步鎖,鎖住獲取實例的方法,但是加了鎖之后,性能就比較差。
- 雙重檢測懶漢單例:針對上面不同同時滿足延遲加載和線程安全問題,就設(shè)計出來雙重檢測的懶漢單例,主要將鎖的代碼塊范圍縮小,先獲取實例,如果實例為空,才使用類級別鎖,鎖住代碼,創(chuàng)建實例。當創(chuàng)建好實例后,后面請求都不會進同步鎖的代碼塊,性能也不會降低。還需要考慮指令重排的問題,需要給成員變量添加 volatile 關(guān)鍵字禁止指令重排。
- 靜態(tài)內(nèi)部類:也同時滿足延遲加載和線程安全,延遲加載是在類加載時不會靜態(tài)內(nèi)部類的代碼,只有調(diào)用時候才會執(zhí)行靜態(tài)內(nèi)部類的代碼。JVM 使用同步鎖的機制保證獲取實例是線程安全的。
- 枚舉單例:通過 Java 枚舉類性本身的特性,來保證實例的唯一和線程的安全。
- 單例模式的應(yīng)用
- JDK: Runtime 封裝了 Java 運行信息,可以獲取有關(guān)運行時環(huán)境的信息,一個 JVM 只需要一個 Runtime 實例.Runtime 單例是餓漢單例,在類加載時就初始化實例。
- Spring: Spring 的 bean 支持單例,使用單例注冊表,一種哈希表存儲 bean信息,key 是存儲 bean 的名稱,value 是存儲 bean 的實例。獲取 bean 首先鎖住表,然后獲取 bean,如果為空就創(chuàng)建 bean,并存入表中,后續(xù)都能從哈希表中獲取 bean 實例了。
參考
-
單例模式(上):為什么說支持懶加載的雙重檢測不比餓漢式更優(yōu)?
-
Spring學習之路——單例模式和多例模式