天天看點

面試:用 Java 實作一個 Singleton 模式

面試系列更新後,終于迎來了我們的第一期,我們也将貼近《劍指 Offer》的題目給大家帶來 Java 的講解,個人還是非常推薦《劍指 Offer》作為面試必刷的書籍的,這不,再一次把這本書分享給大家,PDF 版本在公衆号背景回複「劍指Offer」即可擷取。

我們在面試中總會遇到不少設計模式的問題,而設計模式中的 Singleton 模式又是我們最容易出現的考題,大多數人可能在此前已經有充分的了解,但不少人僅僅是停留在比較淺顯的層次,今天我們就結合《劍指 Offer》給大家帶來更加深入的講解。

題目:請用 Java 手寫一個單例模式代碼,希望盡可能考慮地全面。

不論是 Java 還是 Android 中單例模式肯定是我們經常用到的,是以這道題可能大多數人會第一時間想到餓漢式代碼。

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

    private Singleton() {
    }

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

上面是典型的餓漢式寫法,因為單例的執行個體被聲明成 static 和 final 變量了,是以在第一次加載類到記憶體中時就會初始化,是以也不會存在多線程問題,但它的缺點非常顯而易見,也經常為人诟病。這明顯不是一種懶加載模式(lazy initialization),就因為它是 static 和 final 的,是以類會在加載後就被初始化,導緻我們代碼的健壯性很差,假如後面更改需求,希望在 getInstance() 之前調用某個方法給它設定參數,這個就明顯不符合使用場景了,面試官極有可能在看到這個代碼後覺得你就是一個隻知道完成功能沒有大局觀的人。

當然還會有不少人直接采用我們的懶漢式代碼,這樣就解決了延展性和懶加載了。

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

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

}
           

上述代碼可能是大多數面試者的解法,包括教科書上也是這麼教我們的,但這段代碼卻存在了一個緻命的問題,那就是當多個線程并行調用 getInstance() 的時候,就會建立多個執行個體,這顯然違背了面試官的意思。正好面試官加了一句希望盡可能考慮地全面,是以這樣的代碼肯定不能虜獲面試官的芳心。

既然要線程安全,那我直接加鎖呗。于是并有了下面的代碼。他們也是懶漢式的,隻不過線程安全了。

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

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

}
           

這樣的解法實作了線程安全,但它并不是那麼高效,因為在任何時候隻能有一個線程去調用 getInstance() 方法,但實際上加鎖操作也是耗時的,我們應該盡量地避免使用它。是以自然就引出了雙重檢驗鎖。

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

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

}
           

這段代碼看起來很完美,很可惜,它是有問題。主要在于 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 變量聲明成 volatile 就可以了。

public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

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

}
           

有些人認為使用 volatile 的原因是可見性,也就是可以保證線程在本地不會存有 instance 的副本,每次都是去主記憶體中讀取。但其實是不對的。使用 volatile 的主要原因是其另一個特性:禁止指令重排序優化。也就是說,在 volatile 變量的指派操作後面會有一個記憶體屏障(生成的彙編代碼上),讀操作不會被重排序到記憶體屏障之前。比如上面的例子,取操作必須在執行完 1-2-3 之後或者 1-3-2 之後,不存在執行到 1-3 然後取到值的情況。從「先行發生原則」的角度了解的話,就是對于一個 volatile 變量的寫操作都先行發生于後面對這個變量的讀操作(這裡的“後面”是時間上的先後順序)。

但是特别注意在 Java 5 以前的版本使用了 volatile 的雙檢鎖還是有問題的。其原因是 Java 5 以前的 JMM (Java 記憶體模型)是存在缺陷的,即時将變量聲明成 volatile 也不能完全避免重排序,主要是 volatile 變量前後的代碼仍然存在重排序問題。這個 volatile 屏蔽重排序的問題在 Java 5 中才得以修複,是以在這之後才可以放心使用 volatile。

那麼,有沒有一種既有懶加載,又保證了線程安全,還簡單的方法呢?

當然有,靜态内部類,就是一種我們想要的方法。我們完全可以把 Singleton 執行個體放在一個靜态内部類中,這樣就避免了靜态執行個體在 Singleton 類加載的時候就建立對象,并且由于靜态内部類隻會被加載一次,是以這種寫法也是線程安全的。

public class Singleton {
    private static class Holder {
        private static Singleton INSTANCE = new Singleton();
    }

    private Singleton() {
    }

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

這是我比較推薦的解法,這種寫法用 JVM 本身的機制保證了線程安全的問題,同時讀取執行個體的時候也不會進行同步,沒什麼性能缺陷,還不依賴 JDK 版本。

雖說如此,但看《Effective Java》中第三點來說,還是有必要提醒一下:享有特權的用戶端可以借助 AccessibleObject.setAccessible 方法,通過反射機制來調用私有構造器。如果需要抵禦這種攻擊,可以修改構造器,讓它在被要求建立第二個執行個體的時候抛出異常。

《Effective Java 中文版》PDF 在公衆号背景回複「Effective Java」即可擷取。

我們其實還有更簡單的枚舉單例。

用過枚舉寫單例的人都說:用枚舉寫單例真是太簡單了。下面的這段代碼就是聲明枚舉單例的通常做法。

public enum EasySingleton{
    INSTANCE;
}
           

這是從 Java 1.5 發行版本後就可以實用的單例方法,我們可以通過 EasySingleton.INSTANCE 來通路執行個體,這比調用 getInstance() 方法簡單多了。建立枚舉預設就是線程安全的,是以不需要擔心 double checked locking,而且還能防止反序列化導緻重新建立新的對象。但是還是很少看到有人這樣寫,可能是因為不太熟悉吧。

總結

一個總結肯定是必不可少的,上面也隻是列舉了我們常見的單例實作方式。當然也不完全,比如我們還可以用 static 代碼塊的方式實作懶漢式代碼,但這裡就不一一例舉了。

就我個人而言,我還是比較推薦用靜态内部類的方式使用單例模式,如果涉及到反序列化建立對象的話,不妨也試試枚舉呗~

文章參考連結:

http://wuchong.me/blog/2014/08/28/how-to-correctly-write-singleton-pattern/

繼續閱讀