確保對象的唯一性——單例模式


目錄(?)[+]

前言:

這是一篇我見過的講單例模式最完整的,也是講的最好的一篇博客文章。

3. 1 單例模式的動機

      對於一個軟件系統的某些類而言,我們無須創建多個實例。舉個大家都熟知的例子——Windows任務管理器,如圖3-1所示,我們可以做一個這樣的嘗試,在Windows的“任務欄”的右鍵彈出菜單上多次點擊“啓動任務管理器”,看能否打開多個任務管理器窗口?如果你的桌面出現多個任務管理器,我請你吃飯,微笑(注:電腦中毒或私自修改Windows內核者除外)。通常情況下,無論我們啓動任務管理多少次,Windows系統始終只能彈出一個任務管理器窗口,也就是說在一個Windows系統中,任務管理器存在唯一性。爲什麼要這樣設計呢?我們可以從以下兩個方面來分析:其一,如果能彈出多個窗口,且這些窗口的內容完全一致,全部是重複對象,這勢必會浪費系統資源,任務管理器需要獲取系統運行時的諸多信息,這些信息的獲取需要消耗一定的系統資源,包括CPU資源及內存資源等,浪費是可恥的,而且根本沒有必要顯示多個內容完全相同的窗口;其二,如果彈出的多個窗口內容不一致,問題就更加嚴重了,這意味着在某一瞬間系統資源使用情況和進程、服務等信息存在多個狀態,例如任務管理器窗口A顯示“CPU使用率”爲10%,窗口B顯示“CPU使用率”爲15%,到底哪個纔是真實的呢?這純屬“調戲”用戶,給用戶帶來誤解,更不可取。由此可見,確保Windows任務管理器在系統中有且僅有一個非常重要。

         圖3-1 Windows任務管理器

      回到實際開發中,我們也經常遇到類似的情況,爲了節約系統資源,有時需要確保系統中某個類只有唯一一個實例,當這個唯一實例創建成功之後,我們無法再創建一個同類型的其他對象,所有的操作都只能基於這個唯一實例。爲了確保對象的唯一性,我們可以通過單例模式來實現,這就是單例模式的動機所在。

3. 2 單例模式概述

      下面我們來模擬實現Windows任務管理器,假設任務管理器的類名爲TaskManager,在TaskManager類中包含了大量的成員方法,例如構造函數TaskManager(),顯示進程的方法displayProcesses(),顯示服務的方法displayServices()等,該類的示意代碼如下:

class TaskManager

{

     public TaskManager() {……} //初始化窗口

     public void displayProcesses()  {……} //顯示進程

     public void  displayServices() {……} //顯示服務

     ……

}

      爲了實現Windows任務管理器的唯一性,我們通過如下三步來對上類進行重構:

      (1)由於每次使用new關鍵字來實例化TaskManager類時都將產生一個新對象,爲了確保TaskManager實例的唯一性,我們需要禁止類的外部直接使用new來創建對象,因此需要將TaskManager的構造函數的可見性改爲private,如下代碼所示:

     private TaskManager() {……}

      (2)將構造函數改爲private修飾後該如何創建對象呢?不要着急,雖然類的外部無法再使用new來創建對象,但是在TaskManager的內部還是可以創建的,可見性只對類外有效。因此,我們可以在TaskManager中創建並保存這個唯一實例。爲了讓外界可以訪問這個唯一實例,需要在TaskManager中定義一個靜態的TaskManager類型的私有成員變量,如下代碼所示:

     private static TaskManager tm = null;

       (3)爲了保證成員變量的封裝性,我們將TaskManager類型的tm對象的可見性設置爲private,但外界該如何使用該成員變量並何時實例化該成員變量呢?答案是增加一個公有的靜態方法,如下代碼所示:

public static TaskManager getInstance()

{

    if (tm == null)

    {

        tm = new TaskManager();

    }

    return tm;

}

      在getInstance()方法中首先判斷tm對象是否存在,如果不存在(即tm == null),則使用new關鍵字創建一個新的TaskManager類型的tm對象,再返回新創建的tm對象;否則直接返回已有的tm對象。

      需要注意的是getInstance()方法的修飾符,首先它應該是一個public方法,以便供外界其他對象使用,其次它使用了static關鍵字,即它是一個靜態方法,在類外可以直接通過類名來訪問,而無須創建TaskManager對象,事實上在類外也無法創建TaskManager對象,因爲構造函數是私有的。 

 

思考

爲什麼要將成員變量tm定義爲靜態變量?

       通過以上三個步驟,我們完成了一個最簡單的單例類的設計,其完整代碼如下:

class TaskManager

{

     private static TaskManager tm = null;

     private TaskManager() {……} //初始化窗口

     public void  displayProcesses() {……} //顯示進程

     public void  displayServices() {……} //顯示服務

     public static TaskManager getInstance()

    {

        if (tm == null)

        {

            tm = new TaskManager();

        }

        return tm;

    }

    ……

}

      在類外我們無法直接創建新的TaskManager對象,但可以通過代碼TaskManager.getInstance()來訪問實例對象,第一次調用getInstance()方法時將創建唯一實例,再次調用時將返回第一次創建的實例,從而確保實例對象的唯一性。

      上述代碼也是單例模式的一種最典型實現方式,有了以上基礎,理解單例模式的定義和結構就非常容易了。單例模式定義如下: 

單例模式(Singleton Pattern):確保某一個類只有一個實例,而且自行實例化並向整個系統提供這個實例,這個類稱爲單例類,它提供全局訪問的方法。單例模式是一種對象創建型模式。

      單例模式有三個要點:一是某個類只能有一個實例;二是它必須自行創建這個實例;三是它必須自行向整個系統提供這個實例。

單例模式是結構最簡單的設計模式一,在它的核心結構中只包含一個被稱爲單例類的特殊類。單例模式結構如圖3-2所示:

      單例模式結構圖中只包含一個單例角色:

● Singleton(單例):在單例類的內部實現只生成一個實例,同時它提供一個靜態的getInstance()工廠方法,讓客戶可以訪問它的唯一實例;爲了防止在外部對其實例化,將其構造函數設計爲私有;在單例類內部定義了一個Singleton類型的靜態對象,作爲外部共享的唯一實例。


3.3 負載均衡器的設計與實現

Sunny軟件公司承接了一個服務器負載均衡(Load Balance)軟件的開發工作,該軟件運行在一臺負載均衡服務器上,可以將併發訪問和數據流量分發到服務器集羣中的多臺設備上進行併發處理,提高系統的整體處理能力,縮短響應時間。由於集羣中的服務器需要動態刪減,且客戶端請求需要統一分發,因此需要確保負載均衡器的唯一性,只能有一個負載均衡器來負責服務器的管理和請求的分發,否則將會帶來服務器狀態的不一致以及請求分配衝突等問題。如何確保負載均衡器的唯一性是該軟件成功的關鍵。

      Sunny公司開發人員通過分析和權衡,決定使用單例模式來設計該負載均衡器,結構圖如圖3-3所示:

        在圖3-3中,將負載均衡器LoadBalancer設計爲單例類,其中包含一個存儲服務器信息的集合serverList,每次在serverList中隨機選擇一臺服務器來響應客戶端的請求,實現代碼如下所示:

import java.util.*;

 

//負載均衡器LoadBalancer:單例類,真實環境下該類將非常複雜,包括大量初始化的工作和業務方法,考慮到代碼的可讀性和易理解性,只列出部分與模式相關的核心代碼

class LoadBalancer

{

       //私有靜態成員變量,存儲唯一實例

       private  static LoadBalancer instance = null;

       //服務器集合

       private  List serverList = null;

      

       //私有構造函數

       private  LoadBalancer()

       {

              serverList  = new ArrayList();

       }

      

       //公有靜態成員方法,返回唯一實例

       public  static LoadBalancer getLoadBalancer()

       {

              if  (instance == null)

              {

                     instance  = new LoadBalancer();

              }

              return  instance;

       }

      

       //增加服務器

       public  void addServer(String server)

       {

              serverList.add(server);

       }

      

       //刪除服務器

       public  void removeServer(String server)

       {

              serverList.remove(server);

       }

      

       //使用Random類隨機獲取服務器

       public  String getServer()

       {

              Random  random = new Random();

              int  i = random.nextInt(serverList.size());

              return  (String)serverList.get(i);

       }

}

       我們可以編寫如下客戶端代碼對其進行測試:

class Client

{

       public  static void main(String args[])

       {

         //創建四個LoadBalancer對象

              LoadBalancer  balancer1,balancer2,balancer3,balancer4;

              balancer1  = LoadBalancer.getLoadBalancer();

              balancer2  = LoadBalancer.getLoadBalancer();

              balancer3  = LoadBalancer.getLoadBalancer();

              balancer4  = LoadBalancer.getLoadBalancer();

             

              //判斷服務器負載均衡器是否相同

              if  (balancer1 == balancer2 && balancer2 == balancer3 &&  balancer3 == balancer4)

              {

                     System.out.println("服務器負載均衡器具有唯一性!");

              }

             

              //增加服務器

              balancer1.addServer("Server  1");

              balancer1.addServer("Server  2");

              balancer1.addServer("Server  3");

              balancer1.addServer("Server  4");

             

              //模擬客戶端請求的分發

              for  (int i = 0; i < 10; i++)

           {

            String server =  balancer1.getServer();

                     System.out.println("分發請求至服務器: " + server);

       }

       }

}

       編譯並運行程序,輸出結果如下:

服務器負載均衡器具有唯一性!

分發請求至服務器:  Server 1

分發請求至服務器:  Server 3

分發請求至服務器:  Server 4

分發請求至服務器:  Server 2

分發請求至服務器:  Server 3

分發請求至服務器:  Server 2

分發請求至服務器:  Server 3

分發請求至服務器:  Server 4

分發請求至服務器:  Server 4

分發請求至服務器:  Server 1

       雖然創建了四個LoadBalancer對象,但是它們實際上是同一個對象,因此,通過使用單例模式可以確保LoadBalancer對象的唯一性。


3.4 餓漢式單例與懶漢式單例的討論

      Sunny公司開發人員使用單例模式實現了負載均衡器的設計,但是在實際使用中出現了一個非常嚴重的問題,當負載均衡器在啓動過程中用戶再次啓動該負載均衡器時,系統無任何異常,但當客戶端提交請求時出現請求分發失敗,通過仔細分析發現原來系統中還是存在多個負載均衡器對象,導致分發時目標服務器不一致,從而產生衝突。爲什麼會這樣呢?Sunny公司開發人員百思不得其解。

      現在我們對負載均衡器的實現代碼進行再次分析,當第一次調用getLoadBalancer()方法創建並啓動負載均衡器時,instance對象爲null值,因此係統將執行代碼instance= new LoadBalancer(),在此過程中,由於要對LoadBalancer進行大量初始化工作,需要一段時間來創建LoadBalancer對象。而在此時,如果再一次調用getLoadBalancer()方法(通常發生在多線程環境中),由於instance尚未創建成功,仍爲null值,判斷條件(instance== null)爲真值,因此代碼instance= new LoadBalancer()將再次執行,導致最終創建了多個instance對象,這違背了單例模式的初衷,也導致系統運行發生錯誤。

      如何解決該問題?我們至少有兩種解決方案,在正式介紹這兩種解決方案之前,先介紹一下單例類的兩種不同實現方式,餓漢式單例類和懶漢式單例類:

1.餓漢式單例類

      餓漢式單例類是實現起來最簡單的單例類,餓漢式單例類結構圖如圖3-4所示:

       從圖3-4中可以看出,由於在定義靜態變量的時候實例化單例類,因此在類加載的時候就已經創建了單例對象,代碼如下所示:

public class EagerSingleton

{

private static final  EagerSingleton instance = new EagerSingleton();

private EagerSingleton() { }

public static EagerSingleton getInstance()

{

return instance;

}

}

      當類被加載時,靜態變量instance會被初始化,此時類的私有構造函數會被調用,單例類的唯一實例將被創建。如果使用餓漢式單例來實現負載均衡器LoadBalancer類的設計,則不會出現創建多個單例對象的情況,可確保單例對象的唯一性。

2.懶漢式單例類與線程鎖定

      除了餓漢式單例,還有一種經典的懶漢式單例,也就是前面的負載均衡器LoadBalancer類的實現方式。懶漢式單例類結構圖如圖3-5所示:

 

    從圖3-5中可以看出,懶漢式單例在第一次調用getInstance()方法時實例化,在類加載時並不自行實例化,這種技術又稱爲延遲加載(Lazy Load)技術,即需要的時候再加載實例,爲了避免多個線程同時調用getInstance()方法,我們可以使用關鍵字synchronized,代碼如下所示:

public class LazySingleton

{

private static LazySingleton instance = null;

 

private LazySingleton() { }

 

synchronized public  static LazySingleton getInstance()

{

if (instance == null)

{

instance = new  LazySingleton();

        }

return instance;

}

}

    該懶漢式單例類在getInstance()方法前面增加了關鍵字synchronized進行線程鎖,以處理多個線程同時訪問的問題。但是,上述代碼雖然解決了線程安全問題,但是每次調用getInstance()時都需要進行線程鎖定判斷,在多線程高併發訪問環境中,將會導致系統性能大大降低。如何既解決線程安全問題又不影響系統性能呢?我們繼續對懶漢式單例進行改進。事實上,我們無須對整個getInstance()方法進行鎖定,只需對其中的代碼“instance = new LazySingleton();”進行鎖定即可。因此getInstance()方法可以進行如下改進:

public static LazySingleton getInstance()

{

if (instance == null)

{

    synchronized  (LazySingleton.class)

{

instance = new LazySingleton();

            }

        }

return instance;

}

       問題貌似得以解決,事實並非如此。如果使用以上代碼來實現單例,還是會存在單例對象不唯一。原因如下:

      假如在某一瞬間線程A和線程B都在調用getInstance()方法,此時instance對象爲null值,均能通過instance == null的判斷。由於實現了synchronized加鎖機制,線程A進入synchronized鎖定的代碼中執行實例創建代碼,線程B處於排隊等待狀態,必須等待線程A執行完畢後纔可以進入synchronized鎖定代碼。但當A執行完畢時,線程B並不知道實例已經創建,將繼續創建新的實例,導致產生多個單例對象,違背單例模式的設計思想,因此需要進行進一步改進,在synchronized中再進行一次(instance == null)判斷,這種方式稱爲雙重檢查鎖定(Double-Check Locking)。使用雙重檢查鎖定實現的懶漢式單例類完整代碼如下所示:

public class LazySingleton

{

private volatile static  LazySingleton instance = null;

 

private LazySingleton() { }

 

public static LazySingleton getInstance()

{

if (instance == null)//第一重判斷

{

    synchronized  (LazySingleton.class)//鎖定代碼塊

{

if (instance == null)//第二重判斷

{

    instance =  new LazySingleton();

}

            }

        }

return instance;

}

}

       需要注意的是,如果使用雙重檢查鎖定來實現懶漢式單例類,需要在靜態成員變量instance之前增加修飾符volatile,被volatile修飾的成員變量可以確保多個線程都能夠正確處理,且該代碼只能在JDK 1.5及以上版本中才能正確執行。由於volatile關鍵字會屏蔽Java虛擬機所做的一些代碼優化,可能會導致系統運行效率降低,因此即使使用雙重檢查鎖定來實現單例模式也不是一種完美的實現方式。 

 

擴展

IBM公司高級軟件工程師Peter    Haggar 2004年在IBM developerWorks上發表了一篇名爲《雙重檢查鎖定及單例模式——全面理解這一失效的編程習語》的文章,對JDK    1.5之前的雙重檢查鎖定及單例模式進行了全面分析和闡述,參考鏈接:http://www.ibm.com/developerworks/cn/java/j-dcl.html

3.餓漢式單例類與懶漢式單例類比較

      餓漢式單例類在類被加載時就將自己實例化,它的優點在於無須考慮多線程訪問問題,可以確保實例的唯一性;從調用速度和反應時間角度來講,由於單例對象一開始就得以創建,因此要優於懶漢式單例。但是無論系統在運行時是否需要使用該單例對象,由於在類加載時該對象就需要創建,因此從資源利用效率角度來講,餓漢式單例不及懶漢式單例,而且在系統加載時由於需要創建餓漢式單例對象,加載時間可能會比較長。

      懶漢式單例類在第一次使用時創建,無須一直佔用系統資源,實現了延遲加載,但是必須處理好多個線程同時訪問的問題,特別是當單例類作爲資源控制器,在實例化時必然涉及資源初始化,而資源初始化很有可能耗費大量時間,這意味着出現多線程同時首次引用此類的機率變得較大,需要通過雙重檢查鎖定等機制進行控制,這將導致系統性能受到一定影響。


3.5 一種更好的單例實現方法

       餓漢式單例類不能實現延遲加載,不管將來用不用始終佔據內存;懶漢式單例類線程安全控制煩瑣,而且性能受影響。可見,無論是餓漢式單例還是懶漢式單例都存在這樣那樣的問題,有沒有一種方法,能夠將兩種單例的缺點都克服,而將兩者的優點合二爲一呢?答案是:Yes!下面我們來學習這種更好的被稱之爲Initialization on Demand Holder (IoDH)的技術。

      在IoDH中,我們在單例類中增加一個靜態(static)內部類,在該內部類中創建單例對象,再將該單例對象通過getInstance()方法返回給外部使用,實現代碼如下所示:

//Initialization on Demand Holder

public class Singleton

{

       private  Singleton()

{

       }

      

       private static class HolderClass

       {

              private final static Singleton  instance = new Singleton();

       }

      

       public static Singleton getInstance()

       {

              return HolderClass.instance;

       }

      

       public  static void main(String args[])

       {

              Singleton  s1, s2;

s1 = Singleton.getInstance();

              s2  = Singleton.getInstance();

              System.out.println(s1==s2);

       }

}

      編譯並運行上述代碼,運行結果爲:true,即創建的單例對象s1s2爲同一對象。由於靜態單例對象沒有作爲Singleton的成員變量直接實例化,因此類加載時不會實例化Singleton,第一次調用getInstance()時將加載內部類HolderClass,在該內部類中定義了一個static類型的變量instance,此時會首先初始化這個成員變量,由Java虛擬機來保證其線程安全性,確保該成員變量只能初始化一次。由於getInstance()方法沒有任何線程鎖定,因此其性能不會造成任何影響。

      通過使用IoDH,我們既可以實現延遲加載,又可以保證線程安全,不影響系統性能,不失爲一種最好的Java語言單例模式實現方式(其缺點是與編程語言本身的特性相關,很多面嚮對象語言不支持IoDH)。

 

練習

分別使用餓漢式單例、帶雙重檢查鎖定機制的懶漢式單例以及IoDH技術實現負載均衡器LoadBalancer

      至此,三種單例類的實現方式我們均已學習完畢,它們分別是餓漢式單例、懶漢式單例以及IoDH


3.6 單例模式總結

單例模式作爲一種目標明確、結構簡單、理解容易的設計模式,在軟件開發中使用頻率相當高,在很多應用軟件和框架中都得以廣泛應用。

1.主要優點

單例模式的主要優點如下:

(1) 單例模式提供了對唯一實例的受控訪問。因爲單例類封裝了它的唯一實例,所以它可以嚴格控制客戶怎樣以及何時訪問它。

(2) 由於在系統內存中只存在一個對象,因此可以節約系統資源,對於一些需要頻繁創建和銷燬的對象單例模式無疑可以提高系統的性能。

(3) 允許可變數目的實例。基於單例模式我們可以進行擴展,使用與單例控制相似的方法來獲得指定個數的對象實例,既節省系統資源,又解決了單例單例對象共享過多有損性能的問題。

2.主要缺點

單例模式的主要缺點如下:

(1) 由於單例模式中沒有抽象層,因此單例類的擴展有很大的困難。

(2) 單例類的職責過重,在一定程度上違背了“單一職責原則”。因爲單例類既充當了工廠角色,提供了工廠方法,同時又充當了產品角色,包含一些業務方法,將產品的創建和產品的本身的功能融合到一起。

(3) 現在很多面嚮對象語言(JavaC#)的運行環境都提供了自動垃圾回收的技術,因此,如果實例化的共享對象長時間不被利用,系統會認爲它是垃圾,會自動銷燬並回收資源,下次利用時又將重新實例化,這將導致共享的單例對象狀態的丟失。

3.適用場景

在以下情況下可以考慮使用單例模式:

(1) 系統只需要一個實例對象,如系統要求提供一個唯一的序列號生成器或資源管理器,或者需要考慮資源消耗太大而只允許創建一個對象。

(2) 客戶調用類的單個實例只允許使用一個公共訪問點,除了該公共訪問點,不能通過其他途徑訪問該實例。

 

思考

如何對單例模式進行改造,使得系統中某個類的對象可以存在有限多個,例如兩例或三例?【注:改造之後的類可稱之爲多例類。】



原文作者:劉偉  http://blog.csdn.net/lovelion
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章