這篇文章將解決你以下幾個疑問:
- 為什么要使用單例?
- 單例有哪些寫法掂林?
- 單例存在哪些問題臣缀?
- 單例與靜態(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)中试幽,我們只有一個配置文件筝蚕,當配置文件被加載到內(nèi)存之后,以對象的形式存在铺坞,也理所應(yī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竟痰,也就相當于串行操作了。而這個函數(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ù)也不會再進入到加鎖邏輯中了疏之。所以殿雪,這種實現(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)于這點的詳細解釋为迈,跟特定語言有關(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)部類昆箕,當外部類 IdGenerator 被加載的時候鸦列,并不會創(chuàng)建 SingletonHolder 實例對象。只有當調(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ū)ο筇匦裕簿拖喈斢趽p失了可以應(yīng)對未來需求變化的擴展性苗分。
2. 單例會隱藏類之間的依賴關(guān)系
通過構(gòu)造函數(shù)厌蔽、參數(shù)傳遞等方式聲明的類之間的依賴關(guān)系,我們通過查看函數(shù)的定義俭嘁,就能很容易識別出來躺枕。但是服猪,單例類不需要顯示創(chuàng)建供填、不需要依賴參數(shù)傳遞,在函數(shù)中直接調(diào)用就可以了罢猪。如果代碼比較復(fù)雜近她,這種調(diào)用關(guān)系就會非常隱蔽。在閱讀代碼的時候膳帕,我們就需要仔細查看每個函數(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 成員變量)犁苏,那它實際上相當于一種全局變量硬萍,被所有的代碼共享。如果這個全局變量是一個可變?nèi)肿兞课辏簿褪钦f朴乖,它的成員變量是可以被修改的,那我們在編寫單元測試的時候助赞,還需要注意不同測試用例之間买羞,修改了單例類中的同一個成員變量的值,從而導(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ā)進階學(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)系刪除狠鸳。