面試題:寫一個你認為最好的單例模式
面試考察點
考察目的:單例模式可以考察非常多的基礎知識,是以對于這種問題,很多面試官都會問。小夥伴要注意,在面試過程中,但凡能夠從多個次元考察求職者能力的題目,一定不會被抛棄,特别是比較泛的問題,比如:”請你說說對xxx的了解“之類。
考察範圍:工作1到5年經驗,随着經驗的提升,對于該問題的考察深度越深。
背景知識
單例模式,是一種軟體設計模式,屬于建立型模式的一種。
它的特性是:保證一個類隻有唯一的一個執行個體,并提供一個全局的通路點。
基于這個特性可以知道,單例模式的好處是,可以避免對象的頻繁建立對于記憶體的消耗,因為它限制了執行個體的建立,總的來說,它有以下好處:
- 控制資源的使用,通過線程同步來控制資源的并發通路;
- 控制執行個體産生的數量,達到節約資源的目的。
- 作為通信媒介使用,也就是資料共享,它可以在不建立直接關聯的條件下,讓多個不相關的兩個線程或者程序之間實作通信。
在實際應用中,單例模式使用最多的就是在Spring的IOC容器中,對于Bean的管理,預設都是單例。一個bean隻會建立一個對象,存在内置map中,之後無論擷取多少次該bean,都傳回同一個對象。
下面來了解單例模式的設計。
單例模式設計
既然要保證一個類在運作期間隻有一個執行個體,那必然不能使用
new
關鍵字來進行執行個體。
是以,第一步一定是私有化該類的構造方法,這樣就防止了調用方自己建立該類的執行個體。
接着,由于外部無法執行個體化該對象,是以必須從内部執行個體化之後,提供一個全局的通路入口,來擷取該類的全局唯一執行個體,是以我們可以在類的内部定義一個靜态變量來引用唯一的執行個體,作為對外提供的執行個體通路對象。基于這些點,我們可以得到如下設計。
public class Singleton {
// 靜态字段引用唯一執行個體:
private static final Singleton INSTANCE = new Singleton();
// private構造方法保證外部無法執行個體化:
private Singleton() {
}
}
接着,還需要給外部一個通路該對象執行個體
INSTANCE
的方法,我們可以提供一個靜态方法
public class Singleton {
// 靜态字段引用唯一執行個體:
private static final Singleton INSTANCE = new Singleton();
// 通過靜态方法傳回執行個體:
public static Singleton getInstance() {
return INSTANCE;
}
// private構造方法保證外部無法執行個體化:
private Singleton() {
}
}
這樣就完成了單例模式的設計,總結來看,單例模式分三步驟。
- 使用
私有化構造方法,確定外部無法執行個體化;private
- 通過
變量持有唯一執行個體,保證全局唯一性;private static
-
方法傳回此唯一執行個體,使外部調用方能擷取到執行個體。public static
單例模式的其他實作
既然單例模式隻需要保證程式運作期間隻會産生唯一的執行個體,那意味着單例模式還有更多的實作方法。
- 懶漢式單例模式
- 餓漢式單例模式
- DCL雙重檢查式單例
- 靜态内部類
- 枚舉單例
- 基于容器實作單例
懶漢式,表示不提前建立對象執行個體,而是在需要的時候再建立,代碼如下。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
// synchronized方法,多線程情況下保證單例對象唯一
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
其中,對
getInstance()
方法,增加了
synchronized
同步關鍵字,目的是為了避免在多線程環境下同一時刻調用該方法導緻出現多執行個體問題(線程的并行執行特性帶來的線程安全性問題)。
優點: 隻有在使用時才會執行個體化單例,一定程度上節約了記憶體資源。缺點: 第一次加載時要立即執行個體化,反應稍慢。每次調用getInstance()方法都會進行同步,這樣會消耗不必要的資源。這種模式一般不建議使用。
DCL雙重檢查式單例模式,是基于餓漢式單例模式的性能優化版本。
/**
* DCL實作單例模式
*/
public class Singleton {
private static volatile Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
// 兩層判空,第一層是為了避免不必要的同步
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {// 第二層是為了在null的情況下建立執行個體
instance = new Singleton();
}
}
}
return instance;
}
}
從代碼中可以看到,DCL模式做了兩處改進:
- 在
getInstance()
方法中,把synchronized同步鎖的加鎖範圍縮小了。
縮小鎖的範圍能夠帶來性能上的提升,不妨思考一下,在原來的
模式中,把懶漢式
關鍵字加載方法級别上,意味着不管是多線程環境還是單線程環境,任何一個調用者需要獲得這個對象執行個體時,都需要獲得鎖。但是加這個鎖其實隻有在第一次初始化該執行個體的時候起到保護作用。後續的通路,應該直接傳回synchronized
執行個體對象就行。是以把instance
synchroinzed
加在方法級别,在多線程環境中必然會帶來性能上的開銷。
而DCL模式的改造,就是縮小了加鎖的範圍,隻需要保護該執行個體對象
在第一次初始化即可,後續的通路,都不需要去競争同步鎖。是以它的設計是:instance
-
- 先判斷
執行個體是否為空,如果是,則增加instance
類級别鎖,保護synchronized
對象的執行個體化過程,避免在多線程環境下出現多執行個體問題。instance
- 接着再
同步關鍵字範圍内,再一次判斷synchronized
執行個體是否為空,同樣也是為了避免臨界點時,上一個線程剛初始化完成,下一個線程進入到同步代碼塊導緻多執行個體問題。instance
- 先判斷
- 在成員變量
上修飾了instance
volatile
關鍵字,該關鍵字是為了保證可見性。
之是以要加這個關鍵字,是為了避免在JVM中指令重排序帶來的可見性問題,這個問題主要展現在
這段代碼中。我們來看這段代碼的位元組碼instance=new Singleton()
17: new #3 // class org/example/cl04/Singleton 20: dup 21: invokespecial #4 // Method "<init>":()V 24: putstatic #2 // Field instance:Lorg/example/cl04/Singleton; 27: aload_0 28: monitorexit 29: goto 37 32: astore_1 33: aload_0
關注以下幾個指令
而
指令,和invokespecial #4
指令,是允許重排序的(關于重排序問題,就不再本篇文章中說明,後續的面試題中會分析到),就是說執行順序有可能astore_1
先執行,astore_1
後執行。invokespecial #1
重排序對于兩個沒有依賴關系的指令操作,CPU和記憶體以及JVM,為了優化程式執行性能,會對執行指令進行重排序。也就是說兩個指令的執行順序不一定會按照程式編寫順序來執行。
因為在堆上建立對象開辟位址以後,位址就已經定了,而“将棧裡的Singleton instance
與堆上的對象建立起引用關聯” 和 “将對象裡的成員變量進行指派操作” 是沒什麼邏輯關系的。
是以cpu可以進行亂序執行,隻要程式最終的結果是一緻的就可以。
這種情況,在單線程下沒有問題,但是多線程下,就會出現錯誤。
試想一下,DCL下,線程A在将對象new出來的時,剛執行完
指令,緊接着沒有執行new #4
指令,而是執行了invokespecial #4
astore_1
,也就是說發生了指令重排序。
此時線程B進入getInstance(),發現instance并不為空(因為已經有了引用指向了對象,隻不過還沒來得及給對象裡的成員變量指派),然後線程B便直接return了一個“半初始化”對象(對象還沒徹底建立完)。
是以DCL裡,需要給instance加上volatile關鍵字,因為volatile在JVM層有一個特性叫記憶體屏障,可以防止指令重排序,進而保證了程式的正确性。
-
- new #3 :這行指令是說在堆上的某個位址處開辟了一塊空間作為Singleton對象
- invokespecial #4 :這行指令是說将對象裡的成員變量進行指派操作
- astore_1 :這行指令是說将棧裡的Singleton instance與堆上的對象建立起引用關聯
關于DCL模式的優缺點:
優點:資源使用率高,既能夠在需要的時候才初始化執行個體,又能保證線程安全,同時調用getInstance()方法不進行同步鎖,效率高。缺點:第一次加載時稍慢,由于Java記憶體模型的原因偶爾會失敗。在高并發環境下也有一定的缺陷,雖然發生機率很小。
DCL模式是使用最多的單例模式實作方式,除非代碼在并發場景比較複雜,否則,這種方式基本都能滿足需求。
在類加載的時候不建立單例執行個體。隻有在第一次請求執行個體的時候的時候建立,并且隻在第一次建立後,以後不再建立該類的執行個體。
/**
* 餓漢式實作單例模式
*/
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return instance;
}
}
由于
static
關鍵字修飾的屬性,表示這個成員屬于類本身,不屬于執行個體,運作時,Java 虛拟機隻為靜态變量配置設定一次記憶體,在類加載的過程中完成靜态變量的記憶體配置設定。
是以在類加載的時候就建立好對象執行個體,後續在通路時直接擷取該執行個體即可。
而該模式的優缺點也非常明顯。
優點:線程安全,不需要考慮并發安全性。
缺點:浪費記憶體空間,不管該對象是否被使用到,都會在啟動時提前配置設定記憶體空間。
靜态内部類,是基于餓漢式模式下的優化。
第一次加載Singleton類時不會初始化instance,隻有在第一次調用getInstance()方法時,虛拟機會加載SingletonHolder類,初始化
instance
。
instance
的唯一性、建立過程的線程安全性,都由 JVM 來保證。
/**
* 靜态内部類實作單例模式
*/
public class Singleton {
private Singleton() {
}
public static Singleton getInstance() {
return SingletonHolder.instance;
}
/**
* 靜态内部類
*/
private static class SingletonHolder {
private static Singleton instance = new Singleton();
}
}
這種方式既保證線程安全,單例對象的唯一,也延遲了單例的初始化,推薦使用這種方式來實作單例模式。
靜态内部類不會因為外部類的加載而加載,同時靜态内部類的加載不需要依附外部類,在使用時才加載,不過在加載靜态内部類的過程中也會加載外部類
知識點:如果用static來修飾一個内部類,那麼就是靜态内部類。這個内部類屬于外部類本身,但是不屬于外部類的任何對象。是以使用static修飾的内部類稱為靜态内部類。靜态内部類有如下規則:
- 靜态内部類不能通路外部類的執行個體成員,隻能通路外部類的類成員。
- 外部類可以使用靜态内部類的類名作為調用者來通路靜态内部類的類成員,也可以使用靜态内部類對象通路其執行個體成員。
靜态内部類單例優點:
- 對象的建立是線程安全的。
- 支援延時加載。
- 擷取對象時不需要加鎖。
這是一種比較常用的模式之一。
基于枚舉實作單例
用枚舉來實作單例,是最簡單的方式。這種實作方式通過
Java
枚舉類型本身的特性,保證了執行個體建立的線程安全性和執行個體的唯一性。
public enum SingletonEnum {
INSTANCE;
public void execute(){
System.out.println("begin execute");
}
public static void main(String[] args) {
SingletonEnum.INSTANCE.execute();
}
}
基于枚舉實作單例會發現它并不需要前面描述的幾個操作
- 構造方法私有化
- 執行個體化的變量引用私有化
- 擷取執行個體的方法共有
這類的方式實作枚舉其實并不保險,因為
私有化構造
并不能抵禦
反射攻擊
.
這種方式是作者
Effective Java
提倡的方式,它不僅能避免多線程同步問題,而且還能防止反序列化重新建立新的對象,可謂是很堅強的壁壘啊。
Josh Bloch
下面的代碼示範了基于容器的方式來管理單例。
import java.util.HashMap;
import java.util.Map;
/**
* 容器類實作單例模式
*/
public class SingletonManager {
private static Map<String, Object> objMap = new HashMap<String, Object>();
public static void regsiterService(String key, Object instance) {
if (!objMap.containsKey(key)) {
objMap.put(key, instance);
}
}
public static Object getService(String key) {
return objMap.get(key);
}
}
SingletonManager可以管理多個單例類型,在程式的初始化時,将多個單例類型注入到一個統一管理的類中,使用時根據key擷取對象對應類型的對象。這種方式可以通過統一的接口擷取操作,隐藏了具體實作,降低了耦合度。
關于單例模式的破壞
前面在分析枚舉類實作單例模式時,有提到一個問題,就是私有化構造,會被反射破壞,導緻出現多執行個體問題。
public class Singleton {
private static volatile Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
// 兩層判空,第一層是為了避免不必要的同步
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {// 第二層是為了在null的情況下建立執行個體
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) throws Exception{
Singleton instance=Singleton.getInstance();
Constructor<Singleton> constructor=Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton refInstance=constructor.newInstance();
System.out.println(instance);
System.out.println(refInstance);
System.out.println(instance==refInstance);
}
}
運作結果如下
org.example.cl04.Singleton@29453f44
org.example.cl04.Singleton@5cad8086
false
由于反射可以破壞
private
特性,是以凡是通過
private
私有化構造實作的單例模式,都能夠被反射破壞進而出現多執行個體問題。
可能有人會問,我們沒事幹嘛要去破壞單例呢?直接基于這個入口通路就不會有問題啊?
理論上來說是這樣,但是,假設遇到下面這種情況呢?
下面的代碼示範的是通過對象流實作Singleton的序列化和反序列化。
public class Singleton implements Serializable {
private static volatile Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
// 兩層判空,第一層是為了避免不必要的同步
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {// 第二層是為了在null的情況下建立執行個體
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) throws Exception {
Singleton instance=Singleton.getInstance();
ByteArrayOutputStream baos=new ByteArrayOutputStream();
ObjectOutputStream oos=new ObjectOutputStream(baos);
oos.writeObject(instance);
ByteArrayInputStream bais=new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois=new ObjectInputStream(bais);
Singleton ri=(Singleton) ois.readObject();
System.out.println(instance);
System.out.println(ri);
System.out.println(instance==ri);
}
}
org.example.cl04.Singleton@36baf30c
org.example.cl04.Singleton@66a29884
false
可以看到,序列化的方式,也會破壞單例模式。
枚舉類單例的破壞測試
可能有人會問,枚舉難道就不能破壞嗎?
我們可以試試看,代碼如下。
public enum SingletonEnum {
INSTANCE;
public void execute(){
System.out.println("begin execute");
}
public static void main(String[] args) throws Exception{
SingletonEnum instance=SingletonEnum.INSTANCE;
Constructor<SingletonEnum> constructor=SingletonEnum.class.getDeclaredConstructor();
constructor.setAccessible(true);
SingletonEnum refInstance=constructor.newInstance();
System.out.println(instance);
System.out.println(refInstance);
System.out.println(instance==refInstance);
}
}
Exception in thread "main" java.lang.NoSuchMethodException: org.example.cl04.SingletonEnum.<init>()
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.getDeclaredConstructor(Class.java:2178)
at org.example.cl04.SingletonEnum.main(SingletonEnum.java:15)
從錯誤來看,似乎是沒有一個空的構造函數?這裡并沒有證明 反射無法破壞單例。
下面是Enum這類的源碼,所有枚舉類都繼承了Enum這個抽象類。
public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable {
/**
* The name of this enum constant, as declared in the enum declaration.
* Most programmers should use the {@link #toString} method rather than
* accessing this field.
*/
private final String name;
/**
* Returns the name of this enum constant, exactly as declared in its
* enum declaration.
*
* <b>Most programmers should use the {@link #toString} method in
* preference to this one, as the toString method may return
* a more user-friendly name.</b> This method is designed primarily for
* use in specialized situations where correctness depends on getting the
* exact name, which will not vary from release to release.
*
* @return the name of this enum constant
*/
public final String name() {
return name;
}
/**
* The ordinal of this enumeration constant (its position
* in the enum declaration, where the initial constant is assigned
* an ordinal of zero).
*
* Most programmers will have no use for this field. It is designed
* for use by sophisticated enum-based data structures, such as
* {@link java.util.EnumSet} and {@link java.util.EnumMap}.
*/
private final int ordinal;
/**
* Returns the ordinal of this enumeration constant (its position
* in its enum declaration, where the initial constant is assigned
* an ordinal of zero).
*
* Most programmers will have no use for this method. It is
* designed for use by sophisticated enum-based data structures, such
* as {@link java.util.EnumSet} and {@link java.util.EnumMap}.
*
* @return the ordinal of this enumeration constant
*/
public final int ordinal() {
return ordinal;
}
/**
* Sole constructor. Programmers cannot invoke this constructor.
* It is for use by code emitted by the compiler in response to
* enum type declarations.
*
* @param name - The name of this enum constant, which is the identifier
* used to declare it.
* @param ordinal - The ordinal of this enumeration constant (its position
* in the enum declaration, where the initial constant is assigned
* an ordinal of zero).
*/
protected Enum(String name, int ordinal) {
this.name = name;
this.ordinal = ordinal;
}
}
該類有一個唯一的構造方法,接受兩個參數分别是:
name
和
ordinal
那我們嘗試通過這個構造方法來建立一下執行個體,示範代碼如下。
public enum SingletonEnum {
INSTANCE;
public void execute(){
System.out.println("begin execute");
}
public static void main(String[] args) throws Exception{
SingletonEnum instance=SingletonEnum.INSTANCE;
Constructor<SingletonEnum> constructor=SingletonEnum.class.getDeclaredConstructor(String.class,int.class);
constructor.setAccessible(true);
SingletonEnum refInstance=constructor.newInstance("refinstance",2);
System.out.println(instance);
System.out.println(refInstance);
System.out.println(instance==refInstance);
}
}
運作上述代碼,執行結果如下
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
at org.example.cl04.SingletonEnum.main(SingletonEnum.java:17)
從錯誤資訊來看,我們成功擷取到了
Constructor
這個構造器,但是在
newInstance
時報錯。
定位到出錯的源碼位置。
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
ConstructorAccessor ca = constructorAccessor; // read volatile
從這段代碼:
(clazz.getModifiers() & Modifier.ENUM) != 0
說明:
反射在通過newInstance建立對象時,會檢查該類是否ENUM修飾,如果是則抛出異常,反射失敗
,是以枚舉類型對反射是絕對安全的。
既然反射無法破壞?那序列化呢?我們再來試試
public enum SingletonEnum {
INSTANCE;
public void execute(){
System.out.println("begin execute");
}
public static void main(String[] args) throws Exception{
SingletonEnum instance=SingletonEnum.INSTANCE;
ByteArrayOutputStream baos=new ByteArrayOutputStream();
ObjectOutputStream oos=new ObjectOutputStream(baos);
oos.writeObject(instance);
ByteArrayInputStream bais=new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois=new ObjectInputStream(bais);
SingletonEnum ri=(SingletonEnum) ois.readObject();
System.out.println(instance);
System.out.println(ri);
System.out.println(instance==ri);
}
}
運作結果如下.
INSTANCE
INSTANCE
true
是以,我們可以得出一個結論,枚舉類型是所有單例模式中唯一能夠避免反射破壞導緻多執行個體問題的設計模式。
綜上,可以得出結論:枚舉是實作單例模式的最佳實踐。畢竟使用它全都是優點:
- 反射安全
- 序列化/反序列化安全
- 寫法簡單
問題解答
對于這個問題,想必大家都有答案了,枚舉方式實作單例才是最好的。
當然,回答的時候要從全方面角度去講解。
- 單例模式的概念
- 有哪些方式實作單例
- 每種單例模式的優缺點
- 最好的單例模式,以及為什麼你覺得它是最好的?
問題總結
單例模式看起來簡單,但是學到極緻,也還是有很多知識點的。
比如涉及到線程安全問題、靜态方法和靜态成員變量的特征、枚舉、反射等。
多想再回到從前,大家都隻用jsp/servlet,沒有這麼多亂七八糟的知識,我們隻想做個簡單的程式員。