單例模式無論是在實際專案開發還是面試中,都是經常會涉及到,今天總結一下什麼樣的單例模式才是正確的。
1. 存在問題的單例模式
1.1 執行緒不安全的懶漢式
/**
* Created by zhoujunfu on 2016/8/24.
* 執行緒不安全的懶漢式單例
*/
class SingletonLazyNonThreadSafe {
private static SingletonLazyNonThreadSafe instance;
private SingletonLazyNonThreadSafe() {
System.out.println("初始化單例物件:" + this.hashCode());
}
public static SingletonLazyNonThreadSafe getInstance() {
if (instance == null) {
instance = new SingletonLazyNonThreadSafe();
}
System.out.println("獲取單例物件:" + instance.hashCode());
return instance;
}
}
class Runner implements Runnable {
@Override
public void run() {
SingletonLazyNonThreadSafe.getInstance();
}
}
public class SingletonDemo {
public static void main(String[] args) throws InterruptedException {
// 兩個執行緒併發訪問單例類建立例項
Runner runnerOne = new Runner();
Runner runnerTwo = new Runner();
Thread threadOne = new Thread(runnerOne);
Thread threadTwo = new Thread(runnerTwo);
threadOne.start();
threadTwo.start();
}
}
複製程式碼
懶漢式,也是最想當然的單例方式,執行緒不安全,可以從以下執行結果看出,執行緒併發訪問這種單例類時,會初始化多個例項,違反了單例類的原則,如果在兩個執行緒start的程式碼中間加入執行緒休眠時間,這樣後執行的執行緒才能拿到先執行執行緒建立的單例物件。
1.2 執行緒安全的懶漢式
/**
* Created by zhoujunfu on 2016/8/24.
* 懶漢式單例
*/
class SingletonLazyThreadSafe {
private static SingletonLazyThreadSafe instance;
private SingletonLazyThreadSafe() {
System.out.println("初始化單例物件:" + this.hashCode());
}
public static synchronized SingletonLazyThreadSafe getInstance() {
if (instance == null) {
instance = new SingletonLazyThreadSafe();
}
System.out.println("獲取單例物件:" + instance.hashCode());
return instance;
}
}
class Runner implements Runnable {
@Override
public void run() {
SingletonLazyThreadSafe.getInstance();
}
}
public class TestSingleton {
public static void main(String[] args) throws InterruptedException {
// 兩個執行緒併發訪問單例類建立例項
Runner runnerOne = new Runner();
Runner runnerTwo = new Runner();
Thread threadOne = new Thread(runnerOne);
Thread threadTwo = new Thread(runnerTwo);
threadOne.start();
threadTwo.start();
}
}
複製程式碼
通過將整個getInstance方法設為同步的,來保證每次只能有一個執行緒進入到建立/獲取例項的方法內,雖然做到了執行緒安全,並且解決了多例項的問題,但是它並不高效。因為在任何時候只能有一個執行緒呼叫 getInstance() 方法。但是同步操作只需要在第一次呼叫時才被需要,即第一次建立單例例項物件時。
1.3 雙重檢驗鎖
/**
* Created by zhoujunfu on 2016/8/24.
* 懶漢式雙重檢查鎖
*/
class SingletonDoubleCheck {
private SingletonDoubleCheck() {
System.out.println("初始化單例物件:" + this.hashCode());
}
private static SingletonDoubleCheck instance;
public static SingletonDoubleCheck getInstance() {
if (instance == null) {
synchronized (SingletonDoubleCheck.class) {
if (instance == null) {
instance = new SingletonDoubleCheck();
}
}
}
return instance;
}
}
複製程式碼
雙重檢驗鎖模式(double checked locking pattern),是一種使用同步塊加鎖的方法。程式設計師稱其為雙重檢查鎖,因為會有兩次檢查 instance == null,一次是在同步塊外,一次是在同步塊內。為什麼在同步塊內還要再檢驗一次?因為可能會有多個執行緒一起進入同步塊外的 if,如果在同步塊內不進行二次檢驗的話就會生成多個例項了。
這段程式碼看起來很完美,很可惜,它是有問題。主要在於instance = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情。
1.給 instance 分配記憶體
2.呼叫 Singleton 的建構函式來初始化成員變數
3.將instance物件指向分配的記憶體空間(執行完這步 instance 就為非 null 了)
但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被執行緒二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以執行緒二會直接返回 instance,然後使用,然後順理成章地報錯。
我們只需要將 instance 變數宣告成 volatile 就可以了。有些人認為使用 volatile 的原因是可見性,也就是可以保證執行緒在本地不會存有 instance 的副本,每次都是去主記憶體中讀取。但其實是不對的。使用 volatile 的主要原因是其另一個特性:禁止指令重排序優化。也就是說,在 volatile 變數的賦值操作後面會有一個記憶體屏障(生成的彙編程式碼上),讀操作不會被重排序到記憶體屏障之前。比如上面的例子,取操作必須在執行完 1-2-3 之後或者 1-3-2 之後,不存在執行到 1-3 然後取到值的情況。從「先行發生原則」的角度理解的話,就是對於一個 volatile 變數的寫操作都先行發生於後面對這個變數的讀操作(這裡的“後面”是時間上的先後順序)。
但是特別注意在 Java 5 以前的版本使用了 volatile 的雙檢鎖還是有問題的。其原因是 Java 5 以前的 JMM (Java 記憶體模型)是存在缺陷的,即時將變數宣告成 volatile 也不能完全避免重排序,主要是 volatile 變數前後的程式碼仍然存在重排序問題。這個 volatile 遮蔽重排序的問題在 Java 5 中才得以修復,所以在這之後才可以放心使用 volatile。
2. 不存在問題的單例模式
2.1 餓漢式(非懶載入)
class SingletonHungry {
private SingletonHungry() {
System.out.println("初始化單例物件:" + this.hashCode());
}
private static SingletonHungry instance = new SingletonHungry();
public SingletonHungry getInstance() {
return instance;
}
}
複製程式碼
這種方法非常簡單,因為單例的例項被宣告成 static 和 final 變數了,在第一次載入類到記憶體中時就會初始化,所以建立例項本身是執行緒安全的。 這種寫法如果完美的話,就沒必要在囉嗦那麼多雙檢鎖的問題了。缺點是它不是一種懶載入模式(lazy initialization),單例會在載入類後一開始就被初始化,即使客戶端沒有呼叫 getInstance()方法。餓漢式的建立方式在一些場景中將無法使用:譬如 Singleton 例項的建立是依賴引數或者配置檔案的,在 getInstance() 之前必須呼叫某個方法設定引數給它,那樣這種單例寫法就無法使用了。
2.2 餓漢式(懶載入)
class SingletonStaticNestedClass {
private SingletonStaticNestedClass() {
}
private static class Holder {
private static final SingletonStaticNestedClass instance = new SingletonStaticNestedClass();
}
public SingletonStaticNestedClass getInstance() {
return Holder.instance;
}
}
複製程式碼
這種寫法仍然使用JVM本身機制保證了執行緒安全問題;由於 Holder 是私有的,除了 getInstance() 之外沒有辦法訪問它,因此它是懶漢式的;同時讀取例項的時候不會進行同步,沒有效能缺陷;也不依賴 JDK 版本,但反序列化時會出現問題。
2.3 列舉式(終極方法)
enum SingletonByEnum {
INSTANCE;
}
複製程式碼
我們可以通過EasySingleton.INSTANCE來訪問例項,這比呼叫getInstance()方法簡單多了。建立列舉預設就是執行緒安全的,所以不需要擔心double checked locking,而且還能防止反序列化導致重新建立新的物件。但是還是很少看到有人這樣寫,可能是因為不太熟悉吧。 網路上很多關於單例類的文章都介紹了用列舉法實現單例,但僅僅靠上述的例子還無法知道具體的使用方法,下面以一個具體的例子來說明如何通過列舉實現單例類。
//Example 1
public enum MyDataBaseSource {
DATASOURCE;
private ComboPooledDataSource cpds = null;
private MyDataBaseSource() {
try {
/*--------獲取properties檔案內容------------*/
// 方法一:
/*
* InputStream is =
* MyDBSource.class.getClassLoader().getResourceAsStream("jdbc.properties");
* Properties p = new Properties(); p.load(is);
* System.out.println(p.getProperty("driverClass") );
*/
// 方法二:(不需要properties的字尾)
/*
* ResourceBundle rb = PropertyResourceBundle.getBundle("jdbc") ;
* System.out.println(rb.getString("driverClass"));
*/
// 方法三:(不需要properties的字尾)
ResourceBundle rs = ResourceBundle.getBundle("jdbc");
cpds = new ComboPooledDataSource();
cpds = new ComboPooledDataSource();
cpds.setDriverClass(rs.getString("driverClass"));
cpds.setJdbcUrl(rs.getString("jdbcUrl"));
cpds.setUser(rs.getString("user"));
cpds.setPassword(rs.getString("password"));
cpds.setMaxPoolSize(Integer.parseInt(rs.getString("maxPoolSize")));
cpds.setMinPoolSize(Integer.parseInt(rs.getString("minPoolSize")));
System.out.println("-----呼叫了構造方法------");
;
} catch (Exception e) {
e.printStackTrace();
}
}
public Connection getConnection() {
try {
return cpds.getConnection();
} catch (SQLException e) {
return null;
}
}
}
public class Test {
public static void main(String[] args) {
MyDataBaseSource.DATASOURCE.getConnection() ;
MyDataBaseSource.DATASOURCE.getConnection() ;
MyDataBaseSource.DATASOURCE.getConnection() ;
}
}
//Example 2
public enum UserActivity {
INSTANCE;
private DataSource _dataSource;
private JdbcTemplate _jdbcTemplate;
private UserActivity() {
this._dataSource = MysqlDb.getInstance().getDataSource();
this._jdbcTemplate = new JdbcTemplate(this._dataSource);
}
public void dostuff() {
...
}
}
// use it as ...
UserActivity.INSTANCE.doStuff();
複製程式碼
Tips: 關於列舉
先看一下列舉型別的實質: 我們定義一個代表不同顏色的列舉型別Color,
public enum Color {
RED, BLUE, GREEN;
}
複製程式碼
除了以上的定義方式,我們還可以如下定義,
public enum Color {
RED(), BLUE(), GREEN();
}
複製程式碼
到這裡你就會覺得迷茫(如果你是初學者的話),為什麼這樣子也可以?其實,列舉的成員就是列舉物件,只不過他們是靜態常量而已。使用 javap 命令(javap 檔名<沒有字尾.class>)可以反編譯 class 檔案,如下
我們可以使用普通類來模擬列舉,下面定義一個 Color 類。
public class Color {
private static final Color RED = new Color();
private static final Color GREEN = new Color();
private static final Color BLUE = new Color();
}
複製程式碼
對比一下,你就明白了。如果按照這個邏輯,是否還可以為其新增另外的構造方法?答案是肯定的!
public enum Color {
RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);
Color(String desc, int value) {
this.desc = desc;
this.value = value;
}
String desc;
int value;
}
複製程式碼
為 Color 宣告瞭兩個成員變數,併為其構造帶引數的構造器。如果你這樣建立一個列舉
public enum Color {
RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);
}
複製程式碼
編譯器就會報錯,因為沒有對應的建構函式。 對於類來講,最好將其成員變數私有化,然後,為成員變數提供 get、set 方法。按照這個原則,可以進一步寫好 enum Color.
public enum Color {
RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);
Color(String desc, int value) {
this.desc = desc;
this.value = value;
}
private String desc;
private int value;
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
複製程式碼
但是,java 設計 enum 的目的是提供一組常量,方便使用者設計。如果我們冒然的提供 set 方法(外界可以改變其成員屬性),好像是有點違背了設計的初衷。那麼,我們應該捨棄 set 方法,保留 get 方法。
public enum Color {
RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);
Color(String desc, int value) {
this.desc = desc;
this.value = value;
}
private String desc;
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
複製程式碼
普通類,我們可以將其例項化,那麼,能否例項化列舉呢?在回答這個問題之前,先來看看,反編譯之後的 Color.class 檔案
public enum Color {
RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);
private Color(String desc, int value) {
this.desc = desc;
this.value = value;
}
private String desc;
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
複製程式碼
可以看出,編譯器淘氣的為其構造方法加上了 private,那麼也就是說,我們無法例項化列舉。所有列舉類都繼承了 Enum 類的方法,包括 toString 、equals、hashcode 等方法。因為 equals、hashcode 方法是 final 的,所以不可以被列舉重寫(只可以繼承)。但是,可以重寫 toString 方法。 那麼,使用 Java 的不同類來模擬一下列舉,大概是這個樣子
public class Color {
private static final Color RED = new Color("red color", 0);
private static final Color GREEN = new Color("green color", 1);
private static final Color BLUE = new Color("blue color", 2);
private static final Color YELLOW = new Color("yellow color", 3);
private final String _name;
private final int _id;
private Color(String name, int id) {
_name = name;
_id = id;
}
public String getName() {
return _name;
}
public int getId() {
return _id;
}
public static List<Color> values() {
List<Color> list = new ArrayList<Color>();
list.add(RED);
list.add(GREEN);
list.add(BLUE);
list.add(YELLOW);
return list;
}
@Override
public String toString() {
return "the color _name=" + _name + ", _id=" + _id;
}
}
複製程式碼