一個(gè)單例模式,沒必要這么卷吧
老貓的設(shè)計(jì)模式專欄已經(jīng)偷偷發(fā)車了。不甘愿做crud boy?看了好幾遍的設(shè)計(jì)模式還記不???那就不要刻意記了,跟上老貓的步伐,在一個(gè)個(gè)有趣的職場故事中領(lǐng)悟設(shè)計(jì)模式的精髓。還等什么?趕緊上車吧
如果把系統(tǒng)軟件比喻成江湖的話,那么設(shè)計(jì)原則絕對是OO程序員的武功心法,而設(shè)計(jì)模式絕對是招式。光知道心法是沒有用的,還是得配合招式。只有心法招式合二為一,遇到強(qiáng)敵(“坑爹系統(tǒng)”)才能見招拆招,百戰(zhàn)百勝。
故事
之前讓小貓梳理的業(yè)務(wù)流程以及代碼流程基本已經(jīng)梳理完畢【系統(tǒng)梳理大法&代碼梳理大法】。從代碼側(cè)而言也搞清楚了系統(tǒng)臃腫的原因【違背設(shè)計(jì)原則】。小貓逐漸步入正軌,他決定從一些簡單的業(yè)務(wù)場景入手,開始著手優(yōu)化系統(tǒng)代碼。那么什么樣的業(yè)務(wù)代碼,動(dòng)了之后影響最小呢?小貓看了看,打算就從泛濫創(chuàng)建的線程池著手吧,他打算用單例模式做一次重構(gòu)。
在小貓接手的系統(tǒng)中,線程池的創(chuàng)建基本是想在哪個(gè)類用多線程就在那個(gè)類中直接創(chuàng)建。所以基本上很多service服務(wù)類中都有創(chuàng)建線程池的影子。
寫在前面
遇到上述小貓的這種情況,我們的思路是采用單例模式進(jìn)行提取公共線程池執(zhí)行器,然后根據(jù)不同的業(yè)務(wù)類型使用工廠模式進(jìn)行分類管理。
接下來,我們就單例模式開始吧。
概要
單例模式定義
單例模式(Singleton)又叫單態(tài)模式,它出現(xiàn)目的是為了保證一個(gè)類在系統(tǒng)中只有一個(gè)實(shí)例,并提供一個(gè)訪問它的全局訪問點(diǎn)。從這點(diǎn)可以看出,單例模式的出現(xiàn)是為了可以保證系統(tǒng)中一個(gè)類只有一個(gè)實(shí)例而且該實(shí)例又易于外界訪問,從而方便對實(shí)例個(gè)數(shù)的控制并節(jié)約系統(tǒng)資源而出現(xiàn)的解決方案。如下圖:
單例模式簡單示意圖
餓漢式單例模式
什么叫做餓漢式單例?為了方便記憶,老貓是這么理解的,餓漢給人的形象就是有食物就迫不及待地去吃的形象。那么餓漢式單例模式的形象也就是當(dāng)類創(chuàng)建的時(shí)候就迫不及待地去創(chuàng)建單例對象,這種單例模式是絕對線程安全的,因?yàn)檫@種模式在尚未產(chǎn)生線程之前就已經(jīng)創(chuàng)建了單例。
看一下示例,如下:
/**
* 公眾號(hào):程序員老貓
* 餓漢單例模式
*/
public class HungrySingleton {
private static final HungrySingleton HUNGRY_SINGLETON = new HungrySingleton();
//構(gòu)造函數(shù)私有化,保證不被new方式多次創(chuàng)建新對象
private HungrySingleton() {
}
public static HungrySingleton getInstance(){
return HUNGRY_SINGLETON;
}
}
我們看一下上述案例的優(yōu)缺點(diǎn):
- 優(yōu)點(diǎn):線程安全,類加載時(shí)完成初始化,獲取對象的速度較快。
- 缺點(diǎn):由于類加載的時(shí)候就完成了對象的創(chuàng)建,有的時(shí)候我們無需調(diào)用的情況下,對象已經(jīng)存在,這樣的話就會(huì)造成內(nèi)存浪費(fèi)。
當(dāng)前硬件和服務(wù)器的發(fā)展,快于軟件的發(fā)展,另外的,微服務(wù)和集群化部署,大大降低了橫向擴(kuò)展的門檻和成本,所以老貓覺得當(dāng)前的內(nèi)存其實(shí)是不值錢的,所以上述這種單例模式硬說其缺點(diǎn)有多嚴(yán)重其實(shí)也不然,個(gè)人覺得這種模式用于實(shí)際開發(fā)過程中其實(shí)是沒有問題的。
其實(shí)在我們?nèi)粘J褂玫膕pring框架中,IOC容器本身就是一個(gè)餓漢式單例模式,spring啟動(dòng)的時(shí)候就將對象加載到了內(nèi)存中,這里咱們不做展開,等到后續(xù)咱們梳理到spring源代碼的時(shí)候再展開來說。
懶漢式單例模式
上述餓漢單例模式我們說它的缺點(diǎn)是浪費(fèi)內(nèi)存,因?yàn)槠湓陬惣虞d的時(shí)候就創(chuàng)建了對象,那么針對這種內(nèi)存浪費(fèi)的解決方案,我們就有了“懶漢模式”。對于這種類型的單例模式,老貓是這么理解的,懶漢的定義給人的直觀感覺是懶惰、拖延。那么對應(yīng)的模式上來說,這種方案創(chuàng)建對象的方法也是在程序使用對象前,先判斷該對象是否已經(jīng)實(shí)例化(判空),若已實(shí)例化直接返回該類對象,否則則先執(zhí)行實(shí)例化操作。
看一下示例,如下:
/**
* 公眾號(hào):程序員老貓
* 懶漢式單例模式
*/
public class LazySingleton {
private LazySingleton() {
}
private static LazySingleton lazySingleton = null;
public static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
上面這種單例模式創(chuàng)建對象,內(nèi)存問題看起來是已經(jīng)解決了,但是這種創(chuàng)建方式真的就線程安全了么?咱們接下來寫個(gè)簡單的測試demo:
public class Main {
public static void main(String[] args) {
Thread thread1 = new Thread(()->{
LazySingleton lazySingleton = LazySingleton.getInstance();
System.out.println(lazySingleton.toString());
});
Thread thread2 = new Thread(()->{
LazySingleton lazySingleton = LazySingleton.getInstance();
System.out.println(lazySingleton.toString());
});
thread1.start();
thread2.start();
System.out.println("end");
}
}
執(zhí)行輸出結(jié)果如下:
end
LazySingleton@3fde6a42
LazySingleton@2648fc3a
從上述的輸出中我們很容易地發(fā)現(xiàn),兩個(gè)線程中所獲取的對象是不同的,當(dāng)然這個(gè)是有一定概率性質(zhì)的。所以在這種多線程請求的場景下,就出現(xiàn)了線程安全性問題。
聊到共享變量訪問線程安全性的問題,我們往往就想到了鎖,所以,咱們在原有的代碼塊上加上鎖對其優(yōu)化試試,我們首先想到的是給方法代碼塊加上鎖。
加鎖后代碼如下:
public class LazySingleton {
private LazySingleton() {
}
private static LazySingleton lazySingleton = null;
public synchronized static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
經(jīng)過上述同樣的測試類運(yùn)行之后,我們發(fā)現(xiàn)問題似乎解決了,每次運(yùn)行之后得到的結(jié)果,兩個(gè)線程對象的輸出都是一致的。
我們用線程debug的方式看一下具體的運(yùn)行情況,如下圖:
線程輸出
我們可以發(fā)現(xiàn),當(dāng)一個(gè)線程進(jìn)行初始化實(shí)例時(shí),另一個(gè)線程就會(huì)從Running狀態(tài)自動(dòng)變成了Monitor狀態(tài)。試想一下,如果有大量的線程同時(shí)訪問的時(shí)候,在這樣一個(gè)鎖的爭奪過程中就會(huì)有很多的線程被掛起為Monitor狀態(tài)。CPU壓力隨著線程數(shù)的增加而持續(xù)增加,顯然這種實(shí)現(xiàn)對性能還是很有影響的。
那還有優(yōu)化的空間么?當(dāng)然有,那就是大家經(jīng)常聽到的“DCL”即“Double Check Lock” 實(shí)現(xiàn)如下:
/**
* 公眾號(hào):程序員老貓
* 懶漢式單例模式(DCL)
* Double Check Lock
*/
public class LazySingleton {
private LazySingleton() {
}
//使用volatile防止指令重排
private volatile static LazySingleton lazySingleton = null;
public static LazySingleton getInstance() {
if (lazySingleton == null) {
synchronized (LazySingleton.class) {
if(lazySingleton == null){
lazySingleton = new LazySingleton();
}
}
}
return lazySingleton;
}
}
通過DEBUG,我們來看一下下圖:
雙重校驗(yàn)鎖
這里引申一個(gè)常見的問題,大家在面試的時(shí)候估計(jì)也會(huì)碰到。問題:為什么要double check?去掉第二次check行不行?
回答:當(dāng)2個(gè)線程同時(shí)執(zhí)行g(shù)etInstance方法時(shí),都會(huì)執(zhí)行第一個(gè)if判斷,由于鎖機(jī)制的存在,會(huì)有一個(gè)線程先進(jìn)入同步語句,而另一個(gè)線程等待,當(dāng)?shù)谝粋€(gè)線程執(zhí)行了new Singleton()之后,就會(huì)退出synchronized的保護(hù)區(qū)域,這時(shí)如果沒有第二重if判斷,那么第二個(gè)線程也會(huì)創(chuàng)建一個(gè)實(shí)例,這就破壞了單例。
問題:這里為什么要加上volatile修飾關(guān)鍵字?回答:這里加上該關(guān)鍵字主要是為了防止"指令重排"。關(guān)于“指令重排”具體產(chǎn)生的原因我們這里不做細(xì)究,有興趣的小伙伴可以自己去研究一下,我們這里只是去分析一下,“指令重排”所帶來的影響。
lazySingleton = new LazySingleton();
這樣一個(gè)看似簡單的動(dòng)作,其實(shí)從JVM層來看并不是一個(gè)原子性的行為,這里其實(shí)發(fā)生了三件事:
- 給LazySingleton分配內(nèi)存空間。
- 調(diào)用LazySingleton的構(gòu)造函數(shù),初始化成員字段。
- 將LazySingleton指向分配的內(nèi)存空間(注意此時(shí)的LazySingleton就不是null了)
在此期間存在著指令重排序的優(yōu)化,第2、3步的順序是不能保證的,最后的執(zhí)行順序可能是1-2-3,也可能是1-3-2,假如執(zhí)行順序是1-3-2,我們看看會(huì)出現(xiàn)什么問題。看一下下圖:
指令重排執(zhí)行
從上圖中我們看到雖然LazySingleton不是null,但是指向的空間并沒有初始化,最終被業(yè)務(wù)使用的時(shí)候還是會(huì)報(bào)錯(cuò),這就是DCL失效的問題,這種問題難以跟蹤難以重現(xiàn)可能會(huì)隱藏很久。
JDK1.5之前JMM(Java Memory Model,即Java內(nèi)存模型)中的Cache、寄存器到主存的回寫規(guī)定,上面第二第三的順序無法保證。JDK1.5之后,SUN官方調(diào)整了JVM,具體化了volatile關(guān)鍵字,private volatile static LazySingleton lazySingleton;只要加上volatile,就可以保證每次從主存中讀?。ㄟ@涉及到CPU緩存一致性問題,感興趣的小伙伴可以研究研究),也可以防止指令重排序的發(fā)生,避免拿到未完成初始化的對象。
上面這種方式可以有效降低鎖的競爭,鎖不會(huì)將整個(gè)方法全部鎖定,而是鎖定了某個(gè)代碼塊。其實(shí)完全做完調(diào)試之后我們還是會(huì)發(fā)現(xiàn)鎖爭奪的問題并沒有完全解決,用到了鎖肯定會(huì)對整個(gè)代碼的執(zhí)行效率帶來一定的影響。所以是否存在保證線程的安全,并且能夠不浪費(fèi)內(nèi)存完美的解決方案呢?一起看下下面的解決方案。
內(nèi)部靜態(tài)類單例模式
這種方式其實(shí)是利用了靜態(tài)對象創(chuàng)建的特性來解決上述內(nèi)存浪費(fèi)以及線程不安全的問題。在這里我們要弄清楚,被static修飾的屬性,類加載的時(shí)候,基本屬性就已經(jīng)加載完畢,但是靜態(tài)方法卻不會(huì)加載的時(shí)候自動(dòng)執(zhí)行,而是等到被調(diào)用之后才會(huì)執(zhí)行。并且被STATIC修飾的變量JVM只為靜態(tài)分配一次內(nèi)存。(這里老貓不展開去聊static相關(guān)知識(shí)點(diǎn),有興趣的小伙伴也可以自行去了解一下更多JAVA中static關(guān)鍵字修飾之后的類、屬性、方法的加載機(jī)制以及存儲(chǔ)機(jī)制)
所以綜合這一特性,我們就有了下面這樣的寫法:
public class LazyInnerClassSingleton {
private LazyInnerClassSingleton () {
}
public static final LazyInnerClassSingleton getInstance() {
return LazyHolder.LAZY;
}
private static class LazyHolder {
private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
}
}
上面這種寫法,其實(shí)也屬于“懶漢式單例模式”,并且這種模式相對于“無腦加鎖”以及“DCL”以及“餓漢式單例模式”來說無疑是最優(yōu)的一種實(shí)現(xiàn)方式。
但是深度去追究的話,其實(shí)這種方式也會(huì)有問題,這種寫法并不能防止反序列化和反射生成多個(gè)實(shí)例。我們簡單看一下反射的破壞的測試類:
public class DestructionSingletonTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class<LazyInnerClassSingleton> enumSingletonClass = LazyInnerClassSingleton.class;
//枚舉默認(rèn)有個(gè)String 和 int 類型的構(gòu)造器
Constructor constructor = enumSingletonClass.getDeclaredConstructor();
constructor.setAccessible(true);
//利用反射調(diào)用構(gòu)造方法兩次直接創(chuàng)建兩個(gè)對象,直接破壞單例模式
LazyInnerClassSingleton singleton1 = (LazyInnerClassSingleton) constructor.newInstance();
LazyInnerClassSingleton singleton2 = (LazyInnerClassSingleton) constructor.newInstance();
}
}
這里序列化反序列化單例模式破壞老貓偷個(gè)懶,因?yàn)橄旅鏁?huì)有寫到,有興趣的小伙伴繼續(xù)看下文,老貓覺得這種破壞場景在真實(shí)的業(yè)務(wù)使用場景比較極端,如果不涉及底層框架變動(dòng),光從業(yè)務(wù)角度來看,上面這些單例模式的實(shí)現(xiàn)已經(jīng)管夠了。當(dāng)然如果硬是要防止上面的反射創(chuàng)建單例兩次問題也能解決,如下:
public class LazyInnerClassSingleton {
private LazyInnerClassSingleton () {
if(LazyHolder.LAZY != null) {
throw new RuntimeException("不允許創(chuàng)建多個(gè)實(shí)例");
}
}
public static final LazyInnerClassSingleton getInstance() {
return LazyHolder.LAZY;
}
private static class LazyHolder {
private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
}
}
寫到這里,可能大家都很疑惑了,咋還沒提及用單例模式優(yōu)化線程池創(chuàng)建。下面這不來了么,老貓個(gè)人覺得上面的這種方式進(jìn)行創(chuàng)建單例還是比較好的,所以就用這種方式重構(gòu)一下線程池的創(chuàng)建,具體代碼如下:
public class InnerClassLazyThreadPoolHelper {
public static void execute(Runnable runnable) {
ThreadPoolExecutor threadPoolExecutor = ThreadPoolHelperHolder.THREAD_POOL_EXECUTOR;
threadPoolExecutor.execute(runnable);
}
/**
* 靜態(tài)內(nèi)部類創(chuàng)建實(shí)例(單例).
* 優(yōu)點(diǎn):被調(diào)用時(shí)才會(huì)創(chuàng)建一次實(shí)例
*/
public static class ThreadPoolHelperHolder {
private static final int CPU = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = CPU + 1;
private static final int MAXIMUM_POOL_SIZE = CPU * 2 + 1;
private static final long KEEP_ALIVE_TIME = 1L;
private static final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
private static final int MAX_QUEUE_NUM = 1024;
private ThreadPoolHelperHolder() {
}
private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TIME_UNIT,
new LinkedBlockingQueue<>(MAX_QUEUE_NUM),
new ThreadPoolExecutor.AbortPolicy());
}
}
到此就結(jié)束了嗎?當(dāng)然不是,我們之前說上面這種單例創(chuàng)建模式的弊端是可以被反射或者序列化給攻克,雖然這種還是比較少的,但是技術(shù)么,還是稍微鉆一下牛角尖。有沒有一種單例模式不懼反射以及單例模式呢?顯然是有的。我們看下被很多人認(rèn)為完美單例模式的枚舉類的寫法。
枚舉式單例模式
public enum EnumSingleton {
INSTANCE;
private Object object;
public Object getObject() {
return object;
}
public void setObject(Object object) {
this.object = object;
}
public static EnumSingleton getInstance(){
return INSTANCE;
}
}
上面我們寫過反射模式破壞“靜態(tài)內(nèi)部類單例模式”,那么這里咱們補(bǔ)一下序列化反序列化的例子。具體如下:
public class EnumSingletonTest {
public static void main(String[] args) {
try {
EnumSingleton instance2 = EnumSingleton.getInstance();
instance2.setObject(new Object());
FileOutputStream fileOutputStream = new FileOutputStream("EnumSingletonTest");
ObjectOutputStream oos = new ObjectOutputStream(fileOutputStream);
oos.writeObject(instance2);
oos.flush();
oos.close();
FileInputStream fileInputStream = new FileInputStream("EnumSingletonTest");
ObjectInputStream ois = new ObjectInputStream(fileInputStream);
EnumSingleton instance1 = (EnumSingleton) ois.readObject();
ois.close();
System.out.println(instance2.getObject());
System.out.println(instance1.getObject());
}catch (Exception e) {
}
}
}
最終我們發(fā)現(xiàn)其輸出的結(jié)果是一致的。大家可以參考老貓的代碼自己寫一下測試,關(guān)于反射破壞的方式老貓就不展開了,因?yàn)樯厦嬉呀?jīng)有寫法了,大家可以參考一下,自行做一下測試。
那么既然枚舉類的單例模式這么完美,我們就拿它來重構(gòu)線程池的獲取吧。具體代碼如下:
public enum EnumThreadPoolHelper {
INSTANCE;
private static final ThreadPoolExecutor executor;
static {
final int CPU = Runtime.getRuntime().availableProcessors();
final int CORE_POOL_SIZE = CPU + 1;
final int MAXIMUM_POOL_SIZE = CPU * 2 + 1;
final long KEEP_ALIVE_TIME = 1L;
final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
final int MAX_QUEUE_NUM = 1024;
executor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TIME_UNIT,
new LinkedBlockingQueue<>(MAX_QUEUE_NUM),
new ThreadPoolExecutor.AbortPolicy());
}
public void execute(Runnable runnable) {
executor.execute(runnable);
}
}
當(dāng)然在上述中,針對賦值的方式老貓用了static代碼塊自動(dòng)類加載的時(shí)候就創(chuàng)建好了對象,大家也可以做一下其他優(yōu)化。不過還是得要保證單例模式。判斷是否為單例模式,老貓這里有個(gè)比較粗糙的辦法。我們打印出成員對象變量的值,通過多次調(diào)用看看其值是否一樣即可。當(dāng)然如果大家還有其他好辦法也歡迎留言。
總結(jié)
針對單例模式相信大家對其有了一個(gè)不錯(cuò)的認(rèn)識(shí)了。在日常開發(fā)的過程中,其實(shí)我們都接觸過,spring框架中,IOC容器本身就是單例模式的,當(dāng)然上述老貓也有提及到。框架中的單例模式,咱們等全部梳理完畢設(shè)計(jì)模式之后再去做深入探討。
關(guān)于單例模式的優(yōu)點(diǎn)也是顯而易見的:
- 提供了對唯一實(shí)例的受控訪問。
- 因?yàn)樵谙到y(tǒng)內(nèi)存中只存在一個(gè)對象,所以能夠節(jié)約系統(tǒng)資源,對于一些需要頻繁建立和銷毀的對象單例模式無疑能夠提升系統(tǒng)的性能。
那么缺點(diǎn)呢?大家有想過么?我們就拿上面的線程池創(chuàng)建這個(gè)例子來說事兒。我們整個(gè)業(yè)務(wù)系統(tǒng)其實(shí)有很多類別的線程池,如果說我們根據(jù)不同的業(yè)務(wù)類型去做線程池創(chuàng)建的拆分的話,咱們是不是需要寫很多個(gè)這樣的單例模式。那么對于實(shí)際的開發(fā)過程中肯定是不友好的。所以主要缺點(diǎn)可想而知。
- 因?yàn)閱卫J街袥]有抽象層,所以單例類的擴(kuò)展有很大的困難。
- 從開發(fā)者角度來說,使用單例對象(尤其在類庫中定義的對象)時(shí),開發(fā)人員必須記住自己不能使用new關(guān)鍵字實(shí)例化對象。
所以具體場景還得具體分析,上面的一些單例模式實(shí)現(xiàn),如果大家還有比較好的方式歡迎大家留言。
上面老貓聊到了不同業(yè)務(wù)調(diào)用創(chuàng)建不同業(yè)務(wù)線程池的問題,可能需要定義不同的threadFactory名稱,那么此時(shí),我們該如何去做?帶著疑問,讓我們期待接下來的其他模式吧。