1. 程式人生 > >Java設計模式詳談(一):單例

Java設計模式詳談(一):單例

         經過一段時間的工作歷練和學習,會慢慢接觸到開發六大原則和二十三種設計模式,雖然有時候並不一定全部都會用到,不過對於在今後的學習和工作當中會有很大幫助。六大原則本章暫時不進行討論,本章就開始一一學習GOF當中的二十三中設計模式。

        相信在此之前,網路上會有很多關於設計模式此類的文章,我也一直在想該如何去詮釋如此多的設計模式,只能說我想分享一下我自己對於它們的認識與理解,如果存在問題,還希望讀者能夠留下寶貴的意見,我們共同學習。

        遵循著通俗易懂的理念,我們就來回顧一下單例模式為何要出現,又或者說什麼樣的類可以做成單例的。

        在我工作的過程中,會發現所有可以使用單例的類都會有一個共性,就是無論我例項化多少次,結果都是一樣的。另外,當出現兩個或多個例項的時候,程式會因此產生程式與現實相違背的邏輯錯誤。這樣的情況下,如果不對該類使用單例結構的話,程式中會存在很多一樣的類例項,這往往會造成記憶體的浪費。所以我們在使用單例的目的就是儘可能的節約記憶體空間,減少不需要的GC消耗,並且能夠保證程式正常執行。

        下面,先介紹一種最基本最原始的單例模式構造方式。

/**
 * 常用:普通的單例(不考慮併發)
 * @author lyr
 * @date 2017年11月21日
 */

public class SimpleSingleton {

    private static SimpleSingleton simpleSingleton;
    
    private SimpleSingleton(){};
    
    public static SimpleSingleton getInstance(){
        if(simpleSingleton==null){
            simpleSingleton = new SimpleSingleton();
        }
        return simpleSingleton;
    }
}

這是在不考慮併發的情況下最基本的單例模式,這種方式在幾個地方限制我們獲取到的例項是唯一的。

(1)靜態例項,帶有static關鍵字的屬性在每個類都是唯一的;
(2)私有化構造方法,限制客戶端隨意建立例項,作為最重要的一環;
(3)給出一個公共的獲取例項的靜態方法,主要是在我們未獲取到例項的時候提供給客戶端進行呼叫,當然,這裡肯定不能是非靜態方法,因為非靜態方法必須要有例項才能呼叫;
(4)經過if判斷只有在靜態例項為null時才去建立,否則直接返回。

至於為何這種方式在併發的情況不能使用,下面直接給出了一個例子:

/**
* 測試
* @author  lyr
* @date 2017年11月21日
*/
public class TestSingleton {
    
    public static void main(String[] args) throws InterruptedException {
        
        final Set<String> set = Collections.synchronizedSet(new HashSet<String>());
        final CountDownLatch cdl = new CountDownLatch(1);
         
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < 100; i++) {
            executor.execute(new Runnable() {
                public void run() {
                    try {
                        cdl.await();
                         
                        SimpleSingleton singleton = SimpleSingleton.getInstance();
                        set.add(singleton.toString());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
         
         
        Thread.sleep(1000);
        cdl.countDown();
        Thread.sleep(1000);
         
        System.out.println("一共有" + set.size() + "個例項");
        for (String str : set) {
            System.out.println(str);
        }
         
        executor.shutdown();
         
    }
}

在上面的例子當中我同時開啟了100個執行緒去訪問getInstance(),並且將獲取到的例項的toSting()之後的字串裝入一個同步的set集合當中,set會自動去重,所以看結果輸出兩個或兩個以上的例項字串,就說明在併發訪問的情況下能夠出現多個例項。
在程式當中添加了兩次睡眠時間,第一次是為了留有足夠的時間等待100個執行緒全部開啟,第二次是為了鎖開啟之後,保證所有的執行緒都已經呼叫了getInstance()。
那麼問題來了,究竟是什麼情況導致的在併發的情況下會出現多個例項的呢?當第一個去訪問getInstance的執行緒A,在判斷完singleton是null的情況下,進入if判斷準備建立例項,而此時另一個執行緒B在A還未建立例項之前,又進行了判斷singleton是否為null的判斷,而判斷的結果依然是null,那麼此時執行緒B也會進入到if的判斷環節準備建立例項,當兩個執行緒都去if判斷塊建立例項的時候,此時的單例模式就不再是單例了。


那在併發的情況下該如何正確的使用單例呢,不多BB,直接來下面的例子:

 

 

/**
 * 常用:併發情況下的單例(雙重加鎖)
 * @author lyr
 * @date 2017年11月21日
 */
public class ConcurrentSingleton {

	private static ConcurrentSingleton concurrentSingleton;
	
	private ConcurrentSingleton(){};
	
	public static ConcurrentSingleton getInstance(){
		if(concurrentSingleton==null){
			synchronized (ConcurrentSingleton.class) {
				if(concurrentSingleton==null){
					concurrentSingleton = new ConcurrentSingleton();
				}
			}
		}
		return concurrentSingleton;
	}
}

        可能會有很多人有疑問,為何不直接在方法上進行同步,先來看看如果直接在前一個例子的方法上直接加同步,是可以避免出現多個例項的情況,但是,當一個執行緒進入的時候,其它所有的執行緒都被掛起處於等待狀態。
        其實我們只需要在例項建立之前進行同步就OK了,例項建立後再同步無意義。此時,也就出現了上面的這個教科書版的單例模式,同時也叫雙重加鎖。
        這種做法比上一個的例子在方法上加同步好多了,保證了在例項未建立之前才開始同步,否則就直接返回。這樣節省了大量無謂的等待時間,當然在這種方式中,我們再一次的判斷concurrentSingleton是否為null,這樣的做的好處是什麼呢?這樣去想,如果我們把同步塊中的if判斷去掉,讓A、B執行緒又再次來臨,A執行緒先進入第一層if判斷當前的例項為null,進入同步塊之後建立例項,此時concurrentSingleton被賦予了一個例項,A執行緒退出同步塊;B執行緒進入,由於此時沒有存在第二層if判斷,B執行緒又去建立了一個例項,此時就會出現多個例項的狀況。
       這樣看起來目前的這種方式應該沒有什麼問題了,真的沒有嗎?還是會有的。問題會在哪,問題就有可能出在JVM那裡(這裡只是有可能,並不一定會出現)。

       這個情況下就要考慮JVM建立物件的過程了,主要經歷三步:(1) 分配記憶體;(2) 初始化構造器;(3) 將物件指向分配好的記憶體地址。一般情況下使用上面的單例模式不會出現問題,如果此時出現JVM對位元組碼進行調優,而其中的一項就是對指令順序的調整,此時問題就出現了。
       因為這個時候JVM會先將記憶體地址賦值給物件,針對上面的雙重加鎖,會先將分配好的記憶體地址指給concurrentSingleton,然後再執行初始化構造器,這個時候後面的執行緒去請求getInstance()的時候,會認為concurrentSingleton物件已經例項化了,直接返回一個引用。如果在初始化構造器之前,這個執行緒使用了concurrentSingleton,就會產生莫名的錯誤。針對這一問題,可以考慮在concurrentSingleton變數前新增volatile關鍵字,該關鍵字出現在Java 5以後,以此來保證:(1)在對volatile變數進行寫操作時,不允許和它之前的讀寫操作打亂順序;(2)在對volatile變數進行讀操作時,不允許和它之後的讀寫操作打亂順序。

當然,還有一種比較標準的單例模式,如下:

/**
 * 常用:完整的單例(最終的版本):
 * 	(1)考慮到JVM針對位元組碼調優時,一般會先把建立好的物件指向分配號的記憶體地址,然後再去執行初始化;
 * 	(2)靜態屬性只會在類第一次載入的時候進行初始化,所以不用去考慮併發問題,在初始化的過程中,別的執行緒是無法使用的;
 * 	(3)由於靜態變數只初始化一次,所以仍然是單例
 * @author lyr
 * @date 2017年11月21日
 */
public class FinalSingleton {

	private FinalSingleton(){};
	
	public static FinalSingleton getInstance(){
		return FinalSingletonClass.instance;
	}
	
	private static class FinalSingletonClass{
		static FinalSingleton instance = new FinalSingleton();
	}
}

進行到這裡,單例模式算是結束了,以上就是最終的單例模式的形式,上述形式保證了以下幾點:
(1)Singleton最多隻會存在一個例項,在不考慮反射強行衝破訪問限制的情況下;
(2)保證了併發的情況下,不會出現由於併發而出現多個例項,不會出現由於初始化未完全完成而造成了未正確初始化的例項。

  上述的實現方式都是比較常用的單例模式,當然也會有其它的不常用的一些單例模式,一種就是俗稱餓漢模式:

/**
 * 不常用:餓漢單例模式(一般用於專案啟動載入配置檔案)
 * (1)一旦訪問該類的其它靜態域,就會造成例項的初始化,而可能我們自始至終都沒有使用過該例項,容易造成記憶體浪費
 * @author lyr
 * @date 2017年11月21日
 */
public class HungerSingleton {

	private static HungerSingleton hunger = new HungerSingleton();
	
	private HungerSingleton(){};
	
	public static HungerSingleton getInstance(){
		return hunger;
	}
}

   這一種會在程式啟動載入配置檔案的時候用到,其它情況不建議使用。
  單例模式就分享到這裡,感謝各位的閱讀。