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