1. 程式人生 > 其它 >Java 單例以及單例所引發的思考

Java 單例以及單例所引發的思考

1

前言

前幾天無意中看到一篇文章,講到了老生常談的單例,抱著複習一下的心態點了進去,還是那些熟悉的內容,可是卻發現自己思考的角度變了,以前更多的是去記憶,只停留在表面,而現在更多的是去思考為什麼會這麼做。所以今天我也來總結一下 Java 中常見的單例,並記錄下自己的思考。

2

正文

Java 中常見的幾類單例:

  • 餓漢式單例
  • 雙重檢查鎖單例
  • 靜態內部類單例
  • 列舉單例

我們來逐個分解:

3

餓漢式單例

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

餓漢式單例中 instance 的初始化是在類載入時進行的,而類的載入是由 ClassLoader 來完成,這個過程由 JVM 來保證同步,所以這種方式天生是執行緒安全的。它的缺點也顯而易見:容易造成資源的浪費,並且如果構造方法中處理過多,還有可能引發效能問題。

4

雙重檢查鎖單例

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

這是進化到最終的完美版,它的優點很多,我們來挨個分析:

  1. 延遲載入,它的例項在第一次使用時才會建立
  2. 執行緒安全,使用 synchronized 來解決執行緒同步的問題
  3. 效能提升,如果只有一次檢查的話,相當於為了解決 1% 機率的同步問題,而使用了一個 100% 出現的防護盾。雙重檢查就是把 100% 出現的防護盾,也改為 1% 的機率出現。只有 instance 為 null 的時候,才進入 synchronized 的程式碼段——大大減少了機率。

這裡還得提一下 volatile 關鍵字,volatile 主要的作用有兩點:

  1. 記憶體可見性:可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。
  2. 禁止指令重排:雙重檢查鎖單例中利用的就是這一點。

那什麼是指令重排呢?指令重排是指計算機為了提高執行效率,會做一些優化,在不影響最終結果的情況下,可能會對一些語句的執行順序進行調整。

以下是引用程式設計師之家裡關於指令重排導致程式出錯的例子,寫得非常清楚:

主要是在 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 已經不為 null 但是仍沒有完成初始化』的中間狀態,而這個時候,如果有其他執行緒剛好執行到第一層 if (instance == null) 這裡,這裡讀取到的 instance 已經不為 null 了,所以就直接把這個中間狀態的 instance 拿去用了,就會產生問題。 這裡的關鍵在於——執行緒 T1 對 instance 的寫操作沒有完成,執行緒 T2 就執行了讀操作。

把 instance 宣告為 volatile 之後,對它的寫操作就會有一個記憶體屏障,這樣在它的賦值完成之前,就不用呼叫讀操作。

注意:volatile 阻止的不 instance = new Singleton()這句話內部 [1-2-3] 的指令重排,而是保證了在一個寫操作([1-2-3])完成之前,不會呼叫讀操作(if (instance == null))。

5

靜態內部類單例

public class Singleton {    private Singleton() {}    private static class InnerClass {        private static final Singleton INSTANCE = new Singleton();    }    public static Singleton getInstance() {        return InnerClass.INSTANCE;    }
}

由於 InnerClass 是一個內部類,只在外部類的 Singleton 的 getInstance() 中被使用,所以它被載入的時機也就是在 getInstance() 方法第一次被呼叫的時候。並且 InnerClass 初始化的時候會由 ClassLoader 來保證同步。

6

一些個人的思考

當我第一次看見這種寫法的時候,不禁驚歎於它的巧妙,既利用了 ClassLoader 保證同步,又實現了延遲載入,簡直神乎其技。但是前幾天當我再次體會這種寫法時,便產生了一些思考,為什麼一定要用靜態內部類來實現呢,用非靜態內部類行不行呢? 答案當然是不行的,但是原因究竟是什麼呢?一開始我以為只有靜態內部類才會在第一次呼叫時被載入,其實這是不正確的,內部類(靜態和非靜態)都是在第一次呼叫時才會被載入。 後來我直接把靜態內部類前的 static 關鍵字去掉,編譯器報錯 Inner classes cannot have static declarations(內部類不能持有靜態的宣告),這是為什麼呢?

我們知道要使用一個類的靜態成員,需要先把這個類載入到虛擬機器中,而成員內部類是需要由外部類物件 new 一個例項才可以使用,這就無法做到靜態成員的要求。所以 Java 不允許非靜態內部類持有靜態的宣告。

7

列舉單例

public enum Singleton {    INSTANCE;        public void func1() {        // do something    }
}

使用列舉除了執行緒安全和防止反射強行呼叫構造方法外,還提供了自動序列化機制,防止反序列化的時候建立新的物件。因此,Effective Java 推薦儘可能地使用單元素列舉來實現單例。

8

一些個人的思考

列舉單例是如何防止反射攻擊的呢? 我們得從列舉的實現去考慮。 上面的列舉類經過編譯後會成為下面的格式:

public abstract class Singleton extends Enum

類的修飾為 abstract,所以沒法例項化,反射也無能為力。

9

結語

越來越覺得自己對基礎的把握不夠了,看來是應該抽出時間把 Java 基礎好好過一遍了。

  • 作者:例項波 連結:https://www.jianshu.com/p/64cad6e0f5ba