1. 程式人生 > >吐血整理全網最全的單例模式

吐血整理全網最全的單例模式

前言

之前文章已經說過了設計模式的七大原則,即介面遮蔽原則,開閉原則,依賴倒轉原則,迪米特原則,里氏替換原則,單一職責原則,合成複用原則,不明白的,可以移至萬字總結之設計模式七大原則(https://www.cnblogs.com/chenchen0618/p/12434603.html)。從今天開始我們就要學習一些常見的設計模式,方便我們以後看原始碼使用,當然,也可以指導我們平常的編碼任務。

我們常見的設計模式主要有23種,分為3種類型,咱也不全說,只寫重要的幾個把。

建立型:單例模式,工廠模式,原型模式

結構型:介面卡模式,裝飾模式,代理模式

行為型:模板模式,觀察者模式,狀態模式,責任鏈模式

單例模式的概念和作用

概念

系統中只需要一個全域性的例項,比如一些工具類,Converter,SqlSession等。

為什麼要用單例模式?

  • 只有一個全域性的例項,減少了記憶體開支,特別是某個物件需要頻繁的建立和銷燬的時候,而建立和銷燬的過程由jvm執行,我們無法對其進行優化,所以單例模式的優勢就顯現出來啦。
  • 單例模式可以避免對資源的多重佔用,避免出現多執行緒的複雜問題。

單例模式的寫法重點

構造方法私有化

我們需要將構造方法私有化,而預設不寫的話,是公有的構造方法,外部可以顯式的呼叫來建立物件,我們的目的是讓外部不能建立物件。

提供獲取例項的公有方法

對外只提供一個公有的的方法,用來獲取例項,而這個例項是否是唯一的,單例的,由方法決定,外部無需關心。

單例模式的常見寫法(如下,重點)

餓漢式和懶漢式的區別

餓漢式

餓漢式,從名字上也很好理解,就是“比較餓”,迫不及待的想吃飯,例項在初始化的時候就已經建好了,不管你有沒有用到,都先建好了再說。

懶漢式

餓漢式,從名字上也很好理解,就是“比較懶”,不想吃飯,等餓的時候再吃。在初始化的時候先不建好物件,如果之後用到了,再建立物件。

1.餓漢式(靜態變數)--可以使用

A類

public class A {
    //私有的構造方法
    private A(){}
    //私有的靜態變數
    private final static A a=new A();
    //對外的公有方法
    public static A getInstance(){
        return a;
    }
}

 

測試類

public class test {
    public static void main(String[] args){
        A a1=A.getInstance();
        System.out.println(a1.hashCode());

        A a2=A.getInstance();
        System.out.println(a2.hashCode());
    }
}

 

執行結果

說明

該方法採用的靜態常量的方法來生成對應的例項,其只在類載入的時候就生成了,後續並不會再生成,所以其為單例的。

優點

在類載入的時候,就完成例項化,避免執行緒同步問題。

缺點

沒有達到懶載入的效果,如果從始到終都沒有用到這個例項,可能會導致記憶體的浪費。

2.餓漢式(靜態程式碼塊)--可以使用

A類

public class A { 
    //私有的構造方法
     private A(){}
    //私有的靜態變數
     private final static A a; 
    //靜態程式碼塊 
    static{ a=new A(); } 
    //對外的公有方法
    public static A getInstance(){
     return a; 
    }
}

 

測試類

public class test {
    public static void main(String[] args){
        A a1=A.getInstance();
        System.out.println(a1.hashCode());

        A a2=A.getInstance();
        System.out.println(a2.hashCode());
    }
}

 

執行結果

說明

該靜態程式碼塊的餓漢式單例模式與靜態變數的餓漢式模式大同小異,只是將初始化過程移到了靜態程式碼塊中。

優點缺點

與靜態變數餓漢式的優缺點類似。

3.懶漢式

A類

public class A {
    //私有的構造方法
    private A(){}
    //私有的靜態變數
    private  static A a;
    //對外的公有方法
    public static A getInstance(){
        if(a==null){
            a=new A();
        }
        return a;
    }
}

 

測試類和執行結果

同上。

優點

該方法的確做到了用到即載入,也就是當呼叫getInstance的時候,才判斷是否有該物件,如果不為空,則直接放回,如果為空,則新建一個物件並返回,達到了懶載入的效果。

缺點

當多執行緒的時候,可能會產生多個例項。比如我有兩個執行緒,同時呼叫getInstance方法,並都到了if語句,他們都新建了物件,那這裡就不是單例的啦。

4.懶漢式(執行緒安全,同步方法)--可以使用

public class A {
    //私有的構造方法
    private A(){}
    //私有的靜態變數
    private  static A a;
    //對外的公有方法
    public synchronized static A getInstance(){
        if(a==null){
            a=new A();
        }
        return a;
    }
}

 

測試類和執行結果

同上。

優點

通過synchronize關鍵字,解決了執行緒不安全的問題。如果兩個執行緒同時呼叫getInstance方法時,那就先執行一個執行緒,另一個等待,等第一個執行緒執行結束了,另一個等待的開始執行。

缺點

這種方法是解決了執行緒不安全的問題,卻給效能帶來了很大的問題,效率太低了,getInstance經常發生,每一次都要同步這個方法。

我們想著既然是方法同步導致了效能的問題,我們核心的程式碼就是新建物件的過程,也就是new A();的過程,我們能不能只對部分程式碼進行同步呢?

那就是方法5啦。

5.懶漢式(執行緒不安全)

A類

public class A {
    //私有的構造方法
    private A(){}
    //私有的靜態變數
    private  static A a;
    public  static A getInstance(){
        if(a==null){
            synchronized (A.class){
                a=new A();
            }
        }
        return a;
    }
} 

 

測試類和執行結果

如上。

優點

懶漢式的通用優點,用到才建立,達到懶載入的效果。

缺點

這個沒有意義,並沒有解決多執行緒的問題。我們可以看到如果兩個執行緒同時呼叫getInstance方法,並且都已經進入了if語句,即synchronized的位置,即便同步了,第一個執行緒先執行,進入synchronized同步的程式碼塊,建立了物件,另一個進入等待狀態,等第一個執行緒執行結束,第二個執行緒還是會進入synchronized同步的程式碼塊,建立物件。這個時候我們可以發現,對這程式碼塊加了synchronized沒有任何意義,還是建立了多個物件,並不符合單例。

6.雙重檢查 --強烈推薦使用

A類

public class A {
    //私有的構造方法
    private A() {
    }

    //私有的靜態變數
    private volatile static A a;

    //對外的公有方法
    public static A getInstance() {
        if (a == null) {
            synchronized (A.class) {
                if (a == null) {
                    a = new A();
                }
            }
        }
        return a;
    }
} 

 

測試類和執行結果

同上。

優點

強烈推薦使用,這種寫法既避免了在多執行緒中出現執行緒不安全的情況,也能提高效能。

咱具體來說,如果兩個執行緒同時呼叫了getInstance方法,並且都已到達了if語句之後,synchronized語句之前,此時第一個執行緒進入synchronized之中,先判斷是否為空,很顯然第一次肯定為空,那麼則新建了物件。等到第二個執行緒進入synchronized之中,先判斷是否為空,顯然第一個已經建立了,所以即不新建物件。下次,不管是一個執行緒或者多個執行緒,在第一個if語句那就判斷出有物件了,便直接返回啦,根本進不了裡面的程式碼。

缺點

就是這麼完美,沒有缺點,哈哈哈。

volatile(插曲)

咱先來看一個概念,重排序,也就是語句的執行順序會被重新安排。其主要分為三種:

1.編譯器優化的重排序:可以重新安排語句的執行順序。

2.指令級並行的重排序:現代處理器採用指令級並行技術,將多條指令重疊執行。

3.記憶體系統的重排序:由於處理器使用快取和讀寫緩衝區,所以看上去可能是亂序的。

上面程式碼中的a = new A();可能被被JVM分解成如下程式碼:

// 可以分解為以下三個步驟
1 memory=allocate();// 分配記憶體 相當於c的malloc
2 ctorInstanc(memory) //初始化物件
3 s=memory //設定s指向剛分配的地址
 // 上述三個步驟可能會被重排序為 1-3-2,也就是:
1 memory=allocate();// 分配記憶體 相當於c的malloc
3 s=memory //設定s指向剛分配的地址
2 ctorInstanc(memory) //初始化物件

 

一旦假設發生了這樣的重排序,比如執行緒A在執行了步驟1和步驟3,但是步驟2還沒有執行完。這個時候執行緒B有進入了第一個if語句,它會判斷a不為空,即直接返回了a。其實這是一個未初始化完成的a,即會出現問題。

所以我們會將入volatile關鍵字,來禁止這樣的重排序,即可正常執行。

7.靜態內部類 --強烈推薦使用

A類

public class A {
    //私有建構函式
    private A() {
    }

    //私有的靜態內部類
    private static class B {
        //私有的靜態變數
        private static A a = new A();
    }

    //對外的公有方法
    public static A getInstance() {
        return B.a;
    }
}

 

 

優點

B在A裝載的時候並不會裝載,而是會在呼叫getInstance的時候裝載,這利用了JVM的裝載機制。這樣一來,優點有兩點,其一就是沒有A載入的時候,就裝載了a物件,而是在呼叫的時候才裝載,避免了資源的浪費。其二是多執行緒狀態下,沒有執行緒安全性的問題。

缺點

沒有缺點,太完美啦。

8.列舉 --Java粑粑強烈推薦使用

問題1:私有構造器並不安全

如果不明白反射,可以檢視我之前的文章,傳送門,萬字總結之反射(框架之魂)。

如果我們的物件是通過反射方法invoke出來,這樣新建的物件與通過呼叫getInstance新建的物件是不一樣的,具體咱來看程式碼。

 

public class test {
    public static void main(String[] args) throws Exception {
        A a=A.getInstance();
        A b=A.getInstance();
        System.out.println("a的hash:"+a.hashCode()+",b的hash:"+b.hashCode());

        Constructor<A> constructor=A.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        A c=constructor.newInstance();
        System.out.println("a的hash:"+a.hashCode()+",c的hash:"+c.hashCode());

    }
}

 

我們來看下執行結果:

我們可以看到c的hashcode是和a,b不一樣,因為c是通過構造器反射出來的,由此可以證明私有構造器所組成的單例模式並不是十分安全的。

問題2:序列化問題

我們先將A類實現一個Serializable介面,具體程式碼如下,跟之前的雙重if檢查一樣,只是多了個介面。

 

public class A implements Serializable {
    //私有的構造方法
    private A() {
    }

    //私有的靜態變數
    private volatile static A a;

    //對外的公有方法
    public static A getInstance() {
        if (a == null) {
            synchronized (A.class) {
                if (a == null) {
                    a = new A();
                }
            }
        }
        return a;
    }
} 

 

測試類:

public class test {
    public static void main(String[] args) throws Exception {
        A s = A.getInstance();

        //寫
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("學習Java的小姐姐"));
        oos.writeObject(s);
        oos.flush();
        oos.close();
        //讀
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("學習Java的小姐姐"));
        A s1 = (A)ois.readObject();
        ois.close();

        System.out.println(s+"\n"+s1);
        System.out.println("序列化前後兩個是否同一個:"+(s==s1));
    }
}

 

我們來看下執行結果,很顯然序列化前後兩個物件並不相等。為什麼會出現這種問題呢?這個講起來,又可以寫一篇文章了。簡單來說,任何一個readObject方法,不管是顯式的還是預設的,它都會返回一個新建的例項,這個新建的例項不同於該類初始化時建立的例項。

A類

public enum A {
  a;
  public A getInstance(){
      return a;
  }
}

 

看著程式碼量很少,我們將其編譯下,程式碼如下:

public final class  A extends Enum< A> {      
public static final A a;
public static A[] values();
public static AvalueOf(String s);
static {}; }

 

如何解決問題1?

public class test {
    public static void main(String[] args) throws Exception {
        A a1 = A.a;
        A a2 = A.a;
        System.out.println("正常情況下,例項化兩個例項是否相同:" + (a1 == a2));

        Constructor<A> constructor = null;
        constructor = A.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        A a3 = null;
        a3 = constructor.newInstance();
        System.out.println("a1的hash:" + a1.hashCode() + ",a2的hash:" + a2.hashCode() + ",a3的hash:" + a3.hashCode());
        System.out.println("通過反射攻擊單例模式情況下,例項化兩個例項是否相同:" + (a1 == a3));
    }
}

 

執行結果:

我們看到報錯了,是在尋找建構函式的時候報錯的,即沒有無參的構造方法,那我們看下他繼承的父類ENUM有沒有建構函式,看下原始碼,發現有個兩個引數String和int型別的構造方法,我們再看下是不是構造方法的問題。

我們再用父類的有參構造方法試下,程式碼如下:

public class test {
    public static void main(String[] args) throws Exception {
        A a1 = A.a;
        A a2 = A.a;
        System.out.println("正常情況下,例項化兩個例項是否相同:" + (a1 == a2));
        Constructor<A> constructor = null;
        constructor = A.class.getDeclaredConstructor(String.class,int.class);//其父類的構造器
        constructor.setAccessible(true);
        A a3 = null;
        a3 = constructor.newInstance("學習Java的小姐姐",1);
        System.out.println("a1的hash:" + a1.hashCode() + ",a2的hash:" + a2.hashCode() + ",a3的hash:" + a3.hashCode());
        System.out.println("通過反射攻擊單例模式情況下,例項化兩個例項是否相同:" + (a1 == a3));
    }
}

執行結果如下:

我們發現報錯資訊的位置已經換了,現在是已經有構造方法,而是在newInstance方法的時候報錯了,我們跟下原始碼發現,人家已經明確寫明瞭如果是列舉型別,直接丟擲異常,程式碼如下,所以是無法使用反射來操作列舉型別的資料的。

如何解決問題2?

public class test {
    public static void main(String[] args) throws Exception {
        A s = A.a;

        //寫
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("學習Java的小姐姐"));
        oos.writeObject(s);
        oos.flush();
        oos.close();
        //讀
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("學習Java的小姐姐"));
        A s1 = (A)ois.readObject();
        ois.close();

        System.out.println(s+"\n"+s1);
        System.out.println("序列化前後兩個是否同一個:"+(s==s1));
    }
}

 

執行結果;

優點

避免了反射帶來的物件不一致問題和反序列問題,簡單來說,就是簡單高效沒問題。

結語

看到這裡的都是真愛的,在這裡先謝謝各位大佬啦。

單例模式是最簡單的一種設計模式,主要包括八種形式,分別是餓漢式靜態變數,餓漢式靜態程式碼塊,懶漢式執行緒不安全,懶漢式執行緒安全,懶漢式執行緒不安全(沒啥意義),懶漢式雙重否定執行緒安全,內部靜態類,列舉型別。

這幾種最優的是列舉型別和內部靜態類,其次是懶漢式雙重否定,剩下的都差不多啦。

如果有說的不對的地方,還請各位指正,我好繼續學習去。

參考資料

一個單例模式中volatile關鍵字引發的思考