一、前言
單例模式無論在我們面試,還是日常工作中,都會面對的問題。但很多單例模式的細節,值得我們深入探索一下。
這篇文章透過單例模式,串聯了多方面基礎知識,非常值得一讀。

1、什么是單例模式?
單例模式是一種非常常用的軟件設計模式,它定義是 單例對象的類只能允許一個實例存在。
該類負責創建自己的對象,同時確保只有一個對象被創建。一般常用在工具類的實現或創建對象需要消耗資源的業務場景。
單例模式的特點:
- 類構造器私有
- 持有自己類的引用
- 對外提供獲取實例的靜態方法
我們先用一個簡單示例了解一下單例模式的用法。
public class SimpleSingleton {
//持有自己類的引用
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
//私有的構造方法
private SimpleSingleton() {
}
//對外提供獲取實例的靜態方法
public static SimpleSingleton getInstance() {
return INSTANCE;
}
public static void main(String[] args) {
System.out.println(SimpleSingleton.getInstance().hashCode());
System.out.println(SimpleSingleton.getInstance().hashCode());
}
}
打印結果:
1639705018
1639705018
我們看到兩次獲取SimpleSingleton實例的hashCode是一樣的,說明兩次調用獲取到的是同一個對象。
可能很多朋友平時工作當中都是這么用的,但我要說這段代碼是有問題的,你會相信嗎?
不信,我們一起往下看。
二、餓漢和懶漢模式
在介紹單例模式的時候,必須要先介紹它的兩種非常著名的實現方式:餓漢模式
和 懶漢模式
。
1、餓漢模式
實例在初始化的時候就已經建好了,不管你有沒有用到,先建好了再說。具體代碼如下:
public class SimpleSingleton {
//持有自己類的引用
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
//私有的構造方法
private SimpleSingleton() {
}
//對外提供獲取實例的靜態方法
public static SimpleSingleton getInstance() {
return INSTANCE;
}
}
餓漢模式,其實還有一個變種:
public class SimpleSingleton {
//持有自己類的引用
private static final SimpleSingleton INSTANCE;
static {
INSTANCE = new SimpleSingleton();
}
//私有的構造方法
private SimpleSingleton() {
}
//對外提供獲取實例的靜態方法
public static SimpleSingleton getInstance() {
return INSTANCE;
}
}
使用靜態代碼塊的方式實例化INSTANCE對象。
使用餓漢模式的好處是:沒有線程安全的問題
,但帶來的壞處也很明顯。
一開始就實例化對象了,如果實例化過程非常耗時,並且最后這個對象沒有被使用,不是白白造成資源浪費嗎?
這個時候你也許會想到,不用提前實例化對象,在真正使用的時候再實例化不就可以了?
這就是我接下來要介紹的:懶漢模式。
2、懶漢模式
顧名思義就是實例在用到的時候才去創建,“比較懶”,用的時候才去檢查有沒有實例,如果有則返回,沒有則新建。具體代碼如下:
public class SimpleSingleton2 {
private static SimpleSingleton2 INSTANCE;
private SimpleSingleton2() {
}
public static SimpleSingleton2 getInstance() {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton2();
}
return INSTANCE;
}
}
示例中的INSTANCE對象一開始是空的,在調用getInstance方法才會真正實例化。
嗯,不錯不錯。但這段代碼還是有問題。
3、synchronized關鍵字
上面的代碼有什么問題?
答:假如有多個線程中都調用了getInstance方法,那么都走到 if (INSTANCE == null) 判斷時,可能同時成立,因為INSTANCE初始化時默認值是null。這樣會導致多個線程中同時創建INSTANCE對象,即INSTANCE對象被創建了多次,違背了只創建一個INSTANCE對象的初衷。
那么,要如何改進呢?
答:最簡單的辦法就是使用synchronized
關鍵字。
改進后的代碼如下:
public class SimpleSingleton3 {
private static SimpleSingleton3 INSTANCE;
private SimpleSingleton3() {
}
public synchronized static SimpleSingleton3 getInstance() {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton3();
}
return INSTANCE;
}
public static void main(String[] args) {
System.out.println(SimpleSingleton3.getInstance().hashCode());
System.out.println(SimpleSingleton3.getInstance().hashCode());
}
}
在getInstance方法上加synchronized關鍵字,保證在並發的情況下,只有一個線程能創建INSTANCE對象的實例。這樣總可以了吧?
答:不好意思,還是有問題。
有什么問題?
答:使用synchronized關鍵字會消耗getInstance方法的性能,我們應該判斷當INSTANCE為空時才加鎖,如果不為空不應該加鎖,需要直接返回。
這就需要使用下面要說的雙重檢查鎖了。
4、餓漢和懶漢模式的區別
but,在介紹雙重檢查鎖之前,先插播一個朋友們可能比較關心的話題:餓漢模式 和 懶漢模式 各有什么優缺點?
餓漢模式:優點是沒有線程安全的問題,缺點是浪費內存空間。
懶漢模式:優點是沒有內存空間浪費的問題,缺點是如果控制不好,實際上不是單例的。
好了,下面可以安心的看看雙重檢查鎖,是如何保證性能的,同時又保證單例的。
三、雙重檢查鎖
雙重檢查鎖顧名思義會檢查兩次:在加鎖之前檢查一次是否為空,加鎖之后再檢查一次是否為空。
那么,它是如何實現單例的呢?
1、如何實現單例?
具體代碼如下:
public class SimpleSingleton4 {
private static SimpleSingleton4 INSTANCE;
private SimpleSingleton4() {
}
public static SimpleSingleton4 getInstance() {
if (INSTANCE == null) {
synchronized (SimpleSingleton4.class) {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton4();
}
}
}
return INSTANCE;
}
}
在加鎖之前判斷是否為空,可以確保INSTANCE不為空的情況下,不用加鎖,可以直接返回。為什么在加鎖之后,還需要判斷INSTANCE是否為空呢?
答:是為了防止在多線程並發的情況下,只會實例化一個對象。
比如:線程a和線程b同時調用getInstance方法,假如同時判斷INSTANCE都為空,這時會同時進行搶鎖。
假如線程a先搶到鎖,開始執行synchronized關鍵字包含的代碼,此時線程b處於等待狀態。
線程a創建完新實例了,釋放鎖了,此時線程b拿到鎖,進入synchronized關鍵字包含的代碼,如果沒有再判斷一次INSTANCE是否為空,則可能會重復創建實例。
所以需要在synchronized前后兩次判斷。
不要以為這樣就完了,還有問題呢?
2、volatile關鍵字
上面的代碼還有啥問題?
public static SimpleSingleton4 getInstance() {
if (INSTANCE == null) {//1
synchronized (SimpleSingleton4.class) {//2
if (INSTANCE == null) {//3
INSTANCE = new SimpleSingleton4();//4
}
}
}
return INSTANCE;//5
}
getInstance方法的這段代碼,我是按1、2、3、4、5這種順序寫的,希望也按這個順序執行。
但是java虛擬機實際上會做一些優化,對一些代碼指令進行重排。重排之后的順序可能就變成了:1、3、2、4、5,這樣在多線程的情況下同樣會創建多次實例。重排之后的代碼可能如下:
public static SimpleSingleton4 getInstance() {
if (INSTANCE == null) {//1
if (INSTANCE == null) {//3
synchronized (SimpleSingleton4.class) {//2
INSTANCE = new SimpleSingleton4();//4
}
}
}
return INSTANCE;//5
}
原來如此,那有什么辦法可以解決呢?
答:可以在定義INSTANCE是加上volatile
關鍵字。具體代碼如下:
public class SimpleSingleton7 {
private volatile static SimpleSingleton7 INSTANCE;
private SimpleSingleton7() {
}
public static SimpleSingleton7 getInstance() {
if (INSTANCE == null) {
synchronized (SimpleSingleton7.class) {
if (INSTANCE == null) {
INSTANCE = new SimpleSingleton7();
}
}
}
return INSTANCE;
}
}
volatile關鍵字可以保證多個線程的可見性,但是不能保證原子性。同時它也能禁止指令重排。
雙重檢查鎖的機制既保證了線程安全,又比直接上鎖提高了執行效率,還節省了內存空間。
除了上面的單例模式之外,還有沒有其他的單例模式?
四、靜態內部類
靜態內部類顧名思義是通過靜態的內部類來實現單例模式的。那么,它是如何實現單例的呢?
1、如何實現單例模式?
如何實現單例模式?
public class SimpleSingleton5 {
private SimpleSingleton5() {
}
public static SimpleSingleton5 getInstance() {
return Inner.INSTANCE;
}
private static class Inner {
private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
}
}
我們看到在SimpleSingleton5類中定義了一個靜態的內部類Inner。在SimpleSingleton5類的getInstance方法中,返回的是內部類Inner的實例INSTANCE對象。
只有在程序第一次調用getInstance方法時,虛擬機才加載Inner並實例化INSTANCE對象。
java內部機制保證了,只有一個線程可以獲得對象鎖,其他的線程必須等待,保證對象的唯一性。
2、反射漏洞
上面的代碼看似完美,但還是有漏洞。如果其他人使用反射,依然能夠通過類的無參構造方式創建對象。例如:
Class<SimpleSingleton5> simpleSingleton5Class = SimpleSingleton5.class;
try {
SimpleSingleton5 newInstance = simpleSingleton5Class.newInstance();
System.out.println(newInstance == SimpleSingleton5.getInstance());
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
上面代碼打印結果是false。
由此看出,通過反射創建的對象,跟通過getInstance方法獲取的對象,並非同一個對象,也就是說,這個漏洞會導致SimpleSingleton5非單例。
那么,要如何防止這個漏洞呢?
答:這就需要在無參構造方式中判斷,如果非空,則拋出異常了。
改造后的代碼如下:
public class SimpleSingleton5 {
private SimpleSingleton5() {
if(Inner.INSTANCE != null) {
throw new RuntimeException("不能支持重復實例化");
}
}
public static SimpleSingleton5 getInstance() {
return Inner.INSTANCE;
}
private static class Inner {
private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
}
}
}
如果此時,你認為這種靜態內部類,實現單例模式的方法,已經完美了。
那么,我要告訴你的是,你錯了,還有漏洞。。。
3、反序列化漏洞
眾所周知,java中的類通過實現Serializable
接口,可以實現序列化。
我們可以把類的對象先保存到內存,或者某個文件當中。后面在某個時刻,再恢復成原始對象。
具體代碼如下:
public class SimpleSingleton5 implements Serializable {
private SimpleSingleton5() {
if (Inner.INSTANCE != null) {
throw new RuntimeException("不能支持重復實例化");
}
}
public static SimpleSingleton5 getInstance() {
return Inner.INSTANCE;
}
private static class Inner {
private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
}
private static void writeFile() {
FileOutputStream fos = null;
ObjectOutputStream oos = null;
try {
SimpleSingleton5 simpleSingleton5 = SimpleSingleton5.getInstance();
fos = new FileOutputStream(new File("test.txt"));
oos = new ObjectOutputStream(fos);
oos.writeObject(simpleSingleton5);
System.out.println(simpleSingleton5.hashCode());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (oos != null) {
try {
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
private static void readFile() {
FileInputStream fis = null;
ObjectInputStream ois = null;
try {
fis = new FileInputStream(new File("test.txt"));
ois = new ObjectInputStream(fis);
SimpleSingleton5 myObject = (SimpleSingleton5) ois.readObject();
System.out.println(myObject.hashCode());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
if (ois != null) {
try {
ois.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
writeFile();
readFile();
}
}
運行之后,發現序列化和反序列化后對象的hashCode不一樣:
189568618
793589513
說明,反序列化時創建了一個新對象,打破了單例模式對象唯一性的要求。那么,如何解決這個問題呢?
答:重新readResolve方法。
在上面的實例中,增加如下代碼:
private Object readResolve() throws ObjectStreamException {
return Inner.INSTANCE;
}
運行結果如下:
290658609
290658609
我們看到序列化和反序列化實例對象的hashCode相同了。
做法很簡單,只需要在readResolve方法中,每次都返回唯一的Inner.INSTANCE對象即可。程序在反序列化獲取對象時,會去尋找readResolve()方法。
如果該方法不存在,則直接返回新對象。如果該方法存在,則按該方法的內容返回對象。如果我們之前沒有實例化單例對象,則會返回null。
好了,到這來終於把坑都踩完了。
還是費了不少勁。
不過,我偷偷告訴你一句,其實還有更簡單的方法,哈哈哈。
納尼。。。
五、枚舉
其實在java中枚舉就是天然的單例,每一個實例只有一個對象,這是java底層內部機制保證的。
簡單的用法:
public enum SimpleSingleton7 {
INSTANCE;
public void doSamething() {
System.out.println("doSamething");
}
}
在調用的地方:
public class SimpleSingleton7Test {
public static void main(String[] args) {
SimpleSingleton7.INSTANCE.doSamething();
}
}
在枚舉中實例對象INSTANCE是唯一的,所以它是天然的單例模式。
當然,在枚舉對象唯一性的這個特性,還能創建其他的單例對象,例如:
public enum SimpleSingleton7 {
INSTANCE;
private Student instance;
SimpleSingleton7() {
instance = new Student();
}
public Student getInstance() {
return instance;
}
}
class Student {
}
jvm保證了枚舉是天然的單例,並且不存在線程安全問題,此外,還支持序列化。
在java大神Joshua Bloch的經典書籍《Effective Java》中說過:
單元素的枚舉類型已經成為實現Singleton的最佳方法。
參考
1、公眾號 蘇三說技術 的一篇文章 非常感謝。