java單例模式深度解析

微流發表於2016-05-23

應用場景

由於單例模式只生成一個例項, 減少了系統效能開銷(如: 當一個物件的產生需要比較多的資源時, 如讀取配置, 產生其他依賴物件, 則可以通過在應用啟動時直接產生一個單例物件, 然後永久駐留記憶體的方式來解決)

  • Windows中的工作管理員;
  • 檔案系統, 一個作業系統只能有一個檔案系統;
  • 資料庫連線池的設計與實現;
  • Spring中, 一個Component就只有一個例項Java-Web中, 一個Servlet類只有一個例項;

實現要點

  • 宣告為private來隱藏構造器
  • private static Singleton例項
  • 宣告為public來暴露例項獲取方法

單例模式主要追求三個方面效能

  • 執行緒安全
  • 呼叫效率高
  • 延遲載入

實現方式

主要有五種實現方式,懶漢式(延遲載入,使用時初始化),餓漢式(宣告時初始化),雙重檢查,靜態內部類,列舉。

懶漢式,執行緒不安全的實現

由於沒有同步,多個執行緒可能同時檢測到例項沒有初始化而分別初始化,從而破壞單例約束。

public class Singleton {
    private static Singleton instance;
    private Singleton() {
    };
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}  

懶漢式,執行緒安全但效率低下的實現

由於物件只需要在初次初始化時需要同步,多數情況下不需要互斥的獲得物件,加鎖會造成巨大無意義的資源消耗

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

雙重檢查

這種方法對比於上面的方法確保了只有在初始化的時候需要同步,當初始化完成後,再次呼叫getInstance不會再進入synchronized塊。
NOTE

內部檢查是必要的

由於在同步塊外的if語句中可能有多個執行緒同時檢測到instance為null,同時想要獲取鎖,所以在進入同步塊後還需要再判斷是否為null,避免因為後續獲得鎖的執行緒再次對instance進行初始化

instance宣告為volatile型別是必要的。

  • 指令重排
    由於初始化操作 instance=new Singleton()是非原子操作的,主要包含三個過程
    1. 給instance分配記憶體
    2. 呼叫建構函式初始化instance
    3. 將instance指向分配的空間(instance指向分配空間後,instance就不為空了)
      雖然synchronized塊保證了只有一個執行緒進入同步塊,但是在同步塊內部JVM出於優化需要可能進行指令重排,例如(1->3->2),instance還沒有初始化之前其他執行緒就會在外部檢查到instance不為null,而返回還沒有初始化的instance,從而造成邏輯錯誤。
      • volatile保證變數的可見性
        volatile型別變數可以保證寫入對於讀取的可見性,JVM不會將volatile變數上的操作與其他記憶體操作一起重新排序,volatile變數不會被快取在暫存器,因此保證了檢測instance狀態時總是檢測到instance的最新狀態。

注意:volatile並不保證操作的原子性,例如即使count宣告為volatile型別,count++操作被分解為讀取->寫入兩個操作,雖然讀取到的是count的最新值,但並不能保證讀取與寫入之間不會有其他執行緒再次寫入,從而造成邏輯錯誤

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

餓漢式

這種方式基於單ClassLoder機制,instance在類載入時進行初始化,避免了同步問題。餓漢式的優勢在於實現簡單,劣勢在於不是懶載入模式(lazy initialization)

  • 在需要例項之前就完成了初始化,在單例較多的情況下,會造成記憶體佔用,載入速度慢問題
  • 由於在呼叫getInstance()之前就完成了初始化,如果需要給getInstance()函式傳入引數,將會無法實現
public class Singleton {
    private static final Singleton instance = new Singleton();
    private Singleton() {
    };
    public static Singleton getInstance() {
        return instance;
    }
}  

靜態內部類

由於內部類不會在類的外部被使用,所以只有在呼叫getInstance()方法時才會被載入。同時依賴JVM的ClassLoader類載入機制保證了不會出現同步問題。

public class Singleton {
    private Singleton() {
    };
    public static Singleton getInstance() {
        return Holder.instance;
    }
    private static class Holder{
        private static Singleton instance = new Singleton();
    }
}  

列舉方法

參見列舉類解析
– 執行緒安全
由於列舉類的會在編譯期編譯為繼承自java.lang.Enum的類,其建構函式為私有,不能再建立列舉物件,列舉物件的宣告和初始化都是在static塊中,所以由JVM的ClassLoader機制保證了執行緒的安全性。但是不能實現延遲載入
– 序列化
由於列舉型別採用了特殊的序列化方法,從而保證了在一個JVM中只能有一個例項。

  • 列舉類的例項都是static的,且存在於一個陣列中,可以用values()方法獲取該陣列
  • 在序列化時,只輸出代表列舉型別的名字屬性 name
  • 反序列化時,根據名字在靜態的陣列中查詢對應的列舉物件,由於沒有建立新的物件,因而保證了一個JVM中只有一個物件
public enum Singleton {
    INSTANCE;
    public String error(){
        return "error";
    }
} 

單例模式的破壞與防禦

反射

對於列舉類,該破解方法不適用。

import java.lang.reflect.Constructor;
public class TestCase {
    public void testBreak() throws Exception {
        Class<Singleton> clazz = (Class<Singleton>) Class.forName("Singleton");
        Constructor<Singleton> constructor = clazz.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton instance1 = constructor.newInstance();
        Singleton instance2 = constructor.newInstance();
        System.out.println("singleton? " + (instance1 == instance2));
    }
    public static void main(String[] args) throws Exception{
        new TestCase().testBreak();
    }
}  

序列化

對於列舉類,該破解方法不適用。
該測試首先需要宣告Singleton為實現了可序列化介面public class Singleton implements Serializable

public class TestCase {
    private static final String SYSTEM_FILE = "save.txt";
    public void testBreak() throws Exception {
        Singleton instance1 = Singleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(SYSTEM_FILE));
        oos.writeObject(instance1);
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(SYSTEM_FILE));
        Singleton instance2 = (Singleton) ois.readObject();
        System.out.println("singleton? " + (instance1 == instance2));
    }
    public static void main(String[] args) throws Exception{
        new TestCase().testBreak();
    }
}  

ClassLoader

JVM中存在兩種ClassLoader,啟動內裝載器(bootstrap)和使用者自定義裝載器(user-defined class loader),在一個JVM中可能存在多個ClassLoader,每個ClassLoader擁有自己的NameSpace。一個ClassLoader只能擁有一個class物件型別的例項,但是不同的ClassLoader可能擁有相同的class物件例項,這時可能產生致命的問題。

防禦

對於序列化與反序列化,我們需要新增一個自定義的反序列化方法,使其不再建立物件而是直接返回已有例項,就可以保證單例模式。
我們再次用下面的類進行測試,就發現結果為true。

public final class Singleton {
    private Singleton() {
    }
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() {
        return INSTANCE;
    }
    private Object readResolve() throws ObjectStreamException {
        // instead of the object we`re on,
        // return the class variable INSTANCE
        return INSTANCE;
    }
public class TestCase {
    private static final String SYSTEM_FILE = "save.txt";
    public void testBreak() throws Exception {
        Singleton instance1 = Singleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(SYSTEM_FILE));
        oos.writeObject(instance1);
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(SYSTEM_FILE));
        Singleton instance2 = (Singleton) ois.readObject();
        System.out.println("singleton? " + (instance1 == instance2));
    }
    public static void main(String[] args) throws Exception {
        new TestCase().testBreak();
    }
}  
}  

單例模式效能總結

方式 優點 缺點
餓漢式 執行緒安全, 呼叫效率高 不能延遲載入
懶漢式 執行緒安全, 可以延遲載入 呼叫效率不高
雙重檢測鎖式 執行緒安全, 呼叫效率高, 可以延遲載入
靜態內部類式 執行緒安全, 呼叫效率高, 可以延遲載入
列舉單例 執行緒安全, 呼叫效率高 不能延遲載入

單例效能測試

測試結果:

  1. HungerSingleton 共耗時: 30 毫秒
  2. LazySingleton 共耗時: 48 毫秒
  3. DoubleCheckSingleton 共耗時: 25 毫秒
  4. StaticInnerSingleton 共耗時: 16 毫秒
  5. EnumSingleton 共耗時: 6 毫秒

在不考慮延遲載入的情況下,列舉型別獲得了最好的效率,懶漢模式由於每次方法都需要獲取鎖,所以效率最低,靜態內部類與雙重檢查的效果類似。考慮到列舉可以輕鬆有效的避免序列化與反射,所以列舉是較好實現單例模式的方法。

public class TestCase {
    private static final String SYSTEM_FILE = "save.txt";
    private static final int THREAD_COUNT = 10;
    private static final int CIRCLE_COUNT = 100000;
    public void testSingletonPerformance() throws IOException, InterruptedException {
        final CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        FileWriter writer = new FileWriter(new File(SYSTEM_FILE), true);
        long start = System.currentTimeMillis();
        for (int i = 0; i < THREAD_COUNT; ++i) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < CIRCLE_COUNT; ++i) {
                        Object instance = Singleton.getInstance();
                    }
                    latch.countDown();
                }
            }).start();
        }
        latch.await();
        long end = System.currentTimeMillis();
        writer.append("Singleton 共耗時: " + (end - start) + " 毫秒
");
        writer.close();
    }
    public static void main(String[] args) throws Exception{
        new TestCase().testSingletonPerformance();
    }
}  

補充知識

類載入機制

static關鍵字的作用是把類的成員變成類相關,而不是例項相關,static塊會在類首次被用到的時候進行載入,不是物件建立時,所以static塊具有執行緒安全性
– 普通初始化塊
當Java建立一個物件時, 系統先為物件的所有例項變數分配記憶體(前提是該類已經被載入過了), 然後開始對這些例項變數進行初始化, 順序是: 先執行初始化塊或宣告例項變數時指定的初始值(這兩處執行的順序與他們在原始碼中排列順序相同), 再執行構造器裡指定的初始值.

  • 靜態初始化塊
    又名類初始化塊(普通初始化塊負責物件初始化, 類初始化塊負責對類進行初始化). 靜態初始化塊是類相關的, 系統將在類初始化階段靜態初始化, 而不是在建立物件時才執行. 因此靜態初始化塊總是先於普通初始化塊執行.

  • 執行順序
    系統在類初始化以及物件初始化時, 不僅會執行本類的初始化塊[static/non-static], 而且還會一直上溯到java.lang.Object類, 先執行Object類中的初始化塊[static/non-static], 然後執行其父類的, 最後是自己.
    頂層類(初始化塊, 構造器) -> … -> 父類(初始化塊, 構造器) -> 本類(初始化塊, 構造器)

  • 小結
    static{} 靜態初始化塊會在類載入過程中執行;
    {} 則只是在物件初始化過程中執行, 但先於構造器;

內部類

  • 內部類訪問許可權

    1. Java 外部類只有兩種訪問許可權:public/default, 而內部類則有四種訪問許可權:private/default/protected/public. 而且內部類還可以使用static修飾;內部類可以擁有private訪問許可權、protected訪問許可權、public訪問許可權及包訪問許可權。如果成員內部類Inner用private修飾,則只能在外部類的內部訪問,如果用public修飾,則任何地方都能訪問;如果用protected修飾,則只能在同一個包下或者繼承外部類的情況下訪問;如果是預設訪問許可權,則只能在同一個包下訪問。這一點和外部類有一點不一樣,外部類只能被public和包訪問兩種許可權修飾。成員內部類可以看做是外部類的一個成員,所以可以像類的成員一樣擁有多種許可權修飾。
    2. 內部類分為成員內部類與區域性內部類, 相對來說成員內部類用途更廣泛, 區域性內部類用的較少(匿名內部類除外), 成員內部類又分為靜態(static)內部類與非靜態內部類, 這兩種成員內部類同樣要遵守static與非static的約束(如static內部類不能訪問外部類的非靜態成員等)
  • 非靜態內部類

    1. 非靜態內部類在外部類內使用時, 與平時使用的普通類沒有太大區別;
    2. Java不允許在非static內部類中定義static成員,除非是static final的常量型別
    3. 如果外部類成員變數, 內部類成員變數與內部類中的方法裡面的區域性變數有重名, 則可通過this, 外部類名.this加以區分.
    4. 非靜態內部類的成員可以訪問外部類的private成員, 但反之不成立, 內部類的成員不被外部類所感知. 如果外部類需要訪問內部類中的private成員, 必須顯示建立內部類例項, 而且內部類的private許可權對外部類也是不起作用的:
  • 靜態內部類

    1. 使用static修飾內部類, 則該內部類隸屬於該外部類本身, 而不屬於外部類的某個物件.
    2. 由於static的作用, 靜態內部類不能訪問外部類的例項成員, 而反之不然;
  • 匿名內部類
    如果(方法)區域性變數需要被匿名內部類訪問, 那麼該區域性變數需要使用final修飾.

列舉

  1. 列舉類繼承了java.lang.Enum, 而不是Object, 因此列舉不能顯示繼承其他類; 其中Enum實現了Serializable和Comparable介面(implements Comparable, Serializable);
  2. 非抽象的列舉類預設使用final修飾,因此列舉類不能派生子類;
  3. 列舉類的所有例項必須在列舉類的第一行顯示列出(列舉類不能通過new來建立物件); 並且這些例項預設/且只能是public static final的;
  4. 列舉類的構造器預設/且只能是private;
  5. 列舉類通常應該設計成不可變類, 因此建議成員變數都用private final修飾;
  6. 列舉類不能使用abstract關鍵字將列舉類宣告成抽象類(因為列舉類不允許有子類), 但如果列舉類裡面有抽象方法, 或者列舉類實現了某個介面, 則定義每個列舉值時必須為抽象方法提供實現,


相關文章