Java中的單例模式

單例模式是 Java 中最簡單的設計模式之一,它是指一個類在運行期間始終只有一個實例,我們就把它稱之為單例模式。它不但被應用在實際的工作中,而且還是面試中最??嫉念}目之一。通過單例模式我們可以知道此人的編程風格,以及對于基礎(chǔ)知識的掌握是否牢固。

我們本課時的面試題是,單例的實現(xiàn)方式有幾種?它們有什么優(yōu)缺點?

典型回答

單例的實現(xiàn)分為餓漢模式和懶漢模式。顧名思義,餓漢模式就好比他是一個餓漢,而且有一定的危機意識,他會提前把食物囤積好,以備餓了之后直接能吃到食物。對應到程序中指的是,在類加載時就會進行單例的初始化,以后訪問時直接使用單例對象即可。

餓漢模式的實現(xiàn)代碼如下:

public class Singleton {
    // 聲明私有對象
    private static Singleton instance = new Singleton();    
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        return instance;
    }
    private Singleton() {
    }
    // 方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
class SingletonTest {
    public static void main(String[] args) {
        // 調(diào)用單例對象
        Singleton singleton = Singleton.getInstance();
        // 調(diào)用方法
        singleton.sayHi();
    }
}
Java

以上程序的執(zhí)行結(jié)果為:

Hi,Java.

從上述結(jié)果可以看出,單例對象已經(jīng)被成功獲取到并順利地執(zhí)行了類中的方法。它的優(yōu)點是線程安全,因為單例對象在類加載的時候就已經(jīng)被初始化了,當調(diào)用單例對象時只是把早已經(jīng)創(chuàng)建好的對象賦值給變量;它的缺點是可能會造成資源浪費,如果類加載了單例對象(對象被創(chuàng)建了),但是一直沒有使用,這樣就造成了資源的浪費。

懶漢模式也被稱作為飽漢模式,顧名思義他比較懶,每次只有需要吃飯的時候,才出去找飯吃,而不是像餓漢那樣早早把飯準備好。對應到程序中指的是,當每次需要使用實例時,再去創(chuàng)建獲取實例,而不是在類加載時就將實例創(chuàng)建好。

懶漢模式的實現(xiàn)代碼如下:

public class Singleton {
    // 聲明私有對象
    private static Singleton instance;
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
    private Singleton() {
    }
    // 方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        singleton.sayHi();
    }
}
Java

以上程序的執(zhí)行結(jié)果為:

Hi,Java.

從上述結(jié)果可以看出,單例對象已經(jīng)被成功獲取到并順利地執(zhí)行了類中的方法,它的優(yōu)點是不會造成資源的浪費,因為在調(diào)用的時候才會創(chuàng)建被實例化對象;它的缺點在多線程環(huán)境下是非線程是安全的,比如多個線程同時執(zhí)行到 if 判斷處,此時判斷結(jié)果都是未被初始化,那么這些線程就會同時創(chuàng)建 n 個實例,這樣就會導致意外的情況發(fā)生。

考點分析

使用單例模式可以減少系統(tǒng)的內(nèi)存開銷,提高程序的運行效率,但是使用不當?shù)脑捑蜁斐啥嗑€程下的并發(fā)問題。餓漢模式為最直接的實現(xiàn)單例模式的方法,但它可能會造成對系統(tǒng)資源的浪費,所以只有既能保證線程安全,又可以避免系統(tǒng)資源被浪費的回答才能徹底地征服面試官。

和此知識點相關(guān)的面試題還有以下這些:

  • 什么是雙重檢測鎖?它是線程安全的嗎?
  • 單例的還有其他實現(xiàn)方式嗎?

知識擴展

雙重檢測鎖

為了保證懶漢模式的線程安全我們最簡單的做法就是給獲取實例的方法上加上 synchronized(同步鎖)修飾,如下代碼所示:

public class Singleton {
    // 聲明私有對象
    private static Singleton instance;
    // 獲取實例(單例對象)
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
    private Singleton() {
    }
    // 類方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
Java

這樣雖然能讓懶漢模式變成線程安全的,但由于整個方法都被 synchronized 所包圍,因此增加了同步開銷,降低了程序的執(zhí)行效率。

于是為了改進程序的執(zhí)行效率,我們將 synchronized 放入到方法中,以此來減少被同步鎖所修飾的代碼范圍,實現(xiàn)代碼如下:

public class Singleton {
    // 聲明私有對象
    private static Singleton instance;
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                instance = new Singleton();
            }
        }
        return instance;
    }
    private Singleton() {
    }
    // 類方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
Java

細心的你可能會發(fā)現(xiàn)以上的代碼也存在著非線程安全的問題。例如,當兩個線程同時執(zhí)行到「if (instance == null) { 」判斷時,判斷的結(jié)果都為 true,于是他們就排隊都創(chuàng)建了新的對象,這顯然不符合我們的預期。于是就誕生了大名鼎鼎的雙重檢測鎖(Double Checked Lock,DCL),實現(xiàn)代碼如下:

public class Singleton {
    // 聲明私有對象
    private static Singleton instance;
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        // 第一次判斷
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次判斷
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    private Singleton() {
    }
    // 類方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
Java

上述代碼看似完美,其實隱藏著一個不容易被人發(fā)現(xiàn)的小問題,該問題就出在 new 對象這行代碼上,也就是 instance = new Singleton() 這行代碼。這行代碼看似是一個原子操作,然而并不是,這行代碼最終會被編譯成多條匯編指令,它大致的執(zhí)行流程為以下三個步驟:

  • 給對象實例分配內(nèi)存空間;
  • 調(diào)用對象的構(gòu)造方法、初始化成員字段;
  • 將 instance 對象指向分配的內(nèi)存空間。

但由于 CPU 的優(yōu)化會對執(zhí)行指令進行重排序,也就說上面的執(zhí)行流程的執(zhí)行順序有可能是 1-2-3,也有可能是 1-3-2。假如執(zhí)行的順序是 1-3-2,那么當 A 線程執(zhí)行到步驟 3 時,切換至 B 線程了,而此時 B 線程判斷 instance 對象已經(jīng)指向了對應的內(nèi)存空間,并非為 null 時就會直接進行返回,而此時因為沒有執(zhí)行步驟 2,因此得到的是一個未初始化完成的對象,這樣就導致了問題的誕生。執(zhí)行時間節(jié)點如下表所示:

為了解決此問題,我們可以使用關(guān)鍵字 volatile 來修飾 instance 對象,這樣就可以防止 CPU 指令重排,從而完美地運行懶漢模式,實現(xiàn)代碼如下:

public class Singleton {
    // 聲明私有對象
    private volatile static Singleton instance;
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        // 第一次判斷
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次判斷
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    private Singleton() {
    }
    // 類方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
Java

單例其他實現(xiàn)方式

除了以上的 6 種方式可以實現(xiàn)單例模式外,還可以使用靜態(tài)內(nèi)部類和枚舉類來實現(xiàn)單例。靜態(tài)內(nèi)部類的實現(xiàn)代碼如下:

public class Singleton {
    // 靜態(tài)內(nèi)部類
    private static class SingletonInstance {
        private static final Singleton instance = new Singleton();
    }
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        return SingletonInstance.instance;
    }
    private Singleton() {
    }
    // 類方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
Java

從上述代碼可以看出,靜態(tài)內(nèi)部類和餓漢方式有異曲同工之妙,它們都采用了類裝載的機制來保證,當初始化實例時只有一個線程執(zhí)行,從而保證了多線程下的安全操作。JVM 會在類初始化階段(也就是類裝載階段)創(chuàng)建一個鎖,該鎖可以保證多個線程同步執(zhí)行類初始化的工作,因此在多線程環(huán)境下,類加載機制依然是線程安全的。

但靜態(tài)內(nèi)部類和餓漢方式也有著細微的差別,餓漢方式是在程序啟動時就會進行加載,因此可能造成資源的浪費;而靜態(tài)內(nèi)部類只有在調(diào)用 getInstance() 方法時,才會裝載內(nèi)部類從而完成實例的初始化工作,因此不會造成資源浪費的問題。由此可知,此方式也是較為推薦的單例實現(xiàn)方式。

單例的另一種實現(xiàn)方式為枚舉,它也是《Effective Java》作者極力推薦地單例實現(xiàn)方式,因為枚舉的實現(xiàn)方式不僅是線程安全的,而且只會裝載一次,無論是序列化、反序列化、反射還是克隆都不會新創(chuàng)建對象。它的實現(xiàn)代碼如下:

public class Singleton {
    // 枚舉類型是線程安全的,并且只會裝載一次
    private enum SingletonEnum {
        INSTANCE;
        // 聲明單例對象
        private final Singleton instance;
        // 實例化
        SingletonEnum() {
            instance = new Singleton();
        }
        private Singleton getInstance() {
            return instance;
        }
    }
    // 獲取實例(單例對象)
    public static Singleton getInstance() {
        return SingletonEnum.INSTANCE.getInstance();
    }
    private Singleton() {
    }
    // 類方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        singleton.sayHi();
    }
}
Java

以上程序的執(zhí)行結(jié)果為:

Hi,Java.

小結(jié)

本課時我們講了 8 種實現(xiàn)單例的方式,包括線程安全但可能會造成系統(tǒng)資源浪費的餓漢模式,以及懶漢模式和懶漢模式變種的 5 種實現(xiàn)方式。其中包含了兩種雙重檢測鎖的懶漢變種模式,還有最后兩種線程安全且可以實現(xiàn)延遲加載的靜態(tài)內(nèi)部類的實現(xiàn)方式和枚舉類的實現(xiàn)方式,其中比較推薦使用的是后兩種單例模式的實現(xiàn)方式。

作者:柯廣的網(wǎng)絡日志

微信公眾號:Java大數(shù)據(jù)與數(shù)據(jù)倉庫