單例模式是軟件開發中非常普遍的一種模式。它的主要作用是確保系統中,始終只存在一個類的實例對象。
這樣做的好處有兩點:
1、對於需要頻繁使用的對象,在每次使用時,如果都需要重新創建,並且這些對象的內容都是一樣的。則不但提高了jvm的性能開銷(堆中開辟新地址,同時降低GC效率等),同時還會降低代碼的運行效率。倘若始終在堆中只存在唯一的一個實例對象。任何方法在使用時,均直接訪問這個實例對象,則大大提高了系統的運行效率。
2、可以更好的維護對象,倘若系統中存在多個相同的實例對象,而一旦這些實例對象的屬性發生了改變,則需要通知系統中所有的實例對象均發生相同的改變,才能保證數據的有效性和唯一性。但是當系統的復雜度到達一定的量級後,維護這種場景的開銷會越來越大。比如:如何通知到所有的類實例?或者出現多線程場景後,如何保證所有的實例對象的屬性狀態保持同步修改?單例模式可以很好的解決這個問題,因為整個系統中,只存在一個該類的實例對象。
單例模式實現的核心就是,通過創建方法,始終返回的都是一個唯一的實例。
下面依次介紹開發中常用的幾種實現方式,以及他們的優缺點:
最簡單的形式:
1 public class Singleton 2 { 3 private Singleton() 4 { 5 //do sth 6 7 } 8 9 private static Singleton instance=new Singleton(); 10 11 public static Singleton getInstance() 12 { 13 return instance; 14 } 15 }
這樣實現單例模式的好處是,實現的邏輯簡單,易於閱讀和使用。缺點是由於instance使用的是類靜態字段並且直接初始化,所以在jvm加載該類時,就會直接創建該實例。而我們或許始終都不會使用該實例。倘若示例中的構造函數do sth部分是非常耗時的部分,則會導致加載類的初期,系統的響應速度持續走高,並且在jvm堆中始終都會存在這個對象實例,形成內存的浪費。
ps 有些人可能會很難理解,既然jvm加載該類時,就代表我們會使用該對象了,為什麼還會存在該實例不會被使用的場景?這裡舉個例子,比如需要用到這個類的某個靜態字段,或者靜態方法或者這個類被反射到,jvm都會加載該類。
為了解決這個問題,開發者們後來又想到了一種延時加載的方法:
1 public class Singleton 2 { 3 private Singleton() 4 { 5 //do sth 6 } 7 8 private static Singleton instance = null; 9 10 public static synchronized Singleton getInstance() 11 { 12 if(instance == null) 13 { 14 instance=new Singleton(); 15 } 16 return instance; 17 } 18 }
之所以給這個方法加入一個同步保護,是由於可能存在多線程的場景,線程A首先進入獲取實例的方法,判斷instance為null,則開始運行構造函數,而線程B同時進入該方法,由於構造方法尚未運行結束,因此instance仍然為null,所以線程B仍然會調用構造函數。從而破壞單例的唯一性。
但是單例,勢必會造成線程等待,我們讓單例類的構造函數只運行一次,為的就是快,而現在反而又為了線程安全,使速度降下來。有些人或許會覺得一個小小的同步,影響性能並不大,可是如果出現高並發時,最後一個線程等待的時間,是之前線程等待時間的累加,《java程序性能優化》書中曾經做過嘗試,在五個線程同時調用以上代碼時,耗費時間是390ms,而非延時加載的方法(第一種方法)耗時為0ms(也就是未到達1個ms),兩者相差甚多。
不延時,可能會讓系統無用開銷過多,而延時又為了保證線程安全,造成額外的開銷,究竟應該使用哪種呢?
我個人建議,如果是服務端的話(客戶端則更多的需要根據使用場景來斟酌),建議使用第一種。原因如下:
1)方法簡潔,不容易出錯。(這個我認為非常重要,很多人可能覺得無所謂)
2)硬件現在越來越廉價,用空間換時間大部分情況下是非常劃算的。
3)大部分客戶端更關心的是服務器在運行期的響應時間,而非服務器在啟動時的快慢。(這裡的表述不太嚴謹)
盡管如此,我們還是希望又可以做到延時加載,又能不讓線程存在等待。於是有人想到了以下的方式:
1 public class Singleton 2 { 3 private Singleton() 4 { 5 //do sth 6 } 7 8 private static Singleton instance = null; 9 10 public static Singleton getInstance() 11 { 12 if(instance==null) 13 { 14 synchronized(Singleton.class) 15 { 16 if(instance==null) 17 { 18 instance=new Singleton(); 19 } 20 } 21 } 22 return instance; 23 } 24 }
這樣做的好處是,將線程等待的區間段縮減至最低,只在類初期初始化時,增加線程安全的保護。倘若已經創建成功,則再次獲取實例的線程是不需要再次等待的。
個人不建議這種寫法,因為看著別扭,不方便閱讀,雙重鎖盡管使用廣泛,但是畢竟第一次閱讀時,還是需要仔細分析下,畢竟java中還有很多其他實現單例的優雅的方式。
ps 該種方法並不適用於在JDK1.5之前,這並不是由於語法的錯誤,而是由於java的內存模型自身的問題:簡而言之就是,由於jvm指令順序的優化,可能會導致先給instance賦予了一段堆內存,然後才在該堆內存上初始化該對象。在instance變量賦值成功後,退出同步代碼塊。新線程進入判斷條件,發現instance仍然未初始化,所以再次開始初始化該變量。導致instance被反復初始。在jdk1.5以後推出了volatile關鍵字,我們可以用該關鍵字修飾instance變量,從而防止jvm優化該段指令。
那麼還有什麼辦法來解決這個方法呢?聰明的人想到了使用內部類來保存instance的持有。
1 public class Singleton 2 { 3 private Singleton() 4 { 5 // do sth 6 } 7 8 private static class SingletonInner 9 { 10 private static Singleton instance = new Singleton(); 11 } 12 13 public static Singleton getInstance() 14 { 15 return SingletonInner.instance; 16 } 17 }
前文所述的例子,其實無外乎存在兩個問題,第一最好使用延時加載,最好延時加載的時機是我真正要用到實例的時候,而非加載單例類的時候。第二,開始使用前,就已經加載好單例了,別讓我出現等待。
而靜態內部類可以很好的解決這個問題:1加載該類的時候(調用靜態字段,靜態方法時),並不會調用構造函數創建實例。2真正需要實例時,實例是保存在在靜態內部類中的字段的,靜態內部類此時才會被加載,而單例類此時就會創建實例<clinit>()方法,所以多線程進入時,字段已經被初始化完畢了。這種形式的單例也是我非常喜歡的一種單例形式,不但閱讀方便,同時還很好的彌補了其他單例的一些弊端。
最後再介紹一種利用關鍵字很好的解決了單例問題的方式:
什麼關鍵字生來就可以保證一個實例而生的呢?這就是枚舉。
先看代碼
1 public enum Singleton 2 { 3 instance(); 4 Singleton() 5 { 6 // do sth 7 } 8 9 public final void A() 10 { 11 12 } 13 }
了解枚舉的人都知道每一個枚舉項都是該類的一個實例,而該類也不可以再創造出其他更多的實例。同時通過反射和正反序列化的形式,其實是可以突破前文中示例的單例限制的,即創造出多個實例(雖然如此,我也沒怎麼見過需要各種防范這些問題的)。而使用枚舉,可以通過java自身的機制,很好的解決這些問題。這也是《Effective java》的作者非常建議的形式。不過盡管這本書非常暢銷,而且評價很高,但是卻很少見到使用這種寫法的地方。
說了這麼多,我們也應該再來談談單例模式的缺點:
1、單例模式不容易拓展,類的構造函數被私有化,子類根本無法執行父類的構造方法
2、開發過程中,為了盡可能的保證,單例一旦構造好,就可以方便直接使用的目的,往往在單例中加入大量的方法,從而使單例類的職責很模糊,很多功能無法界定是否應該由該類來負責,違反了面相對象的基本原則。