1. 程式人生 > >Java 中的單例模式,看完這一篇就夠了

Java 中的單例模式,看完這一篇就夠了

單例模式是最常見的一個模式,在Java中單例模式被大量的使用。這同樣也是我在面試時最喜歡提到的一個面試問題,然後在面試者回答後可以進一步挖掘其細節,這不僅檢查了關於單例模式的相關知識,同時也檢查了面試者的編碼水平、多執行緒方面的知識,這些在實際的工作中非常重要。

在這個簡單的Java面試教程中,我列舉了一些Java面試過程中關於單例模式的常會被提到的問題。關於這些面試問題,我沒有提供答案,因為你通過百度搜索很容易找到這些答案。

那麼問題就從什麼是單例模式?你之前用過單例模式嗎?
開始

  定義:確保一個類只有一個例項,而且自行例項化並向整個系統提供這個例項。

   型別:建立類模式


類圖知識點:

1.類圖分為三部分,依次是類名、屬性、方法

2.以<<開頭和以>>結尾的為Stereotype

3.修飾符+代表public,-代表private,#代表protected,什麼都沒有代表包可見。

4.帶下劃線的屬性或方法代表是靜態的。

5.對類圖中物件的關係不熟悉的朋友可以參考文章:

單例模式應該是23種設計模式中最簡單的一種模式了。它有以下幾個要素:

  • 私有的構造方法
  • 指向自己例項的私有靜態引用
  • 以自己例項為返回值的靜態的公有的方法

  單例模式根據例項化物件時機的不同分為兩種:一種是餓漢式單例,一種是懶漢式單例。餓漢式單例在單例類被載入時候,就例項化一個物件交給自己的引用;而懶漢式在呼叫取得例項方法的時候才會例項化物件。程式碼如下:

   Eager mode:

    class Singleton{  
        private Singleton(){}  
        private static final Singleton singleton = new Singleton();  
        public static Singleton getInstance(){return singleton;}  
    }  
  Lazy mode:
    class Singleton{  
        private Singleton(){}  
        private static Singleton singleton ;  
        public static synchronized Singleton getInstance(){  
            if(singleton==null)  
                singleton = new Singleton();  
            return singleton;         
        }     
    }  


1) 哪些類是單例模式的候選類?在Java中哪些類會成為單例?

  (1) 系統資源,如檔案路徑,資料庫連結,系統常量等

  (2)全域性狀態化類,類似AutomicInteger的使用

 

單例模式的優點:

  • 在記憶體中只有一個物件,節省記憶體空間。
  • 避免頻繁的建立銷燬物件,可以提高效能。
  • 避免對共享資源的多重佔用。
  • 可以全域性訪問。

適用場景:由於單例模式的以上優點,所以是程式設計中用的比較多的一種設計模式。我總結了一下我所知道的適合使用單例模式的場景:

  • 需要頻繁例項化然後銷燬的物件。
  • 建立物件時耗時過多或者耗資源過多,但又經常用到的物件。
  • 有狀態的工具類物件。
  • 頻繁訪問資料庫或檔案的物件。

  這裡將檢查面試者是否有對使用單例模式有足夠的使用經驗。他是否熟悉單例模式的優點和缺點。

2)你能在Java中編寫單例裡的getInstance()的程式碼?

很多面試者都在這裡失敗。然而如果不能編寫出這個程式碼,那麼後續的很多問題都不能被提及。

  (1)靜態成員直接初始化,或者在靜態程式碼塊初始化都可以

    class Singleton{  
        private Singleton(){}  
        private static Singleton singleton ;  
        public static synchronized Singleton getInstance(){  
            if(singleton==null)  
                singleton = new Singleton();  
            return singleton;         
        }     
    }  
  該實現只要在一個ClassLoad下就會提供一個物件的單例。但是美中不足的是,不管該資源是否被請求,它都會建立一個物件,佔用jvm記憶體。餓漢式是典型的空間換時間,當類裝載的時候就會建立類的例項,不管你用不用,先創建出來,然後每次呼叫的時候,就不需要再判斷,節省了執行時間。

從lazy initialization思想出發,出現了下2的寫法

  (2) 根據lazy initialization思想,使用到時才初始化。

    class Singleton{  
        private Singleton(){}  
        private static Singleton singleton ;  
        public static synchronized Singleton getInstance(){  
            if(singleton==null)  
                singleton = new Singleton();  
            return singleton;         
        }     
    }  
  該實現方法加了同步鎖,可以有效防止多執行緒在執行getInstance方法得到2個物件。

缺點:只有在第一次呼叫的時候,才會出現生成2個物件,才必須要求同步。而一旦singleton 不為null,系統依舊花費同步鎖開銷,有點得不償失。

因此再改進出現寫法3

    class Singleton{  
        private Singleton(){}  
        private static Singleton singleton ;  
        public static Singleton getInstance(){  
            if(singleton==null)//1  
                synchronized(Singleton.class){//2  
                    singleton = new Singleton();//3  
                }  
            return singleton;         
        }     
    }  

這種寫法減少了鎖開銷,但是在如下情況,卻建立了2個物件:

a:執行緒1執行到1掛起,執行緒1認為singleton為null

b:執行緒2執行到1掛起,執行緒2認為singleton為null

c:執行緒1被喚醒執行synchronized塊程式碼,走完建立了一個物件

d:執行緒2被喚醒執行synchronized塊程式碼,走完建立了另一個物件

所以看出這種寫法,並不完美。

(4)為了解決3存在的問題,引入雙重檢查鎖定

 
    public static Singleton getInstance(){  
            if(singleton==null)//1  
                synchronized(Singleton.class){//2  
                    if(singleton==null)//3  
                        singleton = new Singleton();//4  
                }  
            return singleton;         
        } 

      在同步鎖程式碼塊內部,再判斷一次物件是否為null,為null才建立物件。這種寫法已經接近完美:

a:執行緒1執行到1,已經進入synchronized的時候,執行緒掛起,執行緒1佔有Singleton.class資源鎖;

b:執行緒2執行到1,當它準備synchronized塊時,因為Singleton.class被佔用,執行緒2阻塞;

c:執行緒1被喚醒,判斷出物件為null,執行完建立一個物件

d:執行緒2被喚醒,判斷出物件不為null,不執行建立語句

      如此分析,發現似乎沒問題。

      但是實際上並不能保證它在單處理器或多處理器上正確執行;

      問題就出現在singleton = new Singleton()這一行程式碼。它可以簡單的分成如下三個步驟:

mem= singleton();//1
instance = mem;//2
ctorSingleton(instance);//3

  這行程式碼先在記憶體開闢空間,賦給singleton的引用,然後執行new 初始化資料,但是注意初始化是要消耗時間。如果此時執行緒3在執行步驟1的時候,發現singleton 為非null,就直接返回,那麼執行緒3返回的其實是一個沒構造完成的物件。

      我們期望1,2,3 按照反序執行,但是實際jvm記憶體模型,並沒有明確的有序指定。

      這歸咎於java的平臺的記憶體模型允許“無序寫入”。

 (5) 在4的基礎上引入volatile

程式碼如下:

    class Singleton{  
        private Singleton(){}  
        private static volatile Singleton singleton ;  
        public static Singleton getInstance(){  
            if(singleton==null)//1  
                synchronized(Singleton.class){//2  
                    if(singleton==null)//3  
                        singleton = new Singleton();  
                }  
            return singleton;         
        }     
    }  

    Volatile 變數具有 synchronized 的可見性特性,但是不具備原子特性。這就是說執行緒能夠自動發現 volatile 變數的最新值。

   這種實現方式既可以實現執行緒安全地建立例項,而又不會對效能造成太大的影響。它只是第一次建立例項的時候同步,以後就不需要同步了,從而加快了執行速度。

  根據上面的分析,常見的兩種單例實現方式都存在小小的缺陷,那麼有沒有一種方案,既能實現延遲載入,又能實現執行緒安全呢?

  (6) Lazy initialization holder class模式

  這個模式綜合使用了Java的類級內部類和多執行緒預設同步鎖的知識,很巧妙地同時實現了延遲載入和執行緒安全。
  1.相應的基礎知識
   什麼是類級內部類?

  簡單點說,類級內部類指的是,有static修飾的成員式內部類。如果沒有static修飾的成員式內部類被稱為物件級內部類。

  •   類級內部類相當於其外部類的static成分,它的物件與外部類物件間不存在依賴關係,因此可直接建立。而物件級內部類的例項,是繫結在外部物件例項中的。
  •   類級內部類中,可以定義靜態的方法。在靜態方法中只能夠引用外部類中的靜態成員方法或者成員變數。
  •   類級內部類相當於其外部類的成員,只有在第一次被使用的時候才被會裝載。

     

  多執行緒預設同步鎖的知識

  大家都知道,在多執行緒開發中,為了解決併發問題,主要是通過使用synchronized來加互斥鎖進行同步控制。但是在某些情況中,JVM已經隱含地為您執行了同步,這些情況下就不用自己再來進行同步控制了。這些情況包括:

  1.由靜態初始化器(在靜態欄位上或static{}塊中的初始化器)初始化資料時
  2.訪問final欄位時
  3.在建立執行緒之前建立物件時
  4.執行緒可以看見它將要處理的物件時
  2.解決方案的思路

  要想很簡單地實現執行緒安全,可以採用靜態初始化器的方式,它可以由JVM來保證執行緒的安全性。比如前面的餓漢式實現方式。但是這樣一來,不是會浪費一定的空間嗎?因為這種實現方式,會在類裝載的時候就初始化物件,不管你需不需要。

  如果現在有一種方法能夠讓類裝載的時候不去初始化物件,那不就解決問題了?一種可行的方式就是採用類級內部類,在這個類級內部類裡面去建立物件例項。這樣一來,只要不使用到這個類級內部類,那就不會建立物件例項,從而同時實現延遲載入和執行緒安全。

  示例程式碼如下:

public class Singleton {
    
    private Singleton(){}
    /**
     *    類級的內部類,也就是靜態的成員式內部類,該內部類的例項與外部類的例項
     *    沒有繫結關係,而且只有被呼叫到時才會裝載,從而實現了延遲載入。
     */
    private static class SingletonHolder{
        /**
         * 靜態初始化器,由JVM來保證執行緒安全
         */
        private static Singleton instance = new Singleton();
    }
    
    public static Singleton getInstance(){
        return SingletonHolder.instance;
    }
}
 
   (6) 單例和列舉

   按照《高效Java 第二版》中的說法:單元素的列舉型別已經成為實現Singleton的最佳方法。用列舉來實現單例非常簡單,只需要編寫一個包含單個元素的列舉型別即可。

public enum Singleton {
    /**
     * 定義一個列舉的元素,它就代表了Singleton的一個例項。
     */
    
    uniqueInstance;
    
    /**
     * 單例可以有自己的操作
     */
    public void singletonOperation(){
        //功能處理
    }
}
   使用列舉來實現單例項控制會更加簡潔,而且無償地提供了序列化機制,並由JVM從根本上提供保障,絕對防止多次例項化,是更簡潔、高效、安全的實現單例的方式。

3)在getInstance()方法上同步有優勢還是僅同步必要的塊更優優勢?你更喜歡哪個方式?

這確實是一個非常好的問題,我幾乎每次都會提該問題,用於檢查面試者是否會考慮由於鎖定帶來的效能開銷。因為鎖定僅僅在建立例項時才有意義,然後其他時候例項僅僅是隻讀訪問的,因此只同步必要的塊的效能更優,並且是更好的選擇。

  缺點:只有在第一次呼叫的時候,才會出現生成2個物件,才必須要求同步。而一旦singleton 不為null,系統依舊花費同步鎖開銷,有點得不償失。

4)什麼是單例模式的延遲載入或早期載入?你如何實現它?

這是和Java中類載入的載入和效能開銷的理解的又一個非常好的問題。我面試過的大部分面試者對此並不熟悉,但是最好理解這個概念。

5) Java平臺中的單例模式的例項有哪些?

這是個完全開放的問題,如果你瞭解JDK中的單例類,請共享給我。

   java.lang.Runtime;

6) 單例模式的兩次檢查鎖是什麼?


   可以使用“雙重檢查加鎖(double checked locking)”的方式來實現,就可以既實現執行緒安全,又能夠使效能不受很大的影響。那麼什麼是“雙重檢查加鎖”機制呢?

  所謂“雙重檢查加鎖”機制,指的是:並不是每次進入getInstance方法都需要同步,而是先不同步,進入方法後,先檢查例項是否存在,如果不存在才進行下面的同步塊,這是第一重檢查,進入同步塊過後,再次檢查例項是否存在,如果不存在,就在同步的情況下建立一個例項,這是第二重檢查。這樣一來,就只需要同步一次了,從而減少了多次在同步情況下進行判斷所浪費的時間。

  “雙重檢查加鎖”機制的實現會使用關鍵字volatile,它的意思是:被volatile修飾的變數的值,將不會被本地執行緒快取,所有對該變數的讀寫都是直接操作共享記憶體,從而確保多個執行緒能正確的處理該變數。Volatile是輕量級的synchronized,它在多處理器開發中保證了共享變數的“可見性”。可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。它在某些情況下比synchronized的開銷更小

  注意:在java1.4及以前版本中,很多JVM對於volatile關鍵字的實現的問題,會導致“雙重檢查加鎖”的失敗,因此“雙重檢查加鎖”機制只只能用在java5及以上的版本。

7)你如何阻止使用clone()方法建立單例例項的另一個例項?

該型別問題有時候會通過如何破壞單例或什麼時候Java中的單例模式不是單例來被問及。

在JAVA裡要注意的是,所有的類都預設的繼承自Object,所以都有一個clone方法。為保證只有一個例項,要把這個口堵上。有兩個方面,一個是單例類一定要是final的,這樣使用者就不能繼承它了。另外,如果單例類是繼承於其它類的,還要override它的clone方法,讓它丟擲異常。

8)如果阻止通過使用反射來建立單例類的另一個例項?

開放的問題。在我的理解中,從構造方法中丟擲異常可能是一個選項。

  通過反射建立單例類的另一個例項:

  如果藉助AccessibleObject.setAccessible方法,通過反射機制呼叫私有構造器,反射攻擊:   

public final class HelloWorld
{
private static HelloWorld instance = null;
 
private HelloWorld()
{
}
 
public static HelloWorld getInstance()
{
if (instance == null)
{
instance = new HelloWorld();
}
return instance;
}
 
public void sayHello()
{
System.out.println("hello world!!");
}
 
public static void sayHello2()
{
System.out.println("hello world 222 !!");
}
 
static class Test
{
public static void main(String[] args) throws Exception
{
try
{
Class class1 = Class.forName("HelloWorld");
Constructor[] constructors = class1.getDeclaredConstructors();
AccessibleObject.setAccessible(constructors, true);
for (Constructor con : constructors)
{
if (con.isAccessible())
{
Object classObject = con.newInstance();
Method method = class1.getMethod("sayHello");
method.invoke(classObject);
}
}
 
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
}