面試題2:實現Singleton模式
題目:設計一個類,我們只能生成該類的一個實例。
由於設計模式在面向對象程序設計中起着舉足輕重的作用,在面試過程中很多公司都喜歡問一些與設計模式相關的問題。在常用的模式中,Singleton是唯一一個能夠用短短幾十行代碼完整實現的模式。因此,寫一個Singleton的類型是一個很常見的面試題。
如果你看過我之前寫的設計模式專欄,那么這道題思路你會很開闊。
單例模式的要點有三個:一是某個類只能有一個實例
;二是它必須自行創建這個實例
;三是它必須自行向整個系統提供這個實例
。
我們下面來看一下它的實現
懶漢式寫法
public class LazySingleton {
private static LazySingleton lazySingleton = null;
private LazySingleton() {
}
public static LazySingleton getInstance() {
if(lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
關鍵就是將構造器私有,限制只能通過內部靜態方法來獲取一個實例。
但是這種寫法,很明顯不是線程安全的。如果多個線程在該類初始化之前,有大於一個線程調用了getinstance方法且lazySingleton == null 判斷條件都是正確的時候,這個時候就會導致new出多個LazySingleton實例。可以這么改一下:
這種寫法叫做DoubleCheck。針對類初始化之前多個線程進入 if(lazySingleton == null) 代碼塊中情況
這個時候加鎖控制,再次判斷 if(lazySingleton == null) ,如果條件成立則new出來一個實例,輪到其他的線程判斷的時候自然就就為假了,問題大致解決。
public class LazyDoubleCheckSingleton {
private static LazyDoubleCheckSingleton lazySingleton = null;
private LazyDoubleCheckSingleton() {
}
public static LazyDoubleCheckSingleton getInstance() {
if(lazySingleton == null) {
synchronized (LazyDoubleCheckSingleton.class){
if(lazySingleton == null) {
lazySingleton = new LazyDoubleCheckSingleton();
}
}
}
return lazySingleton;
}
}
但是即使是這樣,上面代碼的改進有些問題還是無法解決的。
因為會有重排序問題。重排序是一種編譯優化技術,屬於《編譯原理》的內容了,這里不詳細探討,但是要告訴你怎么回事。
正常來說,下面的這段代碼
lazySingleton = new LazyDoubleCheckSingleton();
執行的時候是這樣的
- 分配內存給這個對象
- 初始化對象
- 設置LazyDoubleCheckSingleton指向剛分配的內存地址。
但是編譯優化后,可能是這種樣子
- 分配內存給這個對象
- 設置LazyDoubleCheckSingleton指向剛分配的內存地址。
- 初始化對象
2 步驟 和 3 步驟一反,就出問題了。(前提條件,編譯器進行了編譯優化)
比如說有兩個線程,名字分別是線程1和線程2,線程1進入了 if(lazySingleton == null) 代碼塊,拿到了鎖,進行了 new LazyDoubleCheckSingleton()
的執行,在加載構造類的實例的時候,設置LazyDoubleCheckSingleton指向剛分配的內存地址,但是還沒有初始化對象。線程2判斷 if(lazySingleton == null) 為假,直接返回了lazySingleton,又進行了使用,使用的時候就會出問題了。
畫兩張圖吧:
重排序的情況如下:
再看出問題的地方
當然這個很好改進,從禁用重排序方面下手,添加一個volatile。不熟悉線程安全可以參考這篇文章【Java並發編程】線程安全性詳解
private volatile static LazyDoubleCheckSingleton lazySingleton = null;
方法不止一種嘛,也可以利用對象初始化的“可見性”來解決,具體來說是利用靜態內部類基於類初始化的延遲加載,名字很長,但是理解起來並不困難。(使用這種方法,不必擔心上面編譯優化帶來的問題)
類初始化的延遲加載與JVM息息相關,我們演示的例子的
類
只是被加載了而已,而沒有鏈接和初始化。
我們看一下實現方案:
定義一個靜態內部類,其靜態字段實例化了一個單例。獲取單例需要調用getInstance方法間接獲取。
public class StaticInnerClassSingleton {
private static class InnerClass{
private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
}
public static StaticInnerClassSingleton getInstance() {
return InnerClass.staticInnerClassSingleton;
}
}
如果對內部類不熟悉,可以參考這篇文章【Java核心技術卷】深入理解Java的內部類
懶漢式的介紹就到這里吧,下面再看看另外一種單例模式的實現
餓漢式寫法
演示一下基本的寫法
public class HungrySingleton {
// 類加載的時候初始化
private final static HungrySingleton hungrySingleton = new HungrySingleton();
/*
也可以在靜態塊里進行初始化
private static HungrySingleton hungrySingleton;
static {
hungrySingleton = new HungrySingleton();
}
*/
private HungrySingleton() {
}
public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
餓漢式在類加載的時候就完成單例的實例化,如果用不到這個類會造成內存資源的浪費,因為單例實例引用不可變,所以是線程安全的
同樣,上面的餓漢式寫法也是存在問題的
我們依次看一下:
首先是序列化破壞單例模式
先保證餓漢式能夠序列化,需要繼承Serializable 接口。
import java.io.Serializable;
public class HungrySingleton implements Serializable {
// 類加載的時候初始化
private final static HungrySingleton hungrySingleton = new HungrySingleton();
/*
也可以在靜態塊里進行初始化
private static HungrySingleton hungrySingleton;
static {
hungrySingleton = new HungrySingleton();
}
*/
private HungrySingleton() {
}
public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
我們測試一下:
import lombok.extern.slf4j.Slf4j;
import java.io.*;
@Slf4j
public class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
HungrySingleton hungrySingleton = HungrySingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton"));
oos.writeObject(hungrySingleton);
File file = new File("singleton");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton newHungrySingleton = (HungrySingleton) ois.readObject();
log.info("結果 {}",hungrySingleton);
log.info("結果 {}",newHungrySingleton);
log.info("對比結果 {}",hungrySingleton == newHungrySingleton);
}
}
結果:
結果發現對象不一樣,原因就涉及到序列化的底層原因了,我們先看解決方式:
餓漢式代碼中添加下面這段代碼
private Object readResolve() {
return hungrySingleton;
}
重新運行,這個時候的結果:
原因出在readResolve方法上,下面去ObjectInputStream源碼部分找找原因。(里面都涉及到底層實現,不要指望看懂)
在一個讀取底層數據的方法上有一段描述
就是序列化的Object類中可能定義有一個readResolve方法。我們在二進制數據讀取的方法中看到了是否判斷
private Object readOrdinaryObject()方法中有這段代碼,如果存在ReadResolve方法,就去調用。不存在,不調用。聯想到我們在餓漢式添加的代碼,大致能猜到怎么回事了吧。
另外一種情況就是反射攻擊破壞單例
演示一下
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
@Slf4j
public class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class objectClass = HungrySingleton.class;
Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true); // 強行打開構造器權限
HungrySingleton instance = HungrySingleton.getInstance();
HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();
log.info("結果{}",instance);
log.info("結果{}",newInstance);
log.info("比較結果{}",newInstance == instance);
}
}
這里強行破開了private的構造方法的權限,使得能new出來一個單例實例,這不是我們想看到的。
解決方法是在構造方法中拋出異常
private HungrySingleton() {
if( hungrySingleton != null) {
throw new RuntimeException("單例構造器禁止反射調用");
}
}
這個時候再運行一下
其實對於懶漢式也是有反射破壞單例的問題的,也可以采用類似拋出異常的方法來解決。
餓漢式單例與懶漢式單例類比較
- 餓漢式單例類在自己被加載時就將自己實例化。單從資源利用效率角度來講,這個比懶漢式單例類稍差些。從速度和反應時間角度來講,則比懶漢式單例類稍好些。
- 懶漢式單例類在實例化時,必須處理好在多個線程同時首次引用此類時的訪問限制問題,特別是當單例類作為資源控制器在實例化時必然涉及資源初始化,而資源初始化很有可能耗費大量時間,這意味着出現多線程同時首次引用此類的機率變得較大,需要通過同步化機制進行控制。
枚舉
除此之外還有一種單例模式的實現就是枚舉
使用枚舉的方式實現單例模式是《Effective Java》作者力推的方式,在很多優秀的開源代碼中經常可以看到使用枚舉方式實現單例模式的地方,枚舉類型不允許被繼承,同樣是線程安全的且只能被實例化一次,但是枚舉類型不能夠懶加載,對Singleton主動使用,比如調用其中的靜態方法則INSTANCE會立即得到實例化。
//枚舉類型本身是final的,不允許被繼承
public enum Singleton
{
INSTANCE;
//實例變量
private byte[] data = new byte[1024];
Singleton()
{
System.out.println("I want to follow Jeffery.");
}
public static void method()
{
//調用該方法則會主動使用Singleton,INSTANCE將會被實例化
}
public static Singleton getInstance()
{
return INSTANCE;
}
}
在實際面試中,我們為了展現枚舉單例模式,可以寫成這樣:
public enum Singleton
{
INSTANCE;
public static Singleton getInstance()
{
return INSTANCE;
}
}
Java中的枚舉其實是一種語法糖,換句話說就是編譯器幫助我們做了一些的事情,我們將字節碼反編譯成Java代碼,看看編譯器幫我們做了什么,以及探討為什么使用枚舉的方式實現單例模式是《Effective Java》作者力推的方式?
原始代碼如下:
public enum EnumClass {
SPRING,SUMMER,FALL,WINTER;
}
反編譯后的代碼
public final class EnumClass extends Enum
{
public static EnumClass[] values()
{
return (EnumClass[])$VALUES.clone();
}
public static EnumClass valueOf(String name)
{
return (EnumClass)Enum.valueOf(suger/EnumClass, name);
}
private EnumClass(String s, int i)
{
super(s, i);
}
public static final EnumClass SPRING;
public static final EnumClass SUMMER;
public static final EnumClass FALL;
public static final EnumClass WINTER;
private static final EnumClass $VALUES[];
static
{
SPRING = new EnumClass("SPRING", 0);
SUMMER = new EnumClass("SUMMER", 1);
FALL = new EnumClass("FALL", 2);
WINTER = new EnumClass("WINTER", 3);
$VALUES = (new EnumClass[] {
SPRING, SUMMER, FALL, WINTER
});
}
}
對於靜態代碼塊不了解的參考 : Java中靜態代碼塊、構造代碼塊、構造函數、普通代碼塊
結合前面的內容,是不是很容易理解了? 除此之外,我們還可以看出,枚舉是繼承了Enum類的,同時它也是final,即不可繼承的。
枚舉類型的單例模式的玩法有很多,網上傳的比較多的有以下幾種:
內部枚舉類形式
1.構造方法中實例化對象(上面提到了 注意了嗎)
public class EnumSingleton {
private EnumSingleton(){}
public static EnumSingleton getInstance(){
return Singleton.INSTANCE.getInstance();
}
private enum Singleton{
INSTANCE;
private EnumSingleton singleton;
//JVM會保證此方法絕對只調用一次
Singleton(){
singleton = new EnumSingleton();
}
public EnumSingleton getInstance(){
return singleton;
}
}
}
2.枚舉常量的值即為對象實例
public class EnumSingleton {
private EnumSingleton(){}
public static EnumSingleton getInstance(){
return Singleton.INSTANCE.getInstance();
}
private enum Singleton{
INSTANCE(new EnumSingleton());
private EnumSingleton singleton;
//JVM會保證此方法絕對只調用一次
Singleton(EnumSingleton singleton){
this.singleton = singleton;
}
public EnumSingleton getInstance(){
return singleton;
}
}
}
接口實現形式
對於一個標准的enum單例模式,最優秀的寫法還是實現接口的形式:
// 定義單例模式中需要完成的代碼邏輯
public interface MySingleton {
void doSomething();
}
public enum Singleton implements MySingleton {
INSTANCE {
@Override
public void doSomething() {
System.out.println("I want to follow Jeffery. What about you ?");
}
};
public static MySingleton getInstance() {
return Singleton.INSTANCE;
}
}
我就問!單例模式的面試,你還怕不怕?