程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java模式設計之單例模式(四)

Java模式設計之單例模式(四)

編輯:關於JAVA

不完全的單例類

什麼是不完全的單例類

估計有些讀者見過下面這樣的“不完全”的單例類。

代碼清單10:“不完全”單例類

package com.javapatterns.singleton.demos;
public class LazySingleton
{
private static LazySingleton
m_instance = null;
/**
* 公開的構造子,外界可以直接實例化
*/
public LazySingleton() { }
/**
* 靜態工廠方法
* @return 返還LazySingleton 類的惟一實例
*/
synchronized public static
LazySingleton getInstance()
{
if (m_instance == null)
{
m_instance = new LazySingleton();
}
return m_instance;
}
}

上面的代碼乍看起來是一個“懶漢”式單例類,仔細一看,發現有一個公開的構造子。由於外界可以使用構造子創建出任意多個此類的實例,這違背了單例類只能有一個(或有限個)實例的特性,因此這個類不是完全的單例類。這種情況有時會出現,比如javax.swing.TimerQueue 便是一例,關於這個類,請參見《Java與模式》一書中的“觀察者模式與Swing 定時器” 一章。

造成這種情況出現的原因有以下幾種可能:

(1) 初學者的錯誤。許多初學者沒有認識到單例類的構造子不能是公開的,因此犯下這個錯誤。有些初學Java 語言的學員甚至不知道一個Java 類的構造子可以不是公開的。在 這種情況下,設計師可能會通過自我約束,也就是說不去調用構造子的辦法,將這個不完全的單例類在使用中作為一個單例類使用。

在這種情況下,一個簡單的矯正辦法,就是將公開的構造子改為私有的構造子。

(2) 當初出於考慮不周,將一個類設計成為單例類,後來發現此類應當有多於一個的實例。為了彌補錯誤, 干脆將構造子改為公開的,以便在需要多於一個的實例時, 可以隨時調用構造子創建新的實例。要糾正這種情況較為困難,必須根據具體情況做出改進的決定。如果一個類在最初被設計成為單例類,但後來發現實際上此類應當有有限多個實例,這時候應當考慮是否將單例類改為多例類(Multiton)。

(3)設計師的Java 知識很好,而且也知道單例模式的正確使用方法,但是還是有意使用這種不完全的單例模式,因為他意在使用一種“改良”的單例模式。這時候, 除去共有的構造子不符合單例模式的要求之外,這個類必須是很好的單例模式。

默認實例模式

有些設計師將這種不完全的單例模式叫做“默認實例模式”(Default Instance Pattern)。在所謂的“ 默認實例模式”裡面, 一個類提供靜態的方法,如同單例模式一樣, 同時又提供一個公開的構造子,如同普通的類一樣。

這樣做的惟一好處是,這種模式允許客戶端選擇如何將類實例化:創建新的自己獨有的實例,或者使用共享的實例。這樣一來,由於沒有任何的強制性措施,客戶端的選擇不一定是合理的選擇。其結果是設計師往往不會花費時間在如何提供最好的選擇上,而是不恰當地將這種選擇交給客戶端的程序員,這樣必然會導致不理想的設計和欠考慮的實現。

本文建議讀者不要這樣做。

相關模式

有一些模式可以使用單例模式,如抽象工廠模式可以使用單例模式,將具體工廠類設計成單例類;建造模式可以使用單例模式,將具體建造類設計成單例類。

多例(Multiton)模式

正如同本章所說的,單例模式的精神可以推廣到多於一個實例的情況。這時候這種類叫做多例類,這種模式叫做多例模式。單例類(左)和多例類(右)的類圖如下所示。

關於多例模式,請見《Java與模式》一書中的“專題:多例(Multiton)模式與多語言支持”一章。

簡單工廠(Simple Factory)模式

單例模式使用了簡單工廠模式(又稱為靜態工廠方法模式)來提供自己的實例。在上面ConfigManager 例子的代碼中, 靜態工廠方法getInstance() 就是靜態工廠方法。在java.awt.Toolkit 類中,getDefaultToolkit() 方法就是靜態工廠方法。簡單工廠模式的簡略類圖如下所示。

本章討論了單例模式的結構和實現方法。

單例模式是一個看上去很簡單的模式,很多設計師最先學會的往往是單例模式。然而,隨著Java 系統日益變得復雜化和分散化,單例模式的使用變得比過去困難。本書提醒讀者在分散式的Java 系統中使用單例模式時,盡量不要使用有狀態的。

問答題

1. 為什麼不使用一個靜態的“全程”原始變量,而要建一個類?一個靜態的原始變量當然只能有一個值,自然而然不就是“單例”的嗎?

2. 舉例說明如何調用EagerSingleton 類。

3. 舉例說明如何調用RegSingleton 類和RegSingletonChild 類。

4. 請問java.lang.Math 類和java.lang.StrictMath 類是否是單例模式?

5. 我們公司只購買了一個JDBC 驅動軟件的單用戶使用許可,可否使用單例模式管理通過JDBC 驅動軟件連接的數據庫?

問答題答案

1. 單例模式可以提供很復雜的邏輯,而一個原始變量不能自已初始化,不可能有繼承的關系,沒有內部結構。因此單例模式有很多優越之處。

在Java 語言裡並沒有真正的“全程”變量,一個變量必須屬於某一個類或者某一個實例。而在復雜的程序當中,一個靜態變量的初始化發生在哪裡常常是一個不易確定的問題。當然,使用“全程”原始變量並沒有什麼錯誤,就好像選擇使用Fortran 語言而非Java語言編程並不是一種對錯的問題一樣。

2. 幾種單例類的使用方法如下。

代碼清單11:幾種單例類的使用方法

public class RegSingletonTest
{
public static void main(String[] args)
{
//(1) Test eager
System.out.println( EagerSingleton.getInstance());
//(2) Test reg
System.out.println(
RegSingleton.getInstance(
"com.javapatterns.singleton.demos.RegSingleton").about());
System.out.println( RegSingleton.getInstance(null).about() );
System.out.println(
RegSingleton.getInstance(
"com.javapatterns.singleton.demos.RegSingletonChild").about());
System.out.println( RegSingletonChild.getInstance().about());
}
}

3. 見上題答案。

4. 它們都不是單例類。原因如下:

這兩個類均有一個私有的構造子。但是這僅僅是單例模式的必要條件,而不是充分條件。回顧在本章開始提出的單例模式的三個特性可以看出,無論是Math 還是StrictMath 都沒有為外界提供任何自身的實例。實際上,這兩個類都是被設計來提供靜態工廠方法和常量的,因此從來就不需要它們的實例,這才是它們的構造子是私有的原因。Math和StrictMath 類的類圖如下所示。

5. 這樣做是可行的,只是必須注意當使用在分散式系統中的時候,不一定能保證單例類實例的惟一性。

附錄:雙重檢查成例的研究

成例是一種代碼層次上的模式,是在比設計模式的層次更具體的層次上的代碼技巧。成例往往與編程語言密切相關。雙重檢查成例(Double Check Idiom )是從C 語言移植過來的一種代碼模式。在C 語言裡,雙重檢查成例常常用在多線程環境中類的晚實例化(Late Instantiation)裡。

本節之所以要介紹這個成例(嚴格來講,是介紹為什麼這個成例不成立), 是因為有很多人認為雙重檢查成例可以使用在“懶漢”單例模式裡面。

什麼是雙重檢查成例

為了解釋什麼是雙重檢查成例,請首先看看下面沒有使用任何線程安全考慮的錯誤例子。

從單線程的程序談起

首先考慮一個單線程的版本。

代碼清單13:沒有使用任何線程安全措施的一個例子

// Single threaded version
class Foo
{
  private Helper helper = null;
  public Helper getHelper()
  {
   if (helper == null)
   {
    helper = new Helper();
   }
   return helper;
  }
  // other functions and members...
}

這是一個錯誤的例子,詳情請見下面的說明。

寫出這樣的代碼,本意顯然是要保持在整個JVM 中只有一個Helper 的實例;因此,才會有if (helper == null) 的檢查。非常明顯的是,如果在多線程的環境中運行,上面的代碼會有兩個甚至兩個以上的Helper 對象被創建出來,從而造成錯誤。

但是,想像一下在多線程環境中的情形就會發現,如果有兩個線程A 和B 幾乎同時到達if (helper == null)語句的外面的話,假設線程A 比線程B 早一點點,那麼:

(1)A 會首先進入if (helper == null) 塊的內部,並開始執行new Helper() 語句。此時,helper 變量仍然是null,直到線程A 的new Helper() 語句返回並給helper 變量賦值為止。

(2) 但是,線程B 並不會在if (helper == null)語句的外面等待,因為此時helper == null 是成立的,它會馬上進入if (helper == null)語句塊的內部。這樣,線程B 會不可避免地執行helper = new Helper();語句,從而創建出第二個實例來。

(3)線程A 的helper = new Helper();語句執行完畢後,helper 變量得到了真實的對象引用,(helper == null)不再為真。第三個線程不會再進入if (helper == null) 語句塊的內部了。

(4)線程B 的helper = new Helper(); 語句也執行完畢後,helper 變量的值被覆蓋。但是第一個Helper 對象被線程A 引用的事實不會改變。

這時,線程A 和B 各自擁有一個獨立的Helper 對象,而這是錯誤的。

線程安全的版本

為了克服沒有線程安全的缺點,下面給出一個線程安全的例子。

代碼清單14:這是一個正確的答案

// Correct multithreaded version
class Foo
{
  private Helper helper = null;
  public synchronized Helper getHelper()
  {
   if (helper == null)
   {
    helper = new Helper();
    return helper;
   }
  }
  // other functions and members...
}

顯然,由於整個靜態工廠方法都是同步化的,因此,不會有兩個線程同時進入這個方法。因此,當線程A 和B 作為第一批調用者同時或幾乎同時調用此方法時:

(1)早到一點的線程A 會率先進入此方法,同時線程B 會在方法外部等待。

(2) 對線程A 來說,helper 變量的值是null ,因此helper = new Helper(); 語句會被執行。

(3)線程A 結束對方法的執行,helper 變量的值不再是null。

(4)線程B 進入此方法,helper 變量的值不再是null ,因此helper = new Helper(); 語句不會被執行。線程B 取到的是helper 變量所含有的引用,也就是對線程A 所創立的Helper 實例的引用。

顯然,線程A 和B 持有同一個Helper 實例,這是正確的。

畫蛇添足的“雙重檢查”

但是,仔細審察上面的正確答案會發現,同步化實際上只在helper 變量第一次被賦值之前才有用。在helper 變量有了值以後,同步化實際上變成了一個不必要的瓶頸。如果能有一個方法去掉這個小小的額外開銷,不是更加完美了嗎?因此,就有了下面這個設計“巧妙”的雙重檢查成例。在讀者向下繼續讀之前,有必要提醒一句:正如本小節的標題所標明的那樣,這是一個反面教材,因為雙重檢查成例在Java 編譯器裡無法實現。

代碼清單15:使用雙重檢查成例的懶漢式單例模式

// Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo
{
  private Helper helper = null;
  public Helper getHelper()
  {
   if (helper == null) //第一次檢查(位置1)
   {
    //這裡會有多於一個的線程同時到達 (位置2)
    synchronized(this)
    {
     //這裡在每個時刻只能有一個線程 (位置3)
     if (helper == null) //第二次檢查 (位置4)
     {
      helper = new Helper();
     }
    }
   }
   return helper;
  }
  // other functions and members...
}

這是一個錯誤的例子,詳情請見下面的解釋。

對於初次接觸雙重檢查成例的讀者來說,這個技巧的思路並不明顯易懂。因此,本節在這裡給出一個詳盡的解釋。同樣,這裡假設線程A 和B 作為第一批調用者同時或幾乎同時調用靜態工廠方法。

(1) 因為線程A 和B 是第一批調用者,因此,當它們進入此靜態工廠方法時,helper 變量是null。因此,線程A 和B 會同時或幾乎同時到達位置1。

(2)假設線程A 會首先到達位置2,並進入synchronized(this) 到達位置3。這時,由於synchronized(this) 的同步化限制,線程B 無法到達位置3,而只能在位置2 等候。

(3)線程A 執行helper = new Helper() 語句,使得helper 變量得到一個值,即對一個Helper 對象的引用。此時,線程B 只能繼續在位置2 等候。

(4)線程A 退出synchronized(this) ,返回Helper 對象,退出靜態工廠方法。

(5)線程B 進入synchronized(this) 塊,達到位置3,進而達到位置4。由於helper 變量已經不是null 了,因此線程B 退出synchronized(this),返回helper 所引用的Helper 對象(也就是線程A 所創建的Helper 對象),退出靜態工廠方法。

到此為止,線程A 和線程B 得到了同一個Helper 對象。可以看到,在上面的方法

getInstance() 中,同步化僅用來避免多個線程同時初始化這個類,而不是同時調用這個靜態工廠方法。如果這是正確的,那麼使用這一個成例之後,“ 懶漢式”單例類就可以擺脫掉同步化瓶頸,達到一個很妙的境界。

代碼清單16:使用了雙重檢查成例的懶漢式單例類

public class LazySingleton
{
  private static LazySingleton m_instance = null;
  private LazySingleton() { }
  /**
  * 靜態工廠方法
  */
  public static LazySingleton getInstance()
  {
   if (m_instance == null)
   {
   //More than one threads might be here!!!
   synchronized(LazySingleton.class)
   {
    if (m_instance == null)
    {
     m_instance = new LazySingleton();
    }
   }
  }
  return m_instance;
}
}

這是一個錯誤的例子,請見下面的解釋。

第一次接觸到這個技巧的讀者必定會有很多問題,諸如第一次檢查或者第二次檢查可不可以省掉等。回答是:按照多線程的原理和雙重檢查成例的預想方案,它們是不可以省掉的。本節不打算講解的原因在於雙重檢查成例在Java 編譯器中根本不能成立。

雙重檢查成例對Java 語言編譯器不成立

令人吃驚的是,在C 語言裡得到普遍應用的雙重檢查成例在多數的Java 語言編譯器裡面並不成立[BLOCH01, GOETZ01, DCL01] 。上面使用了雙重檢查成例的“懶漢式”單例類,不能工作的基本原因在於,在Java 編譯器中,LazySingleton 類的初始化與m_instance 變量賦值的順序不可預料。如果一個線程在沒有同步化的條件下讀取m_instance 引用,並調用這個對象的方法的話,可能會發現對象的初始化過程尚未完成,從而造成崩潰。

文獻[BLOCH01] 指出:一般而言,雙重檢查成立對Java 語言來說是不成立的。

給讀者的一點建議

有很多非常聰明的人在這個成例的Java 版本上花費了非常多的時間,到現在為止人們得出的結論是:一般而言,雙重檢查成例無法在現有的Java 語言編譯器裡工作[BLOCH01, GOETZ01, DCL01] 。

讀者可能會問,是否有可能通過某種技巧對上面的雙重檢查的實現代碼加以修改,從而使某種形式的雙重檢查成例能在Java 編譯器下工作呢?這種可能性當然不能排除,但是除非讀者對此有特別的興趣,建議不要在這上面花費太多的時間。

在一般情況下使用餓漢式單例模式或者對整個靜態工廠方法同步化的懶漢式單例模式足以解決在實際設計工作中遇到的問題。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved