單例模式相信你們都有所聽聞,甚至也寫過很多了,在面試中也是考得最多的其中一個設計模式,面試官經常會要求寫出兩種類型的單例模式而且解釋其原理,廢話很少說,咱們開始學習如何很好地回答這一道面試題吧。java
面試官問什麼是單例模式時,千萬不要答非所問,給出單例模式有兩種類型之類的回答,要圍繞單例模式的定義去展開。面試
單例模式是指在內存中只會建立且僅建立一次對象的設計模式。在程序中屢次使用同一個對象且做用相同時,爲了防止頻繁地建立對象使得內存飆升,單例模式可讓程序僅在內存中建立一個對象,讓全部須要調用的地方都共享這一單例對象。設計模式
單例模式有兩種類型:安全
懶漢式
:在真正須要使用對象時纔去建立該單例類對象餓漢式
:在類加載時已經建立好該單例對象,等待被程序使用懶漢式建立對象的方法是在程序使用對象前,先判斷該對象是否已經實例化(判空),若已實例化直接返回該類對象。不然則先執行實例化操做。多線程
根據上面的流程圖,就能夠寫出下面的這段代碼併發
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
複製代碼
沒錯,這裏咱們已經寫出了一個很不錯的單例模式,不過它不是完美的,可是這並不影響咱們使用這個「單例對象」。性能
以上就是懶漢式建立單例對象的方法,我會在後面解釋這段代碼在哪裏能夠優化,存在什麼問題。學習
餓漢式在類加載
時已經建立好該對象,在程序調用時直接返回該單例對象便可,即咱們在編碼時就已經指明瞭要立刻建立這個對象,不須要等到被調用時再去建立。測試
關於類加載,涉及到JVM的內容,咱們目前能夠簡單認爲在程序啓動時,這個單例對象就已經建立好了。優化
public class Singleton{
private static final Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance() {
return singleton;
}
}
複製代碼
注意上面的代碼在第3行已經實例化好了一個Singleton對象在內存中,不會有多個Singleton對象實例存在
類在加載時會在堆內存中建立一個Singleton對象,當類被卸載時,Singleton對象也隨之消亡了。
咱們再來回顧懶漢式的核心方法
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
複製代碼
這個方法實際上是存在問題的,試想一下,若是兩個線程同時判斷 singleton 爲空,那麼它們都會去實例化一個Singleton 對象,這就變成多例了。因此,咱們要解決的是線程安全
問題。
最容易想到的解決方法就是在方法上加鎖,或者是對類對象加鎖,程序就會變成下面這個樣子
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
// 或者
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
複製代碼
這樣就規避了兩個線程同時建立Singleton對象的風險,可是引來另一個問題:每次去獲取對象都須要先獲取鎖,併發性能很是地差,極端狀況下,可能會出現卡頓現象。
接下來要作的就是優化性能
:目標是若是沒有實例化對象則加鎖建立,若是已經實例化了,則不須要加鎖,直接獲取實例
因此直接在方法上加鎖的方式就被廢掉了,由於這種方式不管如何都須要先獲取鎖
public static Singleton getInstance() {
if (singleton == null) { // 線程A和線程B同時看到singleton = null,若是不爲null,則直接返回singleton
synchronized(Singleton.class) { // 線程A或線程B得到該鎖進行初始化
if (singleton == null) { // 其中一個線程進入該分支,另一個線程則不會進入該分支
singleton = new Singleton();
}
}
}
return singleton;
}
複製代碼
上面的代碼已經完美地解決了併發安全 + 性能低效問題:
由於須要兩次判空,且對類對象加鎖,該懶漢式寫法也被稱爲:Double Check(雙重校驗) + Lock(加鎖)
完整的代碼以下所示:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) { // 線程A和線程B同時看到singleton = null,若是不爲null,則直接返回singleton
synchronized(Singleton.class) { // 線程A或線程B得到該鎖進行初始化
if (singleton == null) { // 其中一個線程進入該分支,另一個線程則不會進入該分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
複製代碼
上面這段代碼已經近似完美了,可是還存在最後一個問題:指令重排
建立一個對象,在 JVM 中會通過三步:
(1)爲 singleton 分配內存空間
(2)初始化 singleton 對象
(3)將 singleton 指向分配好的內存空間
指令重排序是指:JVM 在保證最終結果正確的狀況下,能夠不按照程序編碼的順序執行語句,儘量提升程序的性能
在這三步中,第 二、3 步有可能會發生指令重排現象,建立對象的順序變爲 1-3-2,會致使多個線程獲取對象時,有可能線程 A 建立對象的過程當中,執行了 一、3 步驟,線程 B 判斷 singleton 已經不爲空,獲取到未初始化的singleton 對象,就會報 NPE 異常。文字較爲晦澀,能夠看流程圖:
使用 volatile 關鍵字能夠**防止指令重排序,**其原理較爲複雜,這篇文章不打算展開,能夠這樣理解:使用 volatile 關鍵字修飾的變量,能夠保證其指令執行的順序與程序指明的順序一致,不會發生順序變換,這樣在多線程環境下就不會發生 NPE 異常了。
volatile 還有第二個做用:使用 volatile 關鍵字修飾的變量,能夠保證其內存可見性,即每一時刻線程讀取到該變量的值都是內存中最新的那個值,線程每次操做該變量都須要先讀取該變量。
最終的代碼以下所示:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) { // 線程A和線程B同時看到singleton = null,若是不爲null,則直接返回singleton
synchronized(Singleton.class) { // 線程A或線程B得到該鎖進行初始化
if (singleton == null) { // 其中一個線程進入該分支,另一個線程則不會進入該分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
複製代碼
不管是完美的懶漢式仍是餓漢式,終究敵不過反射和序列化,它們倆均可以把單例對象破壞掉(產生多個對象)。
下面是一段使用反射破壞單例模式的例子
public static void main(String[] args) {
// 獲取類的顯式構造器
Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
// 可訪問私有構造器
construct.setAccessible(true);
// 利用反射構造新對象
Singleton obj1 = construct.newInstance();
// 經過正常方式獲取單例對象
Singleton obj2 = Singleton.getInstance();
System.out.println(obj1 == obj2); // false
}
複製代碼
上述的代碼一針見血了:利用反射,強制訪問類的私有構造器,去建立另外一個對象
下面是一種使用序列化和反序列化破壞單例模式的例子
public static void main(String[] args) {
// 建立輸出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
// 將單例對象寫到文件中
oos.writeObject(Singleton.getInstance());
// 從文件中讀取單例對象
File file = new File("Singleton.file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Singleton newInstance = (Singleton) ois.readObject();
// 判斷是不是同一個對象
System.out.println(newInstance == Singleton.getInstance()); // false
}
複製代碼
兩個對象地址不相等的緣由是:readObject() 方法讀入對象時它一定會返回一個新的對象實例,必然指向新的內存地址。
咱們已經掌握了懶漢式與餓漢式的常見寫法了,一般狀況下到這裏已經足夠了。可是,追求極致的咱們,怎麼可以止步於此,在《Effective Java》書中,給出了終極解決方法,話很少說,學完下面,真的不虛面試官考你了。
在 JDK 1.5 後,使用 Java 語言實現單例模式的方式又多了一種:枚舉
枚舉實現單例模式完整代碼以下:
public enum Singleton {
INSTANCE;
public void doSomething() {
System.out.println("這是枚舉類型的單例模式!");
}
}
複製代碼
使用枚舉實現單例模式較其它兩種實現方式的優點有 3 點,讓咱們來細品。
代碼對比餓漢式與懶漢式來講,更加地簡潔。最少只須要3行代碼,就能夠完成一個單例模式:
public enum Test {
INSTANCE;
}
複製代碼
咱們從最直觀的地方入手,第一眼看到這3行代碼,就會感受到少
,沒錯,就是少,雖然這優點有些牽強,但寫的代碼越少,越不容易出錯。
它不須要作任何額外的操做,就能夠保證對象單一性與線程安全性。
我寫了一段測試代碼放在下面,這一段代碼能夠證實程序啓動時僅會建立一個 Singleton 對象,且是線程安全的。
咱們能夠簡單地理解枚舉建立實例的過程:在程序啓動時,會調用 Singleton 的空參構造器,實例化好一個Singleton 對象賦給 INSTANCE,以後不再會實例化
public enum Singleton {
INSTANCE;
Singleton() { System.out.println("枚舉建立對象了"); }
public static void main(String[] args) { /* test(); */ }
public void test() {
Singleton t1 = Singleton.INSTANCE;
Singleton t2 = Singleton.INSTANCE;
System.out.print("t1和t2的地址是否相同:" + t1 == t2);
}
}
// 枚舉建立對象了
// t1和t2的地址是否相同:true
複製代碼
除了優點1和優點2,還有最後一個優點是 保護單例模式
,它使得枚舉在當前的單例模式領域已是 無懈可擊
了
使用枚舉能夠防止調用者使用反射、序列化與反序列化機制強制生成多個單例對象,破壞單例模式。
防反射
枚舉類默認繼承了 Enum 類,在利用反射調用 newInstance() 時,會判斷該類是不是一個枚舉類,若是是,則拋出異常。
防止反序列化建立多個枚舉對象
在讀入 Singleton 對象時,每一個枚舉類型和枚舉名字都是惟一的,因此在序列化時,僅僅只是對枚舉的類型和變量名輸出到文件中,在讀入文件反序列化成對象時,使用 Enum 類的 valueOf(String name) 方法根據變量的名字查找對應的枚舉對象。
因此,在序列化和反序列化的過程當中,只是寫出和讀入了枚舉類型和名字,沒有任何關於對象的操做。
小結:
(1)Enum 類內部使用Enum 類型斷定防止經過反射建立多個對象
(2)Enum 類經過寫出(讀入)對象類型和枚舉名字將對象序列化(反序列化),經過 valueOf() 方法匹配枚舉名找到內存中的惟一的對象實例,防止經過反序列化構造多個對象
(3)枚舉類不須要關注線程安全、破壞單例和性能問題,由於其建立對象的時機與餓漢式單例有殊途同歸之妙。
(1)單例模式常見的寫法有兩種:懶漢式、餓漢式
(2)懶漢式:在須要用到對象時才實例化對象,正確的實現方式是:Double Check + Lock,解決了併發安全和性能低下問題
(3)餓漢式:在類加載時已經建立好該單例對象,在獲取單例對象時直接返回對象便可,不會存在併發安全和性能問題。
(4)在開發中若是對內存要求很是高,那麼使用懶漢式寫法,能夠在特定時候才建立該對象;
(5)若是對內存要求不高使用餓漢式寫法,由於簡單不易出錯,且沒有任何併發安全和性能問題
(6)爲了防止多線程環境下,由於指令重排序致使變量報NPE,須要在單例對象上添加 volatile 關鍵字防止指令重排序
(7)最優雅的實現方式是使用枚舉,其代碼精簡,沒有線程安全問題,且 Enum 類內部防止反射和反序列化時破壞單例。