這篇文章將解決你以下幾個疑問:
- 為什么要使用單例?
- 單例有哪些寫法?
- 單例存在哪些問題?
- 單例與靜態(tài)類的區(qū)別?
- 有何替代的解決方案?
- 相關(guān)視頻教程
為什么要使用單例?
單例設(shè)計模式(Singleton Design Pattern)如果一個類只允許創(chuàng)建一個對象(或者實例),那這個類就是一個單例類,這種設(shè)計模式就叫作單例設(shè)計模式,簡稱單例模式。
為什么我們需要單例這種設(shè)計模式?它能解決哪些問題?接下來我通過兩個實戰(zhàn)案例來講解:
實戰(zhàn)案例一:處理資源訪問沖突
我們自定義實現(xiàn)了一個往文件中打印日志的 Logger 類。具體的代碼實現(xiàn)如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/mrHandson/log.txt");
writer = new FileWriter(file, true); //true表示追加寫入
}
public void log(String message) {
writer.write(message);
}
}
// Logger類的應(yīng)用示例:
public class UserController {
private Logger logger = new Logger();
public void login(String username, String password) {
// ...省略業(yè)務(wù)邏輯代碼...
logger.log(username + " logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// ...省略業(yè)務(wù)邏輯代碼...
logger.log("Created an order: " + order.toString());
}
}
所有的日志都寫入到同一個文件 /Users/wangzheng/log.txt 中。在 UserController 和 OrderController 中,我們分別創(chuàng)建兩個 Logger 對象。在 Web 容器的 Servlet 多線程環(huán)境下,如果兩個 Servlet 線程同時分別執(zhí)行 login() 和 create() 兩個函數(shù),并且同時寫日志到 log.txt 文件中,那就有可能存在日志信息互相覆蓋的情況。
為什么會出現(xiàn)互相覆蓋呢?我們可以這么類比著理解。在多線程環(huán)境下,如果兩個線程同時給同一個共享變量加 1,因為共享變量是競爭資源,所以,共享變量最后的結(jié)果有可能并不是加了 2,而是只加了 1。同理,這里的 log.txt 文件也是競爭資源,兩個線程同時往里面寫數(shù)據(jù),就有可能存在互相覆蓋的情況。
實戰(zhàn)案例二:表示全局唯一類
從業(yè)務(wù)概念上,如果有些數(shù)據(jù)在系統(tǒng)中只應(yīng)保存一份,那就比較適合設(shè)計為單例類。
比如,配置信息類。在系統(tǒng)中,我們只有一個配置文件,當(dāng)配置文件被加載到內(nèi)存之后,以對象的形式存在,也理所應(yīng)當(dāng)只有一份。
如何實現(xiàn)一個單例?
要實現(xiàn)一個單例,我們需要關(guān)注的點無外乎下面幾個: -構(gòu)造函數(shù)需要是 private 訪問權(quán)限的,這樣才能避免外部通過 new 創(chuàng)建實例;
- 考慮對象創(chuàng)建時的線程安全問題;
- 考慮是否支持延遲加載;
- 考慮是否支持延遲加載;
- 考慮 getInstance() 性能是否高(是否加鎖)。
1. 餓漢式
餓漢式的實現(xiàn)方式比較簡單。在類加載的時候,instance 靜態(tài)實例就已經(jīng)創(chuàng)建并初始化好了,所以,instance 實例的創(chuàng)建過程是線程安全的。不過,這樣的實現(xiàn)方式不支持延遲加載(在真正用到 IdGenerator 的時候,再創(chuàng)建實例),從名字中我們也可以看出這一點。具體的代碼實現(xiàn)如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
因為不支持延遲加載,如果實例占用資源多(比如占用內(nèi)存多)或初始化耗時長(比如需要加載各種配置文件),提前初始化實例是一種浪費資源的行為。最好的方法應(yīng)該在用到的時候再去初始化。
如果實例占用資源多,按照 fail-fast 的設(shè)計原則(有問題及早暴露),那我們也希望在程序啟動時就將這個實例初始化好。如果資源不夠,就會在程序啟動的時候觸發(fā)報錯(比如 Java 中的 PermGen Space OOM),我們可以立即去修復(fù)。這樣也能避免在程序運行一段時間后,突然因為初始化這個實例占用資源過多,導(dǎo)致系統(tǒng)崩潰,影響系統(tǒng)的可用性。
2. 懶漢式
懶漢式相對于餓漢式的優(yōu)勢是支持延遲加載。具體的代碼實現(xiàn)如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
懶漢式的缺點也很明顯,我們給 getInstance() 這個方法加了一把大鎖(synchronzed),導(dǎo)致這個函數(shù)的并發(fā)度很低。量化一下的話,并發(fā)度是 1,也就相當(dāng)于串行操作了。而這個函數(shù)是在單例使用期間,一直會被調(diào)用。如果這個單例類偶爾會被用到,那這種實現(xiàn)方式還可以接受。但是,如果頻繁地用到,那頻繁加鎖、釋放鎖及并發(fā)度低等問題,會導(dǎo)致性能瓶頸,這種實現(xiàn)方式就不可取了。
3. 雙重檢測
餓漢式不支持延遲加載,懶漢式有性能問題,不支持高并發(fā)。那我們再來看一種既支持延遲加載、又支持高并發(fā)的單例實現(xiàn)方式,也就是雙重檢測實現(xiàn)方式。
在這種實現(xiàn)方式中,只要 instance 被創(chuàng)建之后,即便再調(diào)用 getInstance() 函數(shù)也不會再進(jìn)入到加鎖邏輯中了。所以,這種實現(xiàn)方式解決了懶漢式并發(fā)度低的問題。具體的代碼實現(xiàn)如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
if (instance == null) {
synchronized(IdGenerator.class) { // 此處為類級別的鎖
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
有人說,這種實現(xiàn)方式有些問題。因為指令重排序,可能會導(dǎo)致 IdGenerator 對象被 new 出來,并且賦值給 instance 之后,還沒來得及初始化(執(zhí)行構(gòu)造函數(shù)中的代碼邏輯),就被另一個線程使用了。要解決這個問題,我們需要給 instance 成員變量加上 volatile 關(guān)鍵字,禁止指令重排序才行。實際上,只有很低版本的 Java 才會有這個問題。我們現(xiàn)在用的高版本的 Java 已經(jīng)在 JDK 內(nèi)部實現(xiàn)中解決了這個問題(解決的方法很簡單,只要把對象 new 操作和初始化操作設(shè)計為原子操作,就自然能禁止重排序)。關(guān)于這點的詳細(xì)解釋,跟特定語言有關(guān),我就不展開講了,感興趣的同學(xué)可以自行研究一下。
4. 靜態(tài)內(nèi)部類
利用 Java 的靜態(tài)內(nèi)部類。它有點類似餓漢式,但又能做到了延遲加載。具體是怎么做到的呢?我們先來看它的代碼實現(xiàn)。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private IdGenerator() {}
private static class SingletonHolder{
private static final IdGenerator instance = new IdGenerator();
}
public static IdGenerator getInstance() {
return SingletonHolder.instance;
}
public long getId() {
return id.incrementAndGet();
}
}
SingletonHolder 是一個靜態(tài)內(nèi)部類,當(dāng)外部類 IdGenerator 被加載的時候,并不會創(chuàng)建 SingletonHolder 實例對象。只有當(dāng)調(diào)用 getInstance() 方法時,SingletonHolder 才會被加載,這個時候才會創(chuàng)建 instance。instance 的唯一性、創(chuàng)建過程的線程安全性,都由 JVM 來保證。所以,這種實現(xiàn)方法既保證了線程安全,又能做到延遲加載。
5. 枚舉
這種實現(xiàn)方式通過 Java 枚舉類型本身的特性,保證了實例創(chuàng)建的線程安全性和實例的唯一性。具體的代碼如下所示:
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
單例存在哪些問題?
在項目中使用單例,都是用它來表示一些全局唯一類,比如配置信息類、連接池類、ID 生成器類。單例模式書寫簡潔、使用方便,在代碼中,我們不需要創(chuàng)建對象,直接通過類似 IdGenerator.getInstance().getId() 這樣的方法來調(diào)用就可以了。但是,這種使用方法有點類似硬編碼(hard code),會帶來諸多問題。接下來,我們就具體看看到底有哪些問題:
1. 單例對 OOP 特性的支持不友好
OOP 的四大特性是封裝、抽象、繼承、多態(tài)。單例這種設(shè)計模式對于其中的抽象、繼承、多態(tài)都支持得不好。為什么這么說呢?我們還是通過 IdGenerator 這個例子來講解。
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
//...
}
}
IdGenerator 的使用方式違背了基于接口而非實現(xiàn)的設(shè)計原則,也就違背了廣義上理解的 OOP 的抽象特性。如果未來某一天,我們希望針對不同的業(yè)務(wù)采用不同的 ID 生成算法。比如,訂單 ID 和用戶 ID 采用不同的 ID 生成器來生成。為了應(yīng)對這個需求變化,我們需要修改所有用到 IdGenerator 類的地方,這樣代碼的改動就會比較大。
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
// 需要將上面一行代碼,替換為下面一行代碼
long id = OrderIdGenerator.getIntance().getId();
//...
}
}
public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
// 需要將上面一行代碼,替換為下面一行代碼
long id = UserIdGenerator.getIntance().getId();
}
}
一旦你選擇將某個類設(shè)計成到單例類,也就意味著放棄了繼承和多態(tài)這兩個強有力的面向?qū)ο筇匦?,也就相?dāng)于損失了可以應(yīng)對未來需求變化的擴展性。
2. 單例會隱藏類之間的依賴關(guān)系
通過構(gòu)造函數(shù)、參數(shù)傳遞等方式聲明的類之間的依賴關(guān)系,我們通過查看函數(shù)的定義,就能很容易識別出來。但是,單例類不需要顯示創(chuàng)建、不需要依賴參數(shù)傳遞,在函數(shù)中直接調(diào)用就可以了。如果代碼比較復(fù)雜,這種調(diào)用關(guān)系就會非常隱蔽。在閱讀代碼的時候,我們就需要仔細(xì)查看每個函數(shù)的代碼實現(xiàn),才能知道這個類到底依賴了哪些單例類。
3. 單例對代碼的擴展性不友好
我們知道,單例類只能有一個對象實例。如果未來某一天,我們需要在代碼中創(chuàng)建兩個實例或多個實例,那就要對代碼有比較大的改動。你可能會說,會有這樣的需求嗎?既然單例類大部分情況下都用來表示全局類,怎么會需要兩個或者多個實例呢?
實際上,這樣的需求并不少見。我們拿數(shù)據(jù)庫連接池來舉例解釋一下。
在系統(tǒng)設(shè)計初期,我們覺得系統(tǒng)中只應(yīng)該有一個數(shù)據(jù)庫連接池,這樣能方便我們控制對數(shù)據(jù)庫連接資源的消耗。所以,我們把數(shù)據(jù)庫連接池類設(shè)計成了單例類。但之后我們發(fā)現(xiàn),系統(tǒng)中有些 SQL 語句運行得非常慢。這些 SQL 語句在執(zhí)行的時候,長時間占用數(shù)據(jù)庫連接資源,導(dǎo)致其他 SQL 請求無法響應(yīng)。為了解決這個問題,我們希望將慢 SQL 與其他 SQL 隔離開來執(zhí)行。為了實現(xiàn)這樣的目的,我們可以在系統(tǒng)中創(chuàng)建兩個數(shù)據(jù)庫連接池,慢 SQL 獨享一個數(shù)據(jù)庫連接池,其他 SQL 獨享另外一個數(shù)據(jù)庫連接池,這樣就能避免慢 SQL 影響到其他 SQL 的執(zhí)行。
如果我們將數(shù)據(jù)庫連接池設(shè)計成單例類,顯然就無法適應(yīng)這樣的需求變更,也就是說,單例類在某些情況下會影響代碼的擴展性、靈活性。所以,數(shù)據(jù)庫連接池、線程池這類的資源池,最好還是不要設(shè)計成單例類。實際上,一些開源的數(shù)據(jù)庫連接池、線程池也確實沒有設(shè)計成單例類。
4. 單例對代碼的可測試性不友好
單例模式的使用會影響到代碼的可測試性。如果單例類依賴比較重的外部資源,比如 DB,我們在寫單元測試的時候,希望能通過 mock 的方式將它替換掉。而單例類這種硬編碼式的使用方式,導(dǎo)致無法實現(xiàn) mock 替換。
除此之外,如果單例類持有成員變量(比如 IdGenerator 中的 id 成員變量),那它實際上相當(dāng)于一種全局變量,被所有的代碼共享。如果這個全局變量是一個可變?nèi)肿兞?,也就是說,它的成員變量是可以被修改的,那我們在編寫單元測試的時候,還需要注意不同測試用例之間,修改了單例類中的同一個成員變量的值,從而導(dǎo)致測試結(jié)果互相影響的問題。
5. 單例不支持有參數(shù)的構(gòu)造函數(shù)
單例不支持有參數(shù)的構(gòu)造函數(shù),比如我們創(chuàng)建一個連接池的單例對象,我們沒法通過參數(shù)來指定連接池的大小。
有何替代解決方案?
// 1. 老的使用方式
public demofunction() {
//...
long id = IdGenerator.getInstance().getId();
//...
}
// 2. 新的使用方式:依賴注入
public demofunction(IdGenerator idGenerator) {
long id = idGenerator.getId();
}
// 外部調(diào)用demofunction()的時候,傳入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);
基于新的使用方式,我們將單例生成的對象,作為參數(shù)傳遞給函數(shù)(也可以通過構(gòu)造函數(shù)傳遞給類的成員變量),可以解決單例隱藏類之間依賴關(guān)系的問題。不過,對于單例存在的其他問題,比如對 OOP 特性、擴展性、可測性不友好等問題,還是無法解決。
所以,如果要完全解決這些問題,我們可能要從根上,尋找其他方式來實現(xiàn)全局唯一類。實際上,類對象的全局唯一性可以通過多種不同的方式來保證。我們既可以通過單例模式來強制保證,也可以通過工廠模式、IOC 容器(比如 Spring IOC 容器)來保證。
相關(guān)推薦
【2021 最新版】Android studio全套教程+Android(安卓)開發(fā)入門到精通(項目實戰(zhàn)篇)_嗶哩嗶哩_bilibili
Android開發(fā)進(jìn)階學(xué)習(xí)—設(shè)計思想解讀開源框架 · 已更新至104集(持續(xù)更新中~)_嗶哩嗶哩_bilibili
Android音視頻開發(fā):音視頻基礎(chǔ)知識到直播推流實戰(zhàn)系列教程_嗶哩嗶哩_bilibili
Android項目實戰(zhàn)-從0開始手把手實現(xiàn)組件化路由SDK項目實戰(zhàn)_嗶哩嗶哩_bilibili
本文轉(zhuǎn)自 https://juejin.cn/post/7040651819193729061,如有侵權(quán),請聯(lián)系刪除。