單例模式,真不簡單

雨點的名字發表於2021-11-25

一、前言

單例模式無論在我們面試,還是日常工作中,都會面對的問題。但很多單例模式的細節,值得我們深入探索一下。
這篇文章透過單例模式,串聯了多方面基礎知識,非常值得一讀。

單例模式,真不簡單

1、什麼是單例模式?

單例模式是一種非常常用的軟體設計模式,它定義是 單例物件的類只能允許一個例項存在

該類負責建立自己的物件,同時確保只有一個物件被建立。一般常用在工具類的實現或建立物件需要消耗資源的業務場景。

單例模式的特點:

  • 類構造器私有
  • 持有自己類的引用
  • 對外提供獲取例項的靜態方法

我們先用一個簡單示例瞭解一下單例模式的用法。

public class SimpleSingleton {
    //持有自己類的引用
    private static final SimpleSingleton INSTANCE = new SimpleSingleton();

    //私有的構造方法
    private SimpleSingleton() {
    }
    //對外提供獲取例項的靜態方法
    public static SimpleSingleton getInstance() {
        return INSTANCE;
    }
    
    public static void main(String[] args) {
        System.out.println(SimpleSingleton.getInstance().hashCode());
        System.out.println(SimpleSingleton.getInstance().hashCode());
    }
}

列印結果:

1639705018
1639705018

我們看到兩次獲取SimpleSingleton例項的hashCode是一樣的,說明兩次呼叫獲取到的是同一個物件。

可能很多朋友平時工作當中都是這麼用的,但我要說這段程式碼是有問題的,你會相信嗎?

不信,我們一起往下看。

二、餓漢和懶漢模式

在介紹單例模式的時候,必須要先介紹它的兩種非常著名的實現方式:餓漢模式懶漢模式

1、餓漢模式

例項在初始化的時候就已經建好了,不管你有沒有用到,先建好了再說。具體程式碼如下:

public class SimpleSingleton {
    //持有自己類的引用
    private static final SimpleSingleton INSTANCE = new SimpleSingleton();

    //私有的構造方法
    private SimpleSingleton() {
    }
    //對外提供獲取例項的靜態方法
    public static SimpleSingleton getInstance() {
        return INSTANCE;
    }
}

餓漢模式,其實還有一個變種:

public class SimpleSingleton {
    //持有自己類的引用
    private static final SimpleSingleton INSTANCE;
    static {
       INSTANCE = new SimpleSingleton();
    }

    //私有的構造方法
    private SimpleSingleton() {
    }
    //對外提供獲取例項的靜態方法
    public static SimpleSingleton getInstance() {
        return INSTANCE;
    }
}

使用靜態程式碼塊的方式例項化INSTANCE物件。

使用餓漢模式的好處是:沒有執行緒安全的問題,但帶來的壞處也很明顯。

一開始就例項化物件了,如果例項化過程非常耗時,並且最後這個物件沒有被使用,不是白白造成資源浪費嗎?

這個時候你也許會想到,不用提前例項化物件,在真正使用的時候再例項化不就可以了?

這就是我接下來要介紹的:懶漢模式

2、懶漢模式

顧名思義就是例項在用到的時候才去建立,“比較懶”,用的時候才去檢查有沒有例項,如果有則返回,沒有則新建。具體程式碼如下:

public class SimpleSingleton2 {

    private static SimpleSingleton2 INSTANCE;

    private SimpleSingleton2() {
    }

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

示例中的INSTANCE物件一開始是空的,在呼叫getInstance方法才會真正例項化。
嗯,不錯不錯。但這段程式碼還是有問題。

3、synchronized關鍵字

上面的程式碼有什麼問題?

答:假如有多個執行緒中都呼叫了getInstance方法,那麼都走到 if (INSTANCE == null) 判斷時,可能同時成立,因為INSTANCE初始化時預設值是null。這樣會導致多個執行緒中同時建立INSTANCE物件,即INSTANCE物件被建立了多次,違背了只建立一個INSTANCE物件的初衷。

那麼,要如何改進呢?

答:最簡單的辦法就是使用synchronized關鍵字。

改進後的程式碼如下:

public class SimpleSingleton3 {
    private static SimpleSingleton3 INSTANCE;

    private SimpleSingleton3() {
    }

    public synchronized static SimpleSingleton3 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SimpleSingleton3();
        }
        return INSTANCE;
    }
    public static void main(String[] args) {
        System.out.println(SimpleSingleton3.getInstance().hashCode());
        System.out.println(SimpleSingleton3.getInstance().hashCode());
    }
}

在getInstance方法上加synchronized關鍵字,保證在併發的情況下,只有一個執行緒能建立INSTANCE物件的例項。這樣總可以了吧?

答:不好意思,還是有問題。

有什麼問題?

答:使用synchronized關鍵字會消耗getInstance方法的效能,我們應該判斷當INSTANCE為空時才加鎖,如果不為空不應該加鎖,需要直接返回。
這就需要使用下面要說的雙重檢查鎖了。

4、餓漢和懶漢模式的區別

but,在介紹雙重檢查鎖之前,先插播一個朋友們可能比較關心的話題:餓漢模式 和 懶漢模式 各有什麼優缺點?

餓漢模式:優點是沒有執行緒安全的問題,缺點是浪費記憶體空間。
懶漢模式:優點是沒有記憶體空間浪費的問題,缺點是如果控制不好,實際上不是單例的。

好了,下面可以安心的看看雙重檢查鎖,是如何保證效能的,同時又保證單例的。


三、雙重檢查鎖

雙重檢查鎖顧名思義會檢查兩次:在加鎖之前檢查一次是否為空,加鎖之後再檢查一次是否為空。

那麼,它是如何實現單例的呢?

1、如何實現單例?

具體程式碼如下:

public class SimpleSingleton4 {

    private static SimpleSingleton4 INSTANCE;

    private SimpleSingleton4() {
    }

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

在加鎖之前判斷是否為空,可以確保INSTANCE不為空的情況下,不用加鎖,可以直接返回。為什麼在加鎖之後,還需要判斷INSTANCE是否為空呢?

答:是為了防止在多執行緒併發的情況下,只會例項化一個物件。

比如:執行緒a和執行緒b同時呼叫getInstance方法,假如同時判斷INSTANCE都為空,這時會同時進行搶鎖。
假如執行緒a先搶到鎖,開始執行synchronized關鍵字包含的程式碼,此時執行緒b處於等待狀態。
執行緒a建立完新例項了,釋放鎖了,此時執行緒b拿到鎖,進入synchronized關鍵字包含的程式碼,如果沒有再判斷一次INSTANCE是否為空,則可能會重複建立例項。
所以需要在synchronized前後兩次判斷。

不要以為這樣就完了,還有問題呢?

2、volatile關鍵字

上面的程式碼還有啥問題?

public static SimpleSingleton4 getInstance() {
      if (INSTANCE == null) {//1
          synchronized (SimpleSingleton4.class) {//2
              if (INSTANCE == null) {//3
                  INSTANCE = new SimpleSingleton4();//4
              }
          }
      }
      return INSTANCE;//5
  }

getInstance方法的這段程式碼,我是按1、2、3、4、5這種順序寫的,希望也按這個順序執行。

但是java虛擬機器實際上會做一些優化,對一些程式碼指令進行重排。重排之後的順序可能就變成了:1、3、2、4、5,這樣在多執行緒的情況下同樣會建立多次例項。重排之後的程式碼可能如下:

public static SimpleSingleton4 getInstance() {
    if (INSTANCE == null) {//1
       if (INSTANCE == null) {//3
           synchronized (SimpleSingleton4.class) {//2
                INSTANCE = new SimpleSingleton4();//4
            }
        }
    }
    return INSTANCE;//5
}

原來如此,那有什麼辦法可以解決呢?

答:可以在定義INSTANCE是加上volatile關鍵字。具體程式碼如下:

public class SimpleSingleton7 {

    private volatile static SimpleSingleton7 INSTANCE;

    private SimpleSingleton7() {
    }

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

volatile關鍵字可以保證多個執行緒的可見性,但是不能保證原子性。同時它也能禁止指令重排。

雙重檢查鎖的機制既保證了執行緒安全,又比直接上鎖提高了執行效率,還節省了記憶體空間。

除了上面的單例模式之外,還有沒有其他的單例模式?


四、靜態內部類

靜態內部類顧名思義是通過靜態的內部類來實現單例模式的。那麼,它是如何實現單例的呢?

1、如何實現單例模式?

如何實現單例模式?

public class SimpleSingleton5 {

    private SimpleSingleton5() {
    }

    public static SimpleSingleton5 getInstance() {
        return Inner.INSTANCE;
    }

    private static class Inner {
        private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
    }
}

我們看到在SimpleSingleton5類中定義了一個靜態的內部類Inner。在SimpleSingleton5類的getInstance方法中,返回的是內部類Inner的例項INSTANCE物件。

只有在程式第一次呼叫getInstance方法時,虛擬機器才載入Inner並例項化INSTANCE物件。

java內部機制保證了,只有一個執行緒可以獲得物件鎖,其他的執行緒必須等待,保證物件的唯一性。

2、反射漏洞

上面的程式碼看似完美,但還是有漏洞。如果其他人使用反射,依然能夠通過類的無參構造方式建立物件。例如:

Class<SimpleSingleton5> simpleSingleton5Class = SimpleSingleton5.class;
try {
    SimpleSingleton5 newInstance = simpleSingleton5Class.newInstance();
    System.out.println(newInstance == SimpleSingleton5.getInstance());
} catch (InstantiationException e) {
    e.printStackTrace();
} catch (IllegalAccessException e) {
    e.printStackTrace();
}

上面程式碼列印結果是false。

由此看出,通過反射建立的物件,跟通過getInstance方法獲取的物件,並非同一個物件,也就是說,這個漏洞會導致SimpleSingleton5非單例。

那麼,要如何防止這個漏洞呢?

答:這就需要在無參構造方式中判斷,如果非空,則丟擲異常了。

改造後的程式碼如下:

public class SimpleSingleton5 {

    private SimpleSingleton5() {
        if(Inner.INSTANCE != null) {
           throw new RuntimeException("不能支援重複例項化");
       }
    }

    public static SimpleSingleton5 getInstance() {
        return Inner.INSTANCE;
    }

    private static class Inner {
        private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
        }
    }

}

如果此時,你認為這種靜態內部類,實現單例模式的方法,已經完美了。

那麼,我要告訴你的是,你錯了,還有漏洞。。。

3、反序列化漏洞

眾所周知,java中的類通過實現Serializable介面,可以實現序列化。

我們可以把類的物件先儲存到記憶體,或者某個檔案當中。後面在某個時刻,再恢復成原始物件。

具體程式碼如下:

public class SimpleSingleton5 implements Serializable {

    private SimpleSingleton5() {
        if (Inner.INSTANCE != null) {
            throw new RuntimeException("不能支援重複例項化");
        }
    }

    public static SimpleSingleton5 getInstance() {
        return Inner.INSTANCE;
    }

    private static class Inner {
        private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
    }

    private static void writeFile() {
        FileOutputStream fos = null;
        ObjectOutputStream oos = null;
        try {
            SimpleSingleton5 simpleSingleton5 = SimpleSingleton5.getInstance();
            fos = new FileOutputStream(new File("test.txt"));
            oos = new ObjectOutputStream(fos);
            oos.writeObject(simpleSingleton5);
            System.out.println(simpleSingleton5.hashCode());
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (oos != null) {
                try {
                    oos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (fos != null) {
                try {
                    fos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }
    }

    private static void readFile() {
        FileInputStream fis = null;
        ObjectInputStream ois = null;
        try {
            fis = new FileInputStream(new File("test.txt"));
            ois = new ObjectInputStream(fis);
            SimpleSingleton5 myObject = (SimpleSingleton5) ois.readObject();

            System.out.println(myObject.hashCode());
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (ois != null) {
                try {
                    ois.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        writeFile();
        readFile();
    }
}

執行之後,發現序列化和反序列化後物件的hashCode不一樣:

189568618
793589513

說明,反序列化時建立了一個新物件,打破了單例模式物件唯一性的要求。那麼,如何解決這個問題呢?

答:重新readResolve方法。

在上面的例項中,增加如下程式碼:

private Object readResolve() throws ObjectStreamException {
    return Inner.INSTANCE;
}

執行結果如下:

290658609
290658609

我們看到序列化和反序列化例項物件的hashCode相同了。

做法很簡單,只需要在readResolve方法中,每次都返回唯一的Inner.INSTANCE物件即可。程式在反序列化獲取物件時,會去尋找readResolve()方法。
如果該方法不存在,則直接返回新物件。如果該方法存在,則按該方法的內容返回物件。如果我們之前沒有例項化單例物件,則會返回null。

好了,到這來終於把坑都踩完了。
還是費了不少勁。
不過,我偷偷告訴你一句,其實還有更簡單的方法,哈哈哈。

納尼。。。

五、列舉

其實在java中列舉就是天然的單例,每一個例項只有一個物件,這是java底層內部機制保證的。

簡單的用法:

public enum  SimpleSingleton7 {
    INSTANCE;
    
    public void doSamething() {
        System.out.println("doSamething");
    }
} 

在呼叫的地方:

public class SimpleSingleton7Test {

    public static void main(String[] args) {
        SimpleSingleton7.INSTANCE.doSamething();
    }
}

在列舉中例項物件INSTANCE是唯一的,所以它是天然的單例模式。

當然,在列舉物件唯一性的這個特性,還能建立其他的單例物件,例如:

public enum  SimpleSingleton7 {
    INSTANCE;
    
    private Student instance;
    
    SimpleSingleton7() {
       instance = new Student();
    }
    
    public Student getInstance() {
       return instance;
    }
}

class Student {
}

jvm保證了列舉是天然的單例,並且不存線上程安全問題,此外,還支援序列化。

在java大神Joshua Bloch的經典書籍《Effective Java》中說過:

單元素的列舉型別已經成為實現Singleton的最佳方法。

參考

1、公眾號 蘇三說技術 的一篇文章 非常感謝。


相關文章