為什麼用枚舉類來實現單例模式越來越流行?

  • 2019 年 10 月 3 日
  • 筆記

前言

單例模式是 Java 設計模式中最簡單的一種,只需要一個類就能實現單例模式,但是,你可不能小看單例模式,雖然從設計上來說它比較簡單,但是在實現當中你會遇到非常多的坑,所以,系好安全帶,上車。

單例模式的定義

單例模式就是在程式運行中只實例化一次,創建一個全局唯一對象,有點像 Java 的靜態變數,但是單例模式要優於靜態變數,靜態變數在程式啟動的時候JVM就會進行載入,如果不使用,會造成大量的資源浪費,單例模式能夠實現懶載入,能夠在使用實例的時候才去創建實例。開發工具類庫中的很多工具類都應用了單例模式,比例執行緒池、快取、日誌對象等,它們都只需要創建一個對象,如果創建多份實例,可能會帶來不可預知的問題,比如資源的浪費、結果處理不一致等問題。

單例的實現思路

  • 靜態化實例對象
  • 私有化構造方法,禁止通過構造方法創建實例
  • 提供一個公共的靜態方法,用來返回唯一實例

單例的好處

  • 只有一個對象,記憶體開支少、性能好

  • 避免對資源的多重佔用

  • 在系統設置全局訪問點,優化和共享資源訪問

單例模式的實現

單例模式的寫法有餓漢模式、懶漢模式、雙重檢查鎖模式、靜態內部類單例模式、枚舉類實現單例模式五種方式,其中懶漢模式、雙重檢查鎖模式,如果你寫法不當,在多執行緒情況下會存在不是單例或者單例出異常等問題,具體的原因,在後面的對應處會進行說明。我們從最基本的餓漢模式開始我們的單例編寫之路。

餓漢模式

餓漢模式採用一種簡單粗暴的形式,在定義靜態屬性時,直接實例化了對象。程式碼如下:

//在類載入時就完成了初始化,所以類載入較慢,但獲取對象的速度快  public class SingletonObject1 {      // 利用靜態變數來存儲唯一實例      private static final SingletonObject1 instance = new SingletonObject1();        // 私有化構造函數      private SingletonObject1(){          // 裡面可能有很多操作      }        // 提供公開獲取實例介面      public static SingletonObject1 getInstance(){          return instance;      }  }  

餓漢模式的優缺點

優點
  • 由於使用了static關鍵字,保證了在引用這個變數時,關於這個變數的所以寫入操作都完成,所以保證了JVM層面的執行緒安全
缺點
  • 不能實現懶載入,造成空間浪費,如果一個類比較大,我們在初始化的時就載入了這個類,但是我們長時間沒有使用這個類,這就導致了記憶體空間的浪費。

懶漢模式

懶漢模式是一種偷懶的模式,在程式初始化時不會創建實例,只有在使用實例的時候才會創建實例,所以懶漢模式解決了餓漢模式帶來的空間浪費問題,同時也引入了其他的問題,我們先來看看下面這個懶漢模式

public class SingletonObject2 {      // 定義靜態變數時,未初始化實例      private static SingletonObject2 instance;        // 私有化構造函數      private SingletonObject2(){        }        public static SingletonObject2 getInstance(){          // 使用時,先判斷實例是否為空,如果實例為空,則實例化對象          if (instance == null)              instance = new SingletonObject2();          return instance;      }  }

上面是懶漢模式的實現方式,但是上面這段程式碼在多執行緒的情況下是不安全的,因為它不能保證是單例模式,有可能會出現多份實例的情況,出現多份實例的情況是在創建實例對象時候造成的。所以我單獨把實例化的程式碼提出,來分析一下為什麼會出現多份實例的情況。

     1   if (instance == null)       2       instance = new SingletonObject2();

假設有兩個執行緒都進入到 1 這個位置,因為沒有任何資源保護措施,所以兩個執行緒可以同時判斷的instance都為空,都將去執行 2 的實例化程式碼,所以就會出現多份實例的情況。

通過上面的分析我們已經知道出現多份實例的原因,如果我們在創建實例的時候進行資源保護,是不是可以解決多份實例的問題?確實如此,我們給getInstance()方法加上synchronized關鍵字,使得getInstance()方法成為受保護的資源就能夠解決多份實例的問題。加上synchronized關鍵字之後程式碼如下:

public class SingletonObject3 {      private static SingletonObject3 instance;        private SingletonObject3(){        }        public synchronized static SingletonObject3 getInstance(){          /**           * 添加class類鎖,影響了性能,加鎖之後將程式碼進行了串列化,           * 我們的程式碼塊絕大部分是讀操作,在讀操作的情況下,程式碼執行緒是安全的           *           */            if (instance == null)              instance = new SingletonObject3();          return instance;      }  }

經過修改後,解決了多份實例的問題,但是因為引入synchronized關鍵字,對程式碼加了鎖,就引入了新的問題,加鎖之後會使得程式變成串列化,只有搶到鎖的執行緒才能去執行這段程式碼塊,這會使得系統的性能大大下降。

懶漢模式的優缺點

優點
  • 實現了懶載入,節約了記憶體空間
缺點
  • 在不加鎖的情況下,執行緒不安全,可能出現多份實例
  • 在加鎖的情況下,會是程式串列化,使系統有嚴重的性能問題

雙重檢查鎖模式

再來討論一下懶漢模式中加鎖的問題,對於getInstance()方法來說,絕大部分的操作都是讀操作,讀操作是執行緒安全的,所以我們沒必讓每個執行緒必須持有鎖才能調用該方法,我們需要調整加鎖的問題。由此也產生了一種新的實現模式:雙重檢查鎖模式,下面是雙重檢查鎖模式的單例實現程式碼塊:

public class SingletonObject4 {      private static SingletonObject4 instance;        private SingletonObject4(){        }        public static SingletonObject4 getInstance(){            // 第一次判斷,如果這裡為空,不進入搶鎖階段,直接返回實例          if (instance == null)              synchronized (SingletonObject4.class){                  // 搶到鎖之後再次判斷是否為空                  if (instance == null){                      instance = new SingletonObject4();                  }              }            return instance;      }  }

雙重檢查鎖模式是一種非常好的單例實現模式,解決了單例、性能、執行緒安全問題,上面的雙重檢測鎖模式看上去完美無缺,其實是存在問題,在多執行緒的情況下,可能會出現空指針問題,出現問題的原因是JVM在實例化對象的時候會進行優化和指令重排序操作。什麼是指令重排?,看下面這個例子,簡單了解一下指令從排序

    private SingletonObject4(){       1   int x = 10;       2   int y = 30;       3  Object o = new Object();        }

上面的構造函數SingletonObject4(),我們編寫的順序是1、2、3,JVM 會對它進行指令重排序,所以執行順序可能是3、1、2,也可能是2、3、1,不管是那種執行順序,JVM 最後都會保證所以實例都完成實例化。 如果構造函數中操作比較多時,為了提升效率,JVM 會在構造函數裡面的屬性未全部完成實例化時,就返回對象。雙重檢測鎖出現空指針問題的原因就是出現在這裡,當某個執行緒獲取鎖進行實例化時,其他執行緒就直接獲取實例使用,由於JVM指令重排序的原因,其他執行緒獲取的對象也許不是一個完整的對象,所以在使用實例的時候就會出現空指針異常問題。

要解決雙重檢查鎖模式帶來空指針異常的問題,只需要使用volatile關鍵字,volatile關鍵字嚴格遵循happens-before原則,即在讀操作前,寫操作必須全部完成。添加volatile關鍵字之後的單例模式程式碼:

    // 添加volatile關鍵字      private static volatile SingletonObject5 instance;        private SingletonObject5(){        }        public static SingletonObject5 getInstance(){            if (instance == null)              synchronized (SingletonObject5.class){                  if (instance == null){                      instance = new SingletonObject5();                  }              }            return instance;      }  }

添加volatile關鍵字之後的雙重檢查鎖模式是一種比較好的單例實現模式,能夠保證在多執行緒的情況下執行緒安全也不會有性能問題。

靜態內部類單例模式

靜態內部類單例模式也稱單例持有者模式,實例由內部類創建,由於 JVM 在載入外部類的過程中, 是不會載入靜態內部類的, 只有內部類的屬性/方法被調用時才會被載入, 並初始化其靜態屬性。靜態屬性由static修飾,保證只被實例化一次,並且嚴格保證實例化順序。靜態內部類單例模式程式碼如下:

public class SingletonObject6 {          private SingletonObject6(){        }      // 單例持有者      private static class InstanceHolder{          private  final static SingletonObject6 instance = new SingletonObject6();        }        //      public static SingletonObject6 getInstance(){          // 調用內部類屬性          return InstanceHolder.instance;      }  }

靜態內部類單例模式是一種優秀的單例模式,是開源項目中比較常用的一種單例模式。在沒有加任何鎖的情況下,保證了多執行緒下的安全,並且沒有任何性能影響和空間的浪費。

枚舉類實現單例模式

枚舉類實現單例模式是 effective java 作者極力推薦的單例實現模式,因為枚舉類型是執行緒安全的,並且只會裝載一次,設計者充分的利用了枚舉的這個特性來實現單例模式,枚舉的寫法非常簡單,而且枚舉類型是所用單例實現中唯一一種不會被破壞的單例實現模式。

public class SingletonObject7 {          private SingletonObject7(){        }        /**       * 枚舉類型是執行緒安全的,並且只會裝載一次       */      private enum Singleton{          INSTANCE;            private final SingletonObject7 instance;            Singleton(){              instance = new SingletonObject7();          }            private SingletonObject7 getInstance(){              return instance;          }      }        public static SingletonObject7 getInstance(){            return Singleton.INSTANCE.getInstance();      }  }

破壞單例模式的方法及解決辦法

1、除枚舉方式外, 其他方法都會通過反射的方式破壞單例,反射是通過調用構造方法生成新的對象,所以如果我們想要阻止單例破壞,可以在構造方法中進行判斷,若已有實例, 則阻止生成新的實例,解決辦法如下:

private SingletonObject1(){      if (instance !=null){          throw new RuntimeException("實例已經存在,請通過 getInstance()方法獲取");      }  }

2、如果單例類實現了序列化介面Serializable, 就可以通過反序列化破壞單例,所以我們可以不實現序列化介面,如果非得實現序列化介面,可以重寫反序列化方法readResolve(), 反序列化時直接返回相關單例對象。

  public Object readResolve() throws ObjectStreamException {          return instance;      }

最後

打個小廣告,歡迎掃碼關注微信公眾號:「平頭哥的技術博文」,一起進步吧。