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