1. 程式人生 > >單例模式的幾種實現And反射對其的破壞

單例模式的幾種實現And反射對其的破壞

![](//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e7b0307b36dd47eeac7f5502aaaff023~tplv-k3u1fbpfcp-zoom-1.image) # 一 單例模式概述 ## (一) 什麼是單例模式 單例模式屬於**建立型模式**之一,它提供了一種建立物件的最佳方式 > 在軟體工程中,建立型模式是處理物件建立的設計模式,試圖根據實際情況使用合適的方式建立物件。基本的物件建立方式可能會導致設計上的問題,或增加設計的複雜度。建立型模式通過以某種方式控制物件的建立來解決問題。 因為我們平時雖然可以定義一個全域性變數使一個物件被訪問,但是它並不能保證你多次例項化物件,最直觀的,**多次建立物件的代價就是消耗效能**,導致效率會低一些。單例模式就是用來解決這些問題 順便提一個很常見的例子:例如在 Win 系的電腦下我們永遠只能開啟一個工作管理員,這樣可以避免出現一些資源浪費,以及多視窗顯示資料不一致的問題 **定義:單例模式,保證一個類僅有一個例項,並且提供一個訪問它的全域性訪問點** ## (二) 特點 - **① 單例類只能有一個例項物件** - **② 單例類必須自己建立自己的唯一例項** - **③ 單例類必須對外提供一個訪問該例項的方法** ## (三) 優缺點以及使用場景 ### (1) 優點 - **提供了對唯一例項的受控訪問** - **保證了記憶體中只有唯一例項,減少了記憶體的開銷** - 尤其表現在一些需要多次建立銷燬例項的情況下 - **避免對資源的多重佔用** - 比如對檔案的寫操作 ### (2) 缺點 - 單例模式中沒有抽象層,沒有介面,不能繼承,擴充套件困難,擴充套件需要修改原來的程式碼,違背了 **“開閉原則”** - 單例類的程式碼一般寫在同一個類中,一定程度上職責過重,違背了 **“單一職責原則”** ### (3) 應用場景 **先說幾個大家常見單例的例子:** - Windows 下的工作管理員和回收站,都是典型的單例模式,你可以試一下,沒法同時開啟兩個的哈 - 資料庫連線池的設計一般也是單例模式,因為頻繁的開啟關閉與資料庫的連線,會有不小的效率損耗 - 但是濫用單例也可能帶來一些問題,例如導致共享連線池物件的程式過多而出現連線池溢位 - 網站計數器,通過單例解決同步問題 - 作業系統的檔案系統 - Web 應用的配置物件讀取,因為配置檔案屬於共享的資源 - 程式的日誌應用,一般也是單例,否則追加內容時,容易出問題 **所以,根據一些常見的例子,簡單總結一下,什麼時候用單例模式呢?** - ① 需要頻繁建立銷燬例項的 - ② 例項建立時,消耗資源過多,或者耗時較多的,例如資料連線或者IO - ③ 某個類只要求生成一個類的情況,例如生成唯一序列號,或者人的身份證 - ④ 物件需要共享的情況,如 Web 中配置物件 # 二 實現單例模式 根據單例模式的定義和特點,我們可以分為三步來實現最基本的單例模式 - **① 建構函式私有化** - **② 在類的內部建立例項** - **③ 提供本類例項的唯一全域性訪問點,即提供獲取唯一例項的方法** ## (一) 餓漢式 我們就按照最基本的這三點來寫 ```java public class Hungry { // 構造器私有,靜止外部new private Hungry(){} // 在類的內部建立自己的例項 private static Hungry hungry = new Hungry(); // 獲取本類例項的唯一全域性訪問點 public static Hungry getHungry(){ return hungry; } } ``` 這種做法一開始就直接建立這個例項,我們也稱為餓漢式單例,但是如果**這個例項一直沒有被呼叫,會造成記憶體的浪費**,顯然這樣做是不合適的 ## (二) 懶漢式 餓漢式的主要問題在於,一開始就建立例項導致的記憶體浪費問題,那麼我們將建立物件的步驟,挪到具體使用的時候 ```java public class Lazy1 { // 構造器私有,靜止外部new private Lazy1(){ System.out.println(Thread.currentThread().getName() + " 訪問到了"); } // 定義即可,不真正建立 private static Lazy1 lazy1 = null; // 獲取本類例項的唯一全域性訪問點 public static Lazy1 getLazy1(){ // 如果例項不存在則new一個新的例項,否則返回現有的例項 if (lazy1 == null) { lazy1 = new Lazy1(); } return lazy1; } public static void main(String[] args) { // 多執行緒訪問,看看會有什麼問題 for (int i = 0; i < 10; i++) { new Thread(()->{ Lazy1.getLazy1(); }).start(); } } } ``` 例如上述程式碼,我們只在剛開始做了一個定義,真正的例項化是在呼叫 getLazy1() 時被執行 單執行緒環境下是沒有問題的,但是多執行緒的情況下就會出現問題,例如下面是我執行結果中的一次: ``` Thread-0 訪問到了 Thread-4 訪問到了 Thread-1 訪問到了 Thread-3 訪問到了 Thread-2 訪問到了 ``` ## (三) DCL 懶漢式 ### (1) 方法上直接加鎖 很顯然,多執行緒下的普通懶漢式出現了問題,這個時候,我們只需要加一層鎖就可以解決 簡單的做法就是在方法前加上 synchronized 關鍵字 ```java public static synchronized Lazy1 getLazy1(){ if (lazy1 == null) { lazy1 = new Lazy1(); } return lazy1; } ``` ### (2) 縮小鎖的範圍 但是我們又想縮小鎖的範圍,畢竟方法上加鎖,多執行緒中效率會低一些,所以只把鎖加到需要的程式碼上 我們直觀的可能會這樣寫 ```java public static Lazy1 getLazy1(){ if (lazy1 == null) { synchronized(Lazy1.class){ lazy1 = new Lazy1(); } } return lazy1; } ``` 但是這樣還是有問題的 ### (3) 雙重鎖定 當執行緒 A 和 B 同時訪問getLazy1(),執行到到 `if (lazy1 == null)` 這句的時候,同時判斷出 lazy1 == null,也就同時進入了 if 程式碼塊中,後面因為加了鎖,只有一個能先執行例項化的操作,例如 A 先進入,但是 後面的 B 進入後同樣也可以建立新的例項,就達不到單例的目的了,不信可以自己試一下 解決的方式就是再進行第二次的判斷 ```java // 獲取本類例項的唯一全域性訪問點 public static Lazy1 getLazy1(){ // 如果例項不存在則new一個新的例項,否則返回現有的例項 if (lazy1 == null) { // 加鎖 synchronized(Lazy1.class){ // 第二次判斷是否為null if (lazy1 == null){ lazy1 = new Lazy1(); } } } return lazy1; } ``` ### (4) 指令重排問題 這種在適當位置加鎖的方式,儘可能的降低了加鎖對於效能的影響,也能達到預期效果 但是這段程式碼,在一定條件下還是會有問題,那就是指令重排問題 > 指令重排序是JVM為了優化指令,提高程式執行效率,在不影響單執行緒程式執行結果的前提下,儘可能地提高並行度。 什麼意思呢? 首先要知道 `lazy1 = new Lazy1();` 這一步並不是一個原子性操作,也就是說這個操作會分成很多步 - ① 分配物件的記憶體空間 - ② 執行建構函式,初始化物件 - ③ 指向物件到剛分配的記憶體空間 但是 JVM 為了效率對這個步驟進行了重排序,例如這樣: - ① 分配物件的記憶體空間 - **③ 指向物件到剛分配的記憶體空間,物件還沒被初始化** - ② 執行建構函式,初始化物件 按照 ① ③ ② 的順序,當 A 執行緒執行到 ② 後,B執行緒判斷 lazy1 != null ,但是此時的 lazy1 還沒有被初始化,所以會出問題,並且這個過程中 B 根本執行到鎖那裡,配個表格說明一下: | Time | ThreadA | ThreadB | | ---- | ------------------------------------------------- | --------------------------------------------------- | | t1 | A:① 分配物件的記憶體空間 | | | t2 | A:③ 指向物件到剛分配的記憶體空間,物件還沒被初始化 | | | t3 | | B:判斷 lazy1 是否為 null | | t4 | | B:判斷到 lazy1 != null,返回了一個沒被初始化的物件 | | t5 | A:② 初始化物件 | | 解決的方法很簡單——在定義時增加 volatile 關鍵字,避免指令重排 ### (5) 最終程式碼 最終程式碼如下: ```java public class Lazy1 { // 構造器私有,靜止外部new private Lazy1(){ System.out.println(Thread.currentThread().getName() + " 訪問到了"); } // 定義即可,不真正建立 private static volatile Lazy1 lazy1 = null; // 獲取本類例項的唯一全域性訪問點 public static Lazy1 getLazy1(){ // 如果例項不存在則new一個新的例項,否則返回現有的例項 if (lazy1 == null) { // 加鎖 synchronized(Lazy1.class){ // 第二次判斷是否為null if (lazy1 == null){ lazy1 = new Lazy1(); } } } return lazy1; } public static void main(String[] args) { // 多執行緒訪問,看看會有什麼問題 for (int i = 0; i < 10; i++) { new Thread(()->{ Lazy1.getLazy1(); }).start(); } } } ``` ## (四) 靜態內部類懶漢式 雙重鎖定算是一種可行不錯的方式,而靜態內部類就是一種更加好的方法,不僅速度較快,還保證了執行緒安全,先看程式碼 ```java public class Lazy2 { // 構造器私有,靜止外部new private Lazy2(){ System.out.println(Thread.currentThread().getName() + " 訪問到了"); } // 用來獲取物件 public static Lazy2 getLazy2(){ return InnerClass.lazy2; } // 建立內部類 public static class InnerClass { // 建立單例物件 private static Lazy2 lazy2 = new Lazy2(); } public static void main(String[] args) { // 多執行緒訪問,看看會有什麼問題 for (int i = 0; i < 10; i++) { new Thread(()->{ Lazy2.getLazy2(); }).start(); } } } ``` 上面的程式碼,首先 InnerClass 是一個內部類,其在初始化時是不會被載入的,當用戶執行了 getLazy2() 方法才會載入,同時建立單例物件,所以他也是懶漢式的方法,因為 InnerClass 是一個靜態內部類,所以只會被例項化一次,從而達到執行緒安全,因為並沒有加鎖,所以效能上也會很快,所以一般是推薦的 ## (五) 列舉方式 最後推薦一個非常好的方式,那就是列舉單例方式,其不僅簡單,且保證了安全,先看一下 《Effective Java》中作者的說明: > 這種方法在功能上與公有域方法相似,但更加簡潔無償地提供了序列化機制,絕對防止多次例項化。即使是在面對複雜的序列化或者反射攻擊的時候。雖然這種方法還沒有廣泛採用,**但是單元素的列舉型別經常成為實現Singleton 的最佳方法**,注意,如果 Singleton 必須擴充套件一個超類,而不是擴充套件 enum 時則不宜使用這個方法,(雖然可以宣告列舉去實現介面)。 > > 節選自 《Effective Java》第3條:用私有構造器或者列舉型別強化 Singleton 屬性 > > 原著:Item3: Enforce the singleton property with a private constructor or an enum 程式碼就這樣,簡直不要太簡單,訪問通過 `EnumSingle.IDEAL` 就可以訪問了 ```java public enum EnumSingle { IDEAL; } ``` 我們接下來就要給大家演示為什麼列舉是一種比較安全的方式 # 三 反射破壞單例模式 ## (一) 單例是如何被破壞的 下面用雙重鎖定的懶漢式單例演示一下,這是我們原來的寫法,new 兩個例項出來,輸出一下 ```java public class Lazy1 { // 構造器私有,靜止外部new private Lazy1(){ System.out.println(Thread.currentThread().getName() + " 訪問到了"); } // 定義即可,不真正建立 private static volatile Lazy1 lazy1 = null; // 獲取本類例項的唯一全域性訪問點 public static Lazy1 getLazy1(){ // 如果例項不存在則new一個新的例項,否則返回現有的例項 if (lazy1 == null) { // 加鎖 synchronized(Lazy1.class){ // 第二次判斷是否為null if (lazy1 == null){ lazy1 = new Lazy1(); } } } return lazy1; } public static void main(String[] args) { Lazy1 lazy1 = getLazy1(); Lazy1 lazy2 = getLazy1(); System.out.println(lazy1); System.out.println(lazy2); } } ``` 執行結果: main 訪問到了 cn.ideal.single.Lazy1@1b6d3586 cn.ideal.single.Lazy1@1b6d3586 可以看到,結果是單例沒有問題 ### (1) 一個普通例項化,一個反射例項化 但是我們如果通過反射的方式進行例項化類,會有什麼問題呢? ```java public static void main(String[] args) throws Exception { Lazy1 lazy1 = getLazy1(); // 獲得其空參構造器 Con