讓設計模式飛一下子|單例模式

👉本文章全部文字純原創,若是須要轉載,請註明轉載出處,謝謝!😘java

哈嘍,你們好,我是高冷就是範兒,又和各位見面了。😎從今天開始,我將正式開始設計模式系列文章的寫做分享。今天要和你們分享的是GOF23模式中第一個模式——單例模式。這個模式號稱是GOF23全部設計模式中最簡單的一個設計模式。不過,等你看完這篇文章後會發現,這句話聽聽就好,別當真。😂單例模式簡單嗎?這是不存在的,要想吃透裏面的細節並不容易,尤爲是初學者。可是單例模式在實際生活和開發中,倒是大量的被使用到,所以,這個模式咱們是須要深刻學習掌握的。下面不廢話直入主題。git

dp-1-0

爲何要使用單例模式

單例模式屬於上篇說過的設計模式三大分類中的第一類——建立型模式,天然是跟對象的建立相關,並且聽這名字——單例,也就是說,這個模式在建立對象的同時,還致力於控制建立對象的數量,是的,只能建立一個實例,多的不要。或者從這一方面講,它確實是最簡單的模式。每一個Java程序員都知道,Java中的對象都是使用new關鍵字來加載類並在堆內存中開闢空間建立對象,這是平時用到最多建立對象的方式。也知道每次new都會產生一個全新的對象。一直這樣用着,好像歷來沒以爲有啥很差,更沒有怎麼思考過,這玩意竟然還要去控制它的數量。程序員

👉那麼問題來了,到底咱們爲何要控制對象建立的個數?直接new一下多省事啊❓github

既然這個模式存在而且大量使用,說明有些場景下,沒它還真不行。那麼什麼場景下會沒它不行呢?我舉個栗子🌰,好比咱們平時使用的Windows上的回收站,是否是隻有一個?要是有多個,會發生什麼?我剛把回收站清空了,換到另外一個回收站看垃圾還在,那這垃圾究竟是在,仍是不在?是否是很詭異了?另外好比博客上會有一個博客訪問人數統計,這個東西要是否是單例的會有啥問題?今天統計了流量有100個,次日用了一個新的計數器,又回到0了從新開始統計,那這個統計還有意義嗎?數據庫

也就是說,有些場景下,不使用單例模式,會致使系統同一時刻出現多個狀態缺少同步,用戶天然沒法判斷當前處於什麼狀態設計模式

在技術領域,單例模式的場景更是不可勝數。緩存

好比XXX池的基本都是單例,爲何呢?對象的建立是一個比較耗時耗費資源的過程,尤爲是像線程、數據庫鏈接等,都屬於使用很是頻繁,可是建立銷燬又很是耗時的資源,若是不使用池來控制其數量和建立,會對性能形成極大的影響。另外,像線程池中的線程,可能會須要相互通訊,若是不是在同一個池中,對通訊也會有影響。安全

經過控制建立對象的數量,能夠節約系統資源開銷多線程

另外像應用中的日誌系統,通常也會採用單例模式。這樣全部的日誌都會統一追加,方便後續管理。併發

讀取配置的類通常會使用一個單例去統一加載讀取。由於通常配置只會在應用啓動時加載一次,並且會須要給整個應用全部對象共享。

全局數據共享

還有在各大主流開源框架以及JDK源碼當中,也是大量使用到這種模式。後續我也會拋磚引玉給你們舉兩個例子。

正是存在上這些痛點,使得有時候咱們建立對象還真不能再簡單任性直接new一下,須要對其作一些精細控制。那怎麼才能控制只建立一個對象呢?

通過無數前人總結,通常有如下這些經典的解決方案,

分類

餓漢式

啥叫餓漢式?餓了就馬上想到吃,類比到建立對象也是如此,當類一初始化,該類的對象就馬上會被實例化。

👉怎麼實現?

代碼以下:

public class HungrySingleton {
  private HungrySingleton() {} ❶  

  private static HungrySingleton instance = new HungrySingleton();❷

  public static HungrySingleton getInstance() {
    return instance;
  }
}
複製代碼

當外部調用HungrySingleton.getInstance()時,就會返回惟一的instance實例。爲何是惟一的?

這代碼中有幾個要點

  • 標註❶處該類的構造器用private修飾,防止外部手動經過new建立。後面的例子都須要這樣,後面就不解釋了。
  • 標註❷處是核心,instance使用static修飾,而後調用new建立對象,咱們知道static修飾的東西都屬於類,並且在類加載階段就已經被加載,而且只能被加載一次。就是類加載這種特性很好的保證了單例的特性,也自然防止了併發的問題。

臥槽,單例模式竟然如此簡單,這麼輕鬆就完成一個,這就算完事了?呵呵......

這個代碼確實實現了單例的效果,只要調用HungrySingleton.getInstance(),你就算是神仙也造不出第二個對象......(其實後面會知道,仍是有辦法的)

👉可是想一想,這個方法有啥問題沒?

沒錯,一旦類初始化時就會建立這個對象,有人說,建立就建立唄,這有啥大不了的?大部分狀況下確實是沒啥問題,可是若是建立這個對象極其耗費時間和資源呢?這樣必然會形成巨大的性能損耗。

另外還有一種狀況,有的時候我只是想單純的加載一下類,但並不想去用該對象,那這個時候這種模式就屬於浪費內存了。什麼意思?我舉個栗子🌰,以下代碼,其餘代碼和上面同樣,就是加了❶行,而後我如今外部調用HungrySingleton.flag,會發生什麼?

public class HungrySingleton {
  private HungrySingleton() {} 
	
  private static int flag = 1;	❶
  private static HungrySingleton instance = new HungrySingleton();

  public static HungrySingleton getInstance() {
    return instance;
  }
}
複製代碼

學過Java類加載機制的都知道,當去訪問一個類的靜態屬性的時候會觸發該類初始化,這就致使,我明明只是想使用一下flag屬性,並不想用HungrySingleton對象,但因爲你訪問了flag致使HungrySingleton的初始化,從而致使instance被實例化,形成內存泄露。

看來這種方案可行,但不是完美的,那有啥更好方案,既能保證只建立單個對象,又能夠作到真正須要使用該對象時再建立它(延遲加載),歷來達到節約系統資源目的?答案固然是有的!

懶漢式

和餓漢相反,懶漢天然是很懶,能不吃就不吃飯,等到實在餓得不行了(須要用該對象了)纔去吃飯(建立對象)。

👉怎麼實現?

代碼以下:

public class LazySingleton {
   private static LazySingleton instance = null;	❶
     
	 private LazySingleton() {	
 	 }	
  public static LazySingleton getInstance() {	❷
    if(instance == null){ ❸
      instance = new LazySingleton();
    }
    return instance;
  }
}
複製代碼

關注❶處,類加載時不會馬上建立對象了,而後當LazySingleton.getInstance()調用❷處方法時,經過判斷instance == null,若是有了就不建立了,沒有就纔會建立。

哈哈,既實現了延遲加載,節約資源,又保證了單例,貌似沒毛病。飄了~😎

沒錯,在單線程下面確實如此,惋惜忽略了多線程場景。爲何在多線程下會有問題?分析一下,如今有兩個線程A和B,同時到達❸處,天然此時instance = new LazySingleton()這一行沒被調用過,天然❸處條件成立,而後A和B同時進入了if{}代碼塊,後面的事情就知道了,A和B線程都會調用instance = new LazySingleton(),從而建立多個對象,破壞了單例。

那怎麼辦?有併發問題?那就加鎖同步唄......

public class LazySingleton {
  private LazySingleton() {	
  }	
  private static volatile LazySingleton instance = null;	❶

  public static synchronized LazySingleton getInstance() { ❷
    if(instance == null){
      instance = new LazySingleton();
    }
    return instance;
  }
}
複製代碼

這代碼中有幾個要點

  • 標註❶處其它和上面同樣,多了一個volatile修飾,這主要是爲了保證多線程下內存可見性。由於高速緩存關係,一個線程的修改並不必定要實時同步到另外一線程,volatile能夠用來解決這個問題。
  • 標註❷處加synchronized同步鎖,能夠保證同一時刻只會有一個線程進入getInstance()方法,天然只會有一個線程調用instance = new LazySingleton(),單例天然就保證了。但同時這個帶來了一個新問題,由於每一個線程無論instance有沒有被建立過,都會去調用getInstance(),由於if(instance == null)是須要進入方法後才判斷的,然而getInstance()又是一個同步的,同一時刻只會有一個線程進入,其他線程都必須等待,這就會致使線程阻塞,致使性能降低。

上述方法確實實現了延遲建立對象,可是性能低下的問題如何解決?聰明的攻城獅們又想到了新的方案......

雙重檢測鎖

👉怎麼實現?

代碼以下:

public class LazySingleton {
    private LazySingleton() {
    }	
    
    private static volatile LazySingleton instance = null; ❷
    public static LazySingleton getInstance(){ ❶
      if(instance == null){ ❸	
        synchronized (LazySingleton.class) { ❺
          if(instance == null)	❹
          instance = new LazySingleton();❻
        }
      }
      return instance;
    }
  }
複製代碼

這個代碼看上去會比較複雜,講幾個關注點:

  • 標註❶處方法內部和上面例子代碼最大的區別在於,有❸❹兩處if判斷。爲何要兩次判斷?

    • **第一次if判斷是爲了提升效率。**怎麼理解?回顧懶漢式方案代碼,synchronized將整個方法加同步鎖,也就是說,無論外部(線程)在調用getInstance()方法這一刻該對象是否已經被建立好,都須要阻塞等待。而❸處的if判斷就使得,只有此時真的尚未建立出對象纔會進入synchronized代碼塊,若是已經建立了就直接return了,因此顯然提升性能了。
    • **那麼❹處的第二次if判斷又是爲何?這纔是用來保證多線程安全的。**又怎麼理解?設想下面這種場景。A和B兩個線程同時來到❺處,此時由於synchronized緣故,只能有一個線程進入,假設A拿到了這把鎖,進入synchronized代碼塊,而後經過❻建立出了一個LazySingleton實例,而後離開synchronized代碼塊,而後把鎖釋放了,可是還沒等到它return的時候,B線程拿到了這把鎖,進入synchronized代碼塊,此時要是沒有❹處if判斷,B線程照樣能夠來到❻處,以迅雷不及掩耳之勢噼裏啪啦一頓操做,又建立出一個LazySingleton實例。顯然此時,單例模式已經被破壞了。因此❹處的判斷也不可省略。
  • 標註❷處看上去和懶漢式的代碼沒區別,可是這邊volatile語義已經發生改變,已經不單純是爲了內存可見的問題了,還涉及到指令重排序的問題。怎麼理解?一切問題出在❻處。震驚!❻處看似日常的一行代碼竟然會有問題。是的,下面我來詳解。❻處會建立一個LazySingleton實例,而且賦值給instance變量,很遺憾,這一個動做在指令層面並不是原子操做。這個動做能夠分爲4步,

    1.申請內存空間

    2.初始化默認值

    3.執行構造器初始化

    4.將instance指向建立的對象

    而有些編譯器會對代碼作指令重排序,由於3和4自己相互並不存在依賴,指令重排序的存在可能會致使3和4順序發生顛倒。這會有什麼問題?首先在單線程下並不會有什麼問題,爲何?由於指令重排序的前提就是不改變在單線程下的結果,不管先執行3仍是4,最後返回的對象都是初始化好後的。可是在多線程下呢?設想一種極端場景,如今假設A線程拿到鎖進入到❻處,而後它完成了上面4步的1和2,由於如今指令重排序了,下面A線程會將instance指向建立的對象,也就是說,此時instance != null了!而後正當A要去執行構造器初始化對象時,巧得很,這時候B線程來到❸處,判斷instance == null不成立了,直接返回,獨留A線程在原地罵娘「尼瑪,我™還沒初始化對象呢......」,由於返回了一個沒有通過初始化的對象,後續操做天然會有問題。正是由於這個緣由,因此❷處volatile不可省略,主要緣由就在於防止指令重排序,避免上述問題。

那是否是這樣就萬無一失了呢?很遺憾,上述如此嚴密的控制,仍是不能徹底保證出問題。What?那就是上述的作法有個前提,JDK必須是JDK5或更高版本,由於從JDK5纔開始使用新的JSR-133內存模型規範,而在這個規範中才加強了volatile這個語義......

臥槽,原來搞了大半天仍是有問題啊......心好累,並且說實話,就算不考慮JDK版本這個問題,這種方案的實現代碼太過醜陋,自己看着就不是很爽,並且考慮的東西太多,稍有閃失就GG了。因此,這種方案雖然分析了這麼多,可是其實沒有實際意義,實際工做中強烈不建議使用。那還有沒有好的方案啊?固然有啊!

靜態內部類實現

根據類加載機制,外部類的初始化並不會致使靜態內部類的初始化。

👉怎麼驗證?

以下代碼:

public class Demo {
    private static int a = 1;
    private static class Inner{
        static {
            System.out.println("Inner loading ...");
        }
    }
    public static void main(String[] args) {
        System.out.println(Demo.a);
    }
}
複製代碼

經過Demo.a引用外部類Demo的靜態變量a,會致使外部類的初始化,若是Inner被初始化了,必然會執行static塊,從而打印"Inner loading ...",然而很遺憾,這個代碼執行結果只會打印出「1」。這也印證了開始的結論。有了這個結論,咱們就能夠利用它實現優雅的單例了。哈哈~😍

👉怎麼實現?

代碼以下:

public class StaticInnerSingleton {
    private StaticInnerSingleton() {
    }
    private static class StaticInnerSingletonInstance { ❶
      private static final StaticInnerSingleton instance = new StaticInnerSingleton();
    }
    public static StaticInnerSingleton getInstance() { ❷
      return StaticInnerSingletonInstance.instance;
    }
}
複製代碼

講幾個關注點:

  • ❶處StaticInnerSingletonInstance是一個靜態內部類,內部靜態字段instance負責建立對象。由於上面的結論,因此固然外部類StaticInnerSingleton初始化時,並不會致使StaticInnerSingletonInstance初始化,進而致使instance的初始化。因此實現了延遲加載。

  • 當外部調用❷處getInstance()時,經過StaticInnerSingletonInstance.instanceinstance引用纔會致使對象的建立。因爲static的屬性只會跟隨類加載初始化一次,自然保證了線程安全問題。

這個方案算是完美解決了上述全部方案的問題,且保留了全部的優勢。算是一個完美方案。

還有沒有其它方案?必須的!

枚舉實現

用枚舉實現單例是最簡單的了,由於,Java中的枚舉類型自己就自然單例的,

👉怎麼實現?

代碼以下:

enum EnumSingletonInstance{
   INSTANCE;
   public static EnumSingletonInstance getInstance(){
   		return INSTANCE;
   }
}
複製代碼

惟一遺憾的是,這個方案和餓漢式同樣,無法延遲加載。枚舉類加載天然就會初始化INSTANCE

經常使用的單例模式的方案基本就是這些。那這樣是否是就真的萬無一失了呢?很遺憾的告訴你們,上述這些方法還不是絕對能保證只建立一個對象。mmp......我擦,我就想玩個單例咋這麼累呢?心塞......😭是的,上面的方案除了枚舉方案,其他方案均可以被破解。下面咱們來了解一下。

破解單例

破解單例有兩種方法,反射或者反序列化。下面我用代碼作簡單演示。以餓漢式爲例,其他模式同理,你們能夠自行測試。

反射

👉怎麼破解?

代碼以下:

//餓漢式的代碼省略,參考前面餓漢式章節
public static void main(String[] args) throws Exception {
        System.out.println(HungrySingleton.getInstance());
        System.out.println(HungrySingleton.getInstance());
        System.out.println("反射破解單例...");
        HungrySingleton instance1 = HungrySingleton.class.newInstance();
        HungrySingleton instance2 = HungrySingleton.class.newInstance();
        System.out.println(instance1);
        System.out.println(instance2);
}
複製代碼

輸出結果如圖,很清楚的看到單例被破解了。

dp-1-1

👉如何防止?

很簡單,由於Class.newInstance()是經過調用HungrySingleton無參構造器建立對象的,只要在構造器中加入有如下邏輯便可。這樣,當類初始化時,第一次正常建立出實例並賦值給instance。當再想經過反射想要破解單例時,天然會拋出異常阻止繼續實例化。

//餓漢式的其它代碼,參考前面餓漢式章節
private HungrySingleton() {
    if (instance != null) {
      try {
        throw new Exception("只能建立一個對象!");
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
}
複製代碼

反序列化

👉怎麼破解?

另外,經過序列化和反序列化也能夠破解單例。(前提是單例類實現了Serializable接口)代碼以下:

public static void main(String[] args) throws Exception {
        System.out.println(HungrySingleton.getInstance());
        System.out.println(HungrySingleton.getInstance());
        System.out.println("反序列化破解單例...");
        HungrySingleton instance1 = HungrySingleton.getInstance();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(baos);
        out.writeObject(instance1);	//序列化
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
        HungrySingleton instance2 = (HungrySingleton) ois.readObject();	//反序列化
        System.out.println(instance1);
        System.out.println(instance2);
}
複製代碼

輸出結果如圖,很清楚的看到單例也被破解了。

dp-1-2

👉如何防止?

也很是簡單,只須要在單例類中添加以下readResolve()方法,而後在方法體中返回咱們的單例實例便可。爲何?由於readResolve()方法是在readObject()方法以後才被調用,於是它每次都會用咱們本身生成的單實例替換從流中讀取的對象。這樣天然就保證了單例。

private Object readResolve() throws ObjectStreamException{
  return instance;
}
複製代碼

如何選擇

關於單例模式的所有內容就是這些,最後來作個總結,那麼這麼多的單例模式實現方案咱們到底須要選擇哪一個呢?技術選型歷來不是非黑即白的問題,而是須要根據你的實際應用場景決定的。不過從上述各類單例模式的特色,咱們能夠得出如下結論:

  • 從安全性角度考慮,枚舉顯然是最安全的,保證絕對的單例,由於能夠自然防止反射和反序列化的破解手段。而其它方案必定場合下所有能夠被破解。

  • 從延遲加載考慮,懶漢式、雙重檢測鎖、靜態內部類方案均可以實現,然而雙重檢測鎖方案代碼實現複雜,並且還有對JDK版本的要求,首先排除。懶漢式加鎖性能較差,而靜態內部類實現方法既可以延遲加載節約資源,另外也不須要加鎖,性能較好,因此這方面考慮靜態內部類方案最佳。

👉通常選用原則

  • 單例對象佔用資源少,不須要延時加載:枚舉式好於餓漢式。
  • 單例對象佔用資源大,須要延時加載:靜態內部類式好於懶漢式。

拋磚引玉

前面也提到,單例模式在開源框架中被使用的很是之多,下面我就拋磚引玉挑選幾處給你們講解一下,

下面這個代碼截取自Mybatis中,這是一個典型的使用靜態內部類方式實現單例。

public abstract class VFS {
  	... //省略大量無關代碼
    private static class VFSHolder {
        static final VFS INSTANCE = createVFS();

        static VFS createVFS() {
            ... //省略建立過程
        }
    }
		... //省略大量無關代碼
    public static VFS getInstance() {
        return VFSHolder.INSTANCE;
    }
  	... //省略大量無關代碼
}
複製代碼

在JDK底層也是大量使用了單例模式,好比,Runtime類是JDK中表示Java運行時的環境的一個類,其內部實現也是採用單例模式,由於一個應用程序只須要一個運行時的環境便可,而且是採用餓漢式方式實現。

public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {
    }
}
複製代碼

最後囉嗦一句

在結束以前,我再多說一句,網上有些文章常常會用Spring的單例bean和Mybatis中的ErrorContext類做爲單例模式的例子,其實這是有問題的

  • 首先,Spring中的單例bean跟本文講的單例模式並無關係,不是一回事,可能也就名字比較像,這也是容易混淆的地方,實現方式固然也就天差地別了。Spring的單例bean是經過將指定類首次建立的bean進行緩存,後續去獲取的時候,若是設置爲singleton,就直接會從緩存中返回以前緩存的對象,而不會建立新對象。可是這個是有前提的,那就是在同一個容器中。若是在你的JVM中存在多個Spring容器,該類也就會建立多個實例了。因此這是不能算是真正的單例模式。本文上述描述的單例模式是指JVM進程級別的,也就是說,只要是在同一個JVM中,單例類只會存在一個對象。

  • Mybatis中的ErrorContext類中採用的是ThreadLocal機制保證同一個線程跟惟一一個ErrorContext實例綁定,可是這個也是有前提的,那就是在線程範圍內的,在每個線程內部,確實作到了只建立一個實例,可是從應用級別或者JVM級別依然不是單例,因此不能將其稱之爲單例模式。

一言以蔽之,真正的單例模式,是指JVM進程級別的

好了,今天關於單例模式的技術分享就到此結束,下一篇我會繼續分享另外一個設計模式——工廠模式,一塊兒探討設計模式的奧祕。我們不見不散。😊👏


  • 今天的技術分享就分享到這裏,感謝您百忙抽出這麼長時間閱讀個人文章😊。
  • 另外,個人學習過程當中一些記錄和心得,還有文章都會首先在個人github上更新,不嫌棄能夠star關注一下。
    個人github主頁:github.com/dujunchen/B…
相關文章
相關標籤/搜索