吐血整理全網最全的單例模式
- 2020 年 6 月 3 日
- 筆記
- synchronized, 單例, 枚舉, 設計模式
前言
之前文章已經說過了設計模式的七大原則,即接口屏蔽原則,開閉原則,依賴倒轉原則,迪米特原則,里氏替換原則,單一職責原則,合成復用原則,不明白的,可以移至萬字總結之設計模式七大原則(//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)); } }
運行結果;
優點
避免了反射帶來的對象不一致問題和反序列問題,簡單來說,就是簡單高效沒問題。
結語
看到這裡的都是真愛的,在這裡先謝謝各位大佬啦。
單例模式是最簡單的一種設計模式,主要包括八種形式,分別是餓漢式靜態變量,餓漢式靜態代碼塊,懶漢式線程不安全,懶漢式線程安全,懶漢式線程不安全(沒啥意義),懶漢式雙重否定線程安全,內部靜態類,枚舉類型。
這幾種最優的是枚舉類型和內部靜態類,其次是懶漢式雙重否定,剩下的都差不多啦。
如果有說的不對的地方,還請各位指正,我好繼續學習去。
參考資料