聊聊單例模式,面試加分題
猶記得之前面xx時,面試官一上來就問你知道哪些設計模式,來手寫一個單例模式的場景;尴尬的我,隻寫了懶漢式餓漢式,對于單例其他的變種一概不知;這次就來彌補下這方面的知識盲區!
餓漢式
餓漢式,從字面上了解就是很餓,一上來就要吃的,那麼它會把吃的先準備好,以滿足它的需求;那麼對應到程式上的表現就為:在類加載的時候就會首先進行執行個體的初始化,後面如果應用程式需要這個執行個體的話,就有現成的了,可以直接使用目前的單例對象!
我們來手寫下餓漢式的代碼:
public class Singleton{
// 聲明靜态私有執行個體 并執行個體化
private static Singleton singleton = new Singleton();
// 提供對外初始化方法 靜态類加載就初始化
public static Singleton initInstance(){
return singleton;
}
// 聲明私有構造方法 即在外部類無法通過new 初始化執行個體
private Singleton(){
}
public void doSomeThing(){
System.out.println("do some thing!");
}
}
class SingletonDemo{
public static void main(String[] args) {
Singleton singleton = Singleton.initInstance();
}
}
餓漢式的優點:它是線程安全的,因為單例對象在類加載的時候就被初始化了,當調用單例對象時隻需要去把對應的對象指派給變量即可!
餓漢式的缺點:如果這個類不經常使用,會造成一定的資源浪費!
懶漢式
懶漢式,就是比較懶,每次需要填飽肚子時才會外出覓食;那麼對應到程式層面的了解:當應用程式需要某個對象時,該對象的類就會去建立一個執行個體,而不是提前準備好的!
我們來手寫下懶漢式的代碼:
public class Singleton2 {
// 聲明私有靜态對象
private static Singleton2 singleton2;
// 對外提供初始化方法
public static Singleton2 initInstance(){
if(singleton2 == null){
singleton2 = new Singleton2();
}
return singleton2;
}
// 私有構造器
private Singleton2(){
}
public void doSomeThing(){
System.out.println("do some thing!");
}
}
class SingletonDemo2{
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.initInstance();
singleton2.doSomeThing();
}
}
同樣我們看下懶漢式的優點:不會造成資源的浪費
懶漢式的缺點:多線程情況下,會有線程安全的問題;
上面我們可以看到,餓漢式和懶漢式的唯一差別就是:餓漢式在類加載時就完成了對象的初始化,而懶漢式是在需要初始化的時候再去初始化對象;其實在單線程情況下,他們都是線程安全的;但是我們寫的代碼,必須考慮多線程情況下的并發問題,那麼懶漢式的這種寫法基本不滿足需求,我們需要做些改造,使得它變得線程安全,滿足我們的需求!
雙重檢測鎖
我們知道,懶漢式下對象的初始化在并發環境下,可能多個線程同時執行到
singleton2 == null
,進而初始化了多個執行個體,這就引發了線程安全問題!
我們就需要改寫它的初始化方法,我們知道加鎖可以解決一般的線程安全問題,
synchronized
這個關鍵字可以修飾一個代碼塊或方法,被其修飾的方法或代碼塊就被加了鎖;而從某些方面了解,
synchronized
是個同步鎖,亦是個可重入鎖!哈哈,關于鎖的種類及概念有點多,後面準備寫一篇關于鎖的部落格來總結下;不再發散了,回歸正題
我們來改造下懶漢式的初始化方法如下:
// 對外提供初始化方法
public synchronized static Singleton2 initInstance(){
if(singleton2 == null){
singleton2 = new Singleton2();
}
return singleton2;
}
我們看下上面的代碼,初看沒什麼問題是解決了線程安全問題;但是由于整個方法都被
synchronized
修飾,那麼在多線程的情況下就增加了線程同步的開銷,降低了程式的執行效率;為了改進這個問題,我們将
synchronized
放入到方法内,實作代碼塊的同步;改下如下:
// 對外提供初始化方法
public static Singleton2 initInstance(){
if(singleton2 == null){
synchronized(Singleton2.class){
singleton2 = new Singleton2();
}
}
return singleton2;
}
呃,這樣就滿足了我們的要求了嗎?聰明如你一定發現了,雖然我們将
synchronized
移到了方法内部,降低了同步的開銷,但是在并發的情況下假設多個線程同時執行到
if(singleton2 == null)
時,依舊會排隊初始化
Singleton2
執行個體,這樣又會造成新的線程安全問題;那麼為了解決這個問題,就出現了大名鼎鼎的“雙重檢測鎖”。我們來看下它的實作,将上述代碼改寫如下:
// 對外提供初始化方法
public static Singleton2 initInstance(){
if(singleton2 == null){// 第一次非空判斷
synchronized(Singleton2.class){
if(singleton2 == null)// 第二次非空判斷
singleton2 = new Singleton2();
}
}
return singleton2;
}
哈哈,這個雙重即是判斷兩次的意思,并不是加兩把鎖哈;那麼這樣就能行了嗎?初看沒問題啊,但是我們細想之下這樣寫真的沒問題嗎?你寫的代碼,執行的時候真的會按你想的過程執行嗎?有沒有考慮過指令重排呢?問題就出現在
new Singleton2()
這個代碼上,這行代碼不是一個原子操作!
我們再來回顧下指令重排的大緻執行流程:
1.給對象執行個體配置設定記憶體空間
2.調用對象構造方法,初始化成員變量
3.将構造的對象指向配置設定的記憶體空間
問題就出在指令重排後,cpu對指令重排的優化上,也就是說上述的三個過程并不是每次都是1-2-3順序執行的,而是也有可能1-3-2;那麼我們試想下并發情況下可能出現的場景,當線程A執行到步驟3時,cpu時間片正好輪詢到線程B,那麼線程B判斷執行個體已經指向了對應的記憶體空間,不為null就不會 初始化執行個體了,就得到了一個未初始化完成的對象,這就導緻了問題的誕生!
為了解決這個問題,我們知道還有一個關鍵字
volatile
可以完美的解決指令重排,使得非原子性的操作對其他對象是可見的!(volatile關鍵字保障了變量的記憶體的可見性和一緻性問題,關于記憶體屏障可以看我之前的一篇文章JMM 記憶體模型知識點探究了解)。那麼我們将懶漢式改寫如下:
public class Singleton2 {
// 聲明私有靜态對象
private volatile static Singleton2 singleton2;
// 對外提供初始化方法
public static Singleton2 initInstance(){
if(singleton2 == null){
synchronized(Singleton2.class){
if(singleton2 == null)
singleton2 = new Singleton2();
}
}
return singleton2;
}
// 私有構造器
private Singleton2(){
}
public void doSomeThing(){
System.out.println("do some thing!");
}
}
class SingletonDemo2{
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.initInstance();
singleton2.doSomeThing();
}
}
其實除了上面的單例實作外,還有兩種常見的單例實作
靜态内部類
代碼如下:
public class InnerClassSingleton {
// 私有靜态内部類
private static class InnerInstance{
private static final InnerClassSingleton singleton = new InnerClassSingleton();
}
// 對外提供的初始化方法
public static InnerClassSingleton initInstance(){
return InnerInstance.singleton;
}
// 私有構造器
private InnerClassSingleton(){
}
public void doSomeThing(){
System.out.println("do some thing!");
}
}
class InnerClassSingletonDemo{
public static void main(String[] args) {
InnerClassSingleton innerClassSingleton = InnerClassSingleton.initInstance();
innerClassSingleton.doSomeThing();
}
}
其實,靜态内部類的方式和餓漢式本質是一樣的,都是根據類加載機制來初始化執行個體,進而保證單例和線程安全的;不同的是靜态内部類的方式是按需建構執行個體,不會如餓漢式一樣造成資源浪費的問題;是以這個是餓漢式一個比較好的變種!
枚舉類
枚舉是比較推薦的一種單例模式,它是線程安全的,且通過反射、序列化以及反序列化都無法破壞它的單例屬性(其他的單例采用私有構造器的實作其實并不安全),至于為什麼呢?這個可以參考部落格:[為什麼要用枚舉實作單例模式(避免反射、序列化問題)]
public class EnumSingleton {
// 聲明私有的枚舉類型
private enum Enum{
INSTANCE;
// 聲明單例對象
private final EnumSingleton instance;
// 執行個體化
Enum(){
instance = new EnumSingleton();
}
private EnumSingleton getInstance(){
return instance;
}
}
// 對外提供的初始化方法
public static EnumSingleton initInstance(){
return Enum.INSTANCE.getInstance();
}
// 私有構造器
private EnumSingleton(){
}
public void doSomeThing(){
System.out.println("do some thing!");
}
}
class EnumSingletonDemo{
public static void main(String[] args) {
EnumSingleton enumSingleton = EnumSingleton.initInstance();
enumSingleton.doSomeThing();
}
}
好,至此我們總結了單例的幾種實作方式;比較推薦的是後面兩種方式,一般懶漢式我們就采用雙重檢測鎖的方式;你可以發散思考下單例的應用場景,例如Spring中的Bean的初始化就是單例模式的典型應用,或者在消息中心中使用比較頻繁的短連結!
餘路那麼長,還是得帶着虔誠上路...