吐血整理全網最全的單例模式
- 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)); } }
運行結果;
優點
避免了反射帶來的對象不一致問題和反序列問題,簡單來說,就是簡單高效沒問題。
結語
看到這裡的都是真愛的,在這裡先謝謝各位大佬啦。
單例模式是最簡單的一種設計模式,主要包括八種形式,分別是餓漢式靜態變數,餓漢式靜態程式碼塊,懶漢式執行緒不安全,懶漢式執行緒安全,懶漢式執行緒不安全(沒啥意義),懶漢式雙重否定執行緒安全,內部靜態類,枚舉類型。
這幾種最優的是枚舉類型和內部靜態類,其次是懶漢式雙重否定,剩下的都差不多啦。
如果有說的不對的地方,還請各位指正,我好繼續學習去。
參考資料