EffectiveJava讀書筆記- 第3條:用私有構造器或者枚舉類型強化Singleton屬性

用私有構造器或枚舉類型強化Singleton屬性

單例模式(Singleton Pattern)無疑是筆試面試中被問得最多的問題之一。單例模式雖然看似簡單,但是仍有很多東西值得思考。

GOF是這麼定義單例模式的:

確保一個類只有一個實例,並提供一個全局訪問點。

通常實現單例都需要我們私有化構造器,讓對象無法在外部創建,同時提供一個外部訪問的方法返回這個單例對象。

通常單例分爲兩大類實現:餓漢式和懶漢式。

餓漢式單例

所謂“餓漢式單例”就是在類加載器加載這個類的時候就立馬創建這個類的單例對象

1. 使用靜態常量域提供外部訪問

public class Singleton {
    public static final Singleton INSTANCE = new Singleton();
    private Singleton() {/* 私有化構造器 */}
    public void doSomething() {
        ...
    }
}

2. 使用靜態工廠方法提供外部訪問

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    private Singleton() {/* 私有化構造器 */}
    public static Singleton getInstance() {
        return INSTANCE;
    }
    public void doSomething() {
        ...
    }
}

靜態工廠方法相對於靜態常量域的好處是可以在不改變API的前提下,可以改變該類是否爲單例的想法。

3. 防止反射調用私有構造器

上面的私有構造方法仍有缺少保護,外部的調用者仍可以使用反射機制AccessibleObject.setAccessible()方法來訪問私有構造方法:

public class SingletonTest {
    @Test
    public void testReflect()
            throws NoSuchMethodException, SecurityException, InstantiationException,
            IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton newInstance = constructor.newInstance();
        Assert.assertNotEquals(Singleton.getInstance(), newInstance);
    }
}

所有我們要對構造方法更狠一點:

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    private Singleton() {
        if(INSTANCE != null)
          throw new IllegalStateException("The object can only be created once");
    }
    public static Singleton getInstance() {
        return INSTANCE;
    }
    public void doSomething() {
        ...
    }
}

4. 防止反序列化導致的多個實例

如果我們的Singleton類實現了Serializable接口,上面構造器檢測拋異常的方式也無法阻止反序列化創建新實例。

public class SingletonTest {

    @Test
    public void testSeriliable() {
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream("D:/singleton.obj"))) {
            oos.writeObject(Singleton.getInstance());
        } catch (Exception ignore) {}

        try (ObjectInputStream ois = new ObjectInputStream(
                new FileInputStream("D:/singleton.obj"))) {
            Object newInstance = ois.readObject();
            Assert.assertNotEquals(Singleton.getInstance(), newInstance);
        } catch (Exception ignore) {}
    }
}

我們只需要定義一個readResolve即可:

public class Singleton implements Serializable {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
        if (INSTANCE != null)
            throw new RuntimeException("The object can only be created once");
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }

    public void doSomething() {
        System.out.println("do something");
    }

    // 訪問修飾符可以任意
    private Object readResolve() {
        return INSTANCE;
    }
}

這個方式是《Effective Java》中推薦的做法。關於readResolve的原理,可以參考Java對象序列化規範或者StackOverflow對這個問題的討論:用Java如何高效的實現單例

5. 使用單元素枚舉類實現單例

使用枚舉類實現單例是《Effective Java》中推薦的最佳方法:

public enum Singleton {
    INSTANCE;

    public void doSomething() {
        System.out.println("do something");
    }
}

這種方式和最開始的使用靜態常量域的方式差不多,但是它更簡潔;由於枚舉類的特性,它能絕對地防止多次實例化,並且無償地提供了序列化的機制,這種方式完全避免了前面的反射和反序列化的問題。

懶漢式單例

所謂“懶漢式單例”就是在加載這個類的時候不立即創建對象,而是等到第一次用到單例對象的時候臨時創建單例對象。對於一些大對象來說,懶加載還是很有必要的。

1. 使用靜態工廠方法實現懶漢式單例

很顯然爲了能實現懶漢式單例,我們肯定不能直接使用靜態常量了,所以只能用靜態工廠方法實現懶漢式單例了。

public class Singleton {
    private static Singleton INSTANCE;
    private Singleton() {/* 私有化構造器 */}
    public static Singleton getInstance() {
        if(INSTANCE == null) {
            INSTANCE = new Singleton()
        }
        return INSTANCE;
    }
    public void doSomething() {
        ...
    }
}

2. 同步方法解決多線程問題

上面的單例在單線程環境下確實沒啥毛病,但是在多線程環境下可能就會出現問題:可能會有多個進程同時通過 (INSTANCE == null)的條件檢查,於是,多個實例就創建出來,如果在C++裏面創建的對象沒有銷燬就會導致內存泄漏(多線程的世界真可怕(╯︵╰)),不過好在java天生支持多線程同步,我們可以在靜態工廠方法上添加synchronized關鍵字實現線程同步訪問:

public class Singleton {
    private static Singleton INSTANCE;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }

    public void doSomething() {
        ...
    }
}

3. 使用同步代碼塊減小鎖粒度

線程同步問題是解決了,但是每次調用getInstance方法的時候都去檢查同步鎖肯定會影響程序執行的效率,雖然現在JVM對synchronized的優化做的越來越好,但是調用次數多了整體效率肯定下降,所以我們有必要減小鎖粒度。

第一步:

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                INSTANCE = new Singleton();
            }
        }
        return INSTANCE;
    }

上面的做法可行嗎,很顯然是不可以滴,多個線程仍然會進入 (INSTANCE == null)條件,這裏的同步只是讓多個線程排隊去創建對象而已。

第二步:

    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (INSTANCE == null) {
                INSTANCE = new Singleton();
            }
        }
        return INSTANCE;
    }

這種做法,和使用靜態代碼塊差不多,每次調用getInstance方法的時候仍然會去檢查同步鎖。

第三步:

    public static Singleton getInstance() {
        // DCL
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }

這個Double Checked Locking總該差不多了吧,不好意思,還不夠。

多處理器共享內存(shared memory multiprocessors)或者編譯器優化(optimizing compilers)進行指令重排的情況下仍有可能會導致創建多個對象。

對於這個問題我這有兩種解釋:

1. 多處理器共享內存:

處理器p1創建完Singleton對象,並把它賦值給INSTANCE變量走出了同步代碼塊,但是INSTANCE變量並沒有立即反映到內存上(處理器直接操作cache高速緩存,並不直接操作內存),這時處理器p2進入同步代碼塊後發現INSTANCE仍爲null,就會創建另一個Singleton對象。

2. 編譯器優化時進行指令重排:

INSTANCE = new Singleton();這句話大概會分三步走:

  1. new:要求操作系統進行內存分配
  2. Singleton():調用類的構造函數對分配的內存進行初始化
  3. =:將新創建的對象的地址賦值給INSTANCE變量

但是JVM在將字節碼翻譯成機器碼的過程中可能會對指令進行重新排列(學過編譯原理的應該都知道編譯器會對指令進行優化重排),這個時候第2步和第3步的先後順序就不確定了,如果線程1按照1->3->2的順序執行,線程2得到的就是一個還未初始化的實例對象,然後就報錯了。

這個時候我們用上volatile關鍵字就能解決了。

關於volatile關鍵字的一些用法我之前也寫過一篇文章:http://blog.csdn.net/Holmofy/article/details/73824757

第四步:

public class Singleton {
    // 使用volatile關鍵字
    private static volatile Singleton INSTANCE;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }

    public void doSomething() {
        ...
    }
}

4. 使用私有靜態內部類保存單例

上面的方法也太麻煩了吧,一個單例都要搞老半天,有沒有更簡單的方法。

《Effective Java》第一版推薦的方法:

public class Singleton {

    // 靜態內部類包裝實例
    private static class InstanceHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    private Singleton() {}

    public static Singleton getInstance() {
        return InstanceHolder.INSTANCE;
    }

    public void doSomething() {
        System.out.println("do something");
    }

}

因爲使用了私有靜態內部類,所以只有當第一次調用getInstance方法時纔會加載這個靜態內部類,然後纔會去創建對象;讀取的時候又沒有進行線程同步不影響性能(簡直完美了)。

什麼時候單例不是單例

之前在StackOverflow中看到有討論不同類加載器下單例模式會出現問題,然後在Oracle官網找到了這篇文章

1. 兩個或多個JVM中有多個單例對象

由於程序在不同的JVM上運行,很明顯每個JVM都會有自己的Singleton實例。但是在基於分佈式技術的系統(如EJB,RMI和Jini)可以讓不同的JVM中的兩個對象保持相同的狀態。

2. 不同的類加載器會創建多個單例對象

一個JVM可以有多個ClassLoader,當兩個ClassLoader加載一個類時,實際上有兩個class副本,然後每個class都有它自己的Singleton實例。有一些Servlet容器(比如iPlanet)每個Servlet都有自己的類加載器,那麼兩個不同的Servlet將訪問不同的Singleton對象。如果你的程序中也有自定義ClassLoader,那麼務必注意這個問題。

參考鏈接:

StackOverflow: https://stackoverflow.com/questions/70689/what-is-an-efficient-way-to-implement-a-singleton-pattern-in-java/71399#71399

The “Double-Checked Locking is Broken” Declaration:http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

How to Simply Singleton:https://www.javaworld.com/article/2073352/core-java/simply-singleton.html

When is a Singleton not a Singleton? http://www.oracle.com/technetwork/articles/java/singleton-1577166.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章