關於單例模式

foofoo發表於2018-09-06

單例模式無論是在實際專案開發還是面試中,都是經常會涉及到,今天總結一下什麼樣的單例模式才是正確的。

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;
    }

}
複製程式碼

相關文章