上一章我們學習了裝飾者模式,這章LZ帶給大家的是單例模式。 首先單例模式是用來幹嘛的?它是用來實例化一個獨一無二的對象!那這有什麼用處?有一些對象我們只需要一個,比如緩存,線程池等。而事實上,這類對象只能有一個示例,如果製造多個示例,就會導致許多問題產生,比如程式的行為異常,資源使用過量。而單例模式 ...
上一章我們學習了裝飾者模式,這章LZ帶給大家的是單例模式。
首先單例模式是用來幹嘛的?它是用來實例化一個獨一無二的對象!那這有什麼用處?有一些對象我們只需要一個,比如緩存,線程池等。而事實上,這類對象只能有一個示例,如果製造多個示例,就會導致許多問題產生,比如程式的行為異常,資源使用過量。而單例模式可以幫助我們確保只有一個實例會被創建。首先我們來看一段代碼:
public class MyClass {
private static MyClass myClass;
private MyClass(){
}
public static MyClass getInstance(){
if(myClass ==null){
myClass = new MyClass();
}
return myClass;
}
}
1.首先我們創建一個靜態實例,而帶有static關鍵字的屬性在每一個類中都是唯一的。
2.接著我們將構造方法私有化,從而限制調用者隨意創造實例,這也是保證單例的最重要的一步。
3.當然,我們必須要給一個可供調用方使用的獲取實例的靜態方法,這裡必須是靜態方法,為什麼呢?請註意,如果我們給的是非靜態的,那麼調用方必須擁有實例才能調用這個方法,但是既然沒有調用這個方法,調用方又哪裡來的實例呢?這不是自相矛盾嗎
4.我們加一個判斷,當只有持有的靜態實例為null時才調用構造方法創造一個實例並把它賦予myClass靜態變數中,註意,如果我們不需要這個實例,它就永遠不會產生,這就是“延遲實例化”。
由此我們可以看出來,單例模式確保一個類只有一個實例,並提供一個全局訪問點。
是不是很簡單?事實上單例模式確實特別簡單,不過LZ還有些內容沒有說完。
如果各位去公司面試,面試官讓你們寫一個單例模式,你們把上面LZ給的代碼寫給面試官,如果你們是應屆生,也許面試官會覺得不錯,但如果你們已經是工作超過一年的同學,那麼寫出上面的代碼恐怕你們就要完蛋。為什麼呢?其實這是一個併發的問題,上面的代碼在不考慮併發的情況下,確實沒有問題,但是一旦考慮多線程併發,就會出現問題。
下麵LZ用事實說話,給大家模擬一下多線程併發的情況
public class TestMyClass {
boolean myLock ;
public boolean isMyLock() {
return myLock;
}
public void setMyLock(boolean myLock) {
this.myLock = myLock;
}
public static void main(String[] args) throws Exception {
int num=100;
final CyclicBarrier cyclicBarrier = new CyclicBarrier(num);
final Set<String> set=Collections.synchronizedSet(new HashSet<String>());
ExecutorService executorService = Executors.newCachedThreadPool();
for(int i=0;i<num;i++){
executorService.execute(new Runnable() {
public void run() {
try {
cyclicBarrier.await();
MyClass myClass = MyClass.getInstance();
set.add(myClass.toString());
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
Thread.sleep(2000);
System.out.println("------併發情況下我們取到的實例------");
for (String instance : set) {
System.out.println(instance);
}
executorService.shutdown();
}
}
代碼比較簡單,這裡LZ是用的柵欄阻塞等待所有線程創建完畢,然後同時執行獲取實例的操作。
LZ在程式中同時開啟了100個線程來訪問getInstance方法,然後把獲得實例的實例字元串裝入同步的set集合,這裡為什麼要放到set集合就不用LZ解釋了吧=。=set集合會自動去重,所以我們看結果輸出了多少實例字元串,就說明我們在併發訪問的過程中產生了多少實例。
這裡我讓main線程睡眠了一次,是為了給足夠的時間讓100個線程全部開啟。下麵我們看一下結果(如果你照我的代碼演示結果出現了一個,不要驚訝。我試了試大概3次之內就會出現我這種情況,甚至出現4個的都有)
那麼為什麼會造成這種情況呢?
當併發訪問的時候,第一個調用getInstance方法的線程A,在判斷完myClass是null的時候,線程A就進入了if塊準備創造實例,說時遲那時快,在這同時另外一個線程B線上程A還未創造出實例之前,就又進行了myClass是否為null的判斷,這時myClass當然依然為null,所以線程B也會進入if塊去創造實例,那麼問題就出來了,有兩個線程都進入了if塊去創造實例,結果就造成產生了兩個對象出來。接下來LZ做的一個類似於圖的東西,各位可以看看,雖然看起來不太直觀,但是配合LZ的講解詳細各位一目瞭然。
1 public static MyClass getInstance(){ 對象的狀態
2 public static MyClass getInstance(){ null
3 if(myClass ==null){ null
4 if(myClass ==null){ null
5 myClass = new MyClass(); object1
6 }
7 return myClass; object1
8 myClass = new MyClass(); object2
9 }
10 return myClass; object2
11 }
那麼,我們又應該怎麼解決這個線程併發導致的問題呢?
詳細各位會立刻想起synchronized關鍵字,我們只要把getInstance()變成同步方法,就可以以上的問題了。
public class MyClass {
private static MyClass myClass;
private MyClass(){
}
public synchronized static MyClass getInstance(){
if(myClass ==null){
myClass = new MyClass();
}
return myClass;
}
}
通過加上synchronized關鍵字到getInstance()方法前,我們迫使每個線程在進入此方法前,必須先等待其他線程離開,就是說,不會有兩個線程同時進入此方法。
但是,如果我們這樣做,就會導致性能降低,因為,我們只有第一次調用getInstance()這個方法的時候需要同步,而當一旦設置好了myClass這個變數,我們就不需要再同步了,那麼之後我們每次都同步,會導致性能降低。那麼順著這個角度去思考,我們可以先去判斷myClass是否為null,當它為null時再同步。
public class MyClass {
private static MyClass myClass;
private MyClass(){
}
public static MyClass getInstance(){
if(myClass ==null){
synchronized(MyClass.class){
if(myClass ==null){
myClass = new MyClass();
}
}
}
return myClass;
}
}
這種做法也被稱為雙重加鎖
經過剛纔LZ的分析,這種做法應該是滿足了要求,看起來是沒有問題了,但如果我們再進一步深入考慮的話,其實仍然是有可能出現問題的。
這裡我們深入到JVM中去探索上面這段代碼,相信各位都知道虛擬機在執行創建實例的這一步操作的時候,其實是分了好幾步去進行的,專業點說,創建一個新的對象並非是原子性操作。在有些JVM中上述做法是沒有問題的,但是有些情況下是會造成莫名的錯誤。
我們先來搞清楚在JVM創建新的對象時,主要要經過三步。
1.分配記憶體
2.初始化構造器
3.將對象指向分配的記憶體的地址
這種順序在上述雙重加鎖的方式是沒有問題的,因為這種情況下JVM是完成了整個對象的構造才將記憶體的地址交給了對象。但是如果2和3步驟是相反的(2和3可能是相反的是因為JVM會針對位元組碼進行調優,而其中的一項調優便是調整指令的執行順序),就會出現問題了。
我們假設2與3位置相反了,針對上述的雙重加鎖來講,因為這時會先將記憶體地址賦給對象myClass,然後再進行初始化構造器,這時候後面的線程去請求getInstance方法時,會認為myClass對象已經實例化了,直接返回一個引用。如果在初始化構造器之前,這個線程使用了myClass,就會產生莫名的錯誤。
那麼我們要如何避免這一個問題呢?我們可以給靜態的實例屬性加上關鍵字volatile,這樣就不會出現實例化發生一半的情況,因為加入了volatile關鍵字,就等於禁止了JVM自動的指令重排序優化,並且強行保證線程中對變數所做的任何寫入操作對其他線程都是即時可見的。volatile會強行將對該變數的所有讀和取操作綁定成一個不可拆分的動作。由於本節我們講的是設計模式,所以這裡LZ不會去詳細介紹volatile以及JVM中變數訪問時所做的具體動作(或者以後LZ會單獨將),感興趣的讀者可以去翻閱相關的資料。
另外由於volatile關鍵字是在JDK1.5版本出現的,所以凡是1.4及1.4之前的版本都無法使用。這裡LZ把這種寫法完整的列出來。
public class MyClass {
private volatile static MyClass myClass;
private MyClass(){
}
public static MyClass getInstance(){
if(myClass ==null){
synchronized(MyClass.class){
if(myClass ==null){
myClass = new MyClass();
}
}
}
return myClass;
}
}
另外,這就是我們常說的“懶漢式”,大家可以這樣記“因為懶漢太懶了,所以只有用的時候才創建對象。”
懶漢式單例類。 只在外部對象第一次請求實例的時候才會去創建
優點:第一次調用時才會初始化,避免記憶體浪費。
缺點:必須加鎖synchronized 才能保證單例,效率低
當然,除了這種寫法,我們還有一種辦法可以解決線程併發的問題,相信大家都聽過“餓漢式”
class MyClassTo {
private static MyClassTo myClassTo = new MyClassTo();
private MyClassTo(){}
public static MyClassTo getInstance(){
return myClassTo;
}
}
因為太餓了,所以上來就創建=。=
餓漢式單例類。 它在類載入時就立即創建對象。
優點:沒有加鎖,執行效率高。 用戶體驗上來說,比懶漢式要好。
缺點:類載入時就初始化,浪費記憶體
那麼為什麼餓漢比懶漢要好,一個是空間換時間,一個是時間換空間,你們說是時間終於還是空間重要?=。=
另外,還有一種單例模式,被稱為"登記式"
class MyClassThree{
private MyClassThree(){}
public static MyClassThree getInstance(){ return SINGLETON.myClassThree;}
private static class SINGLETON{//內部類
private static final MyClassThree myClassThree= new MyClassThree();
}
}
內部類只有在外部類被調用才載入,產生SINGLETON實例,又不用加鎖,這個模式有上述倆模式的優點,屏蔽了他們的缺點,是最好的單例模式。
首先來說一下,這種方式為何會避免了雙重加鎖的漏洞,主要是因為一個類的靜態屬性只會在第一次載入類時初始化,這是JVM幫我們保證的,所以我們根本無需擔心併發訪問的問題。所以在初始化進行一半的時候,別的線程是無法使用的,因為JVM會幫我們強行同步這個過程。另外由於靜態變數只初始化一次,所以singleton仍然是單例的。
那麼我們總結一下這種模式幫助我們做到了什麼:
1.在不考慮反射強行突破訪問限制的情況下,MyClassThree最多只有一個實例。
2.保證了併發訪問的情況下,不會由於初始化動作未完全完成而造成使用了尚未正確初始化的實例。
3.保證了併發訪問的情況下,不會發生由於併發而產生多個實例。
好了,到這裡單例模式LZ就講完了,下期預告,等下次再說=。=