你應該瞭解的單例模式

單元素的枚舉類型經常成爲實現 Singleton 的最佳方法 。

什麼是單例?就一條基本原則,單例對象的類只會被初始化一次。在 Java 中,我們可以說在 JVM 中只存在該類的唯一一個對象實例。在 Android 中,我們可以說在程序運行期間,該類有且僅有一個對象實例。

單例模式的簡單實現步驟:

  1. 構造方法私有,保證無法從外部通過 new 的方式創建對象。
  2. 對外提供獲取該類實例的靜態方法。
  3. 類的內部創建該類的對象,通過第 2 步的靜態方法返回。

按照上述步驟寫下你認爲比較嚴謹的單例模式,然後看看你所寫下的單例能否滿足以下條件:

  • 你的單例按需加載嗎?
  • 你的單例線程安全嗎?涉及到併發三要素:原子性、可見性、有序性
  • 你的單例暴力反射和序列化安全嗎?

如果你的單例已滿足上述條件,給自己鼓個掌,如不滿足上述條件或者有興趣的可接着瞭解下,主要從下面幾種創建單例的方式描述對應實現方式的優缺點以及如何優化:

  • 餓漢模式
  • 懶漢模式
    • 延遲加載
    • 靜態內部類
    • 雙重檢鎖(DCL)
  • 枚舉實現單例

一、餓漢式

public class SingleTon {
    //第三步創建唯一實例
    private static SingleTon instance = new SingleTon();
    
    //第一步構造方法私有
    private SingleTon() {
    }
    
    //第二步暴露靜態方法返回唯一實例
    public static SingleTon getInstance() {
        return instance;
    } 
}

優點:
設計簡單 ,解決了多線程實例化的問題。

缺點:
在虛擬機加載SingleTon類的時候,將會在初始化階段爲類靜態變量賦值,也就是在虛擬機加載該類的時候(此時可能並沒有調用 getInstance 方法)就已經調用了 new SingleTon(); 創建了該對象的實例,之後不管這個實例對象用不用,都會佔據內存空間。

二 、懶漢式

public class SingleTon {
    //創建唯一實例
    private static SingleTon instance = null;
    
    private SingleTon() {
    }
    
    public static SingleTon getInstance() {
        //延遲初始化 在第一次調用 getInstance 的時候創建對象
        if (instance == null) {
            instance = new SingleTon();
        }
        return instance;
    } 
}

優點:
設計也是比較簡單的,和餓漢式不同,當這個Singleton被加載的時候,被static修飾的靜態變量將會被初始化爲null,這個時候並不會佔用內存,而是當第一次調用getInstance方法的時候纔會被初始化實例對象,按需創建。

缺點:
在單線程環境下是沒有問題的,在多線程環境下,會產生線程安全問題。在有兩個線程同時 運行到了 instane == null這個語句,並且都通過了,那他們就會都各自實例化一個對象,這樣就又不是單例了。

如何解決懶漢式在多線程環境下的多實例問題?

  • 靜態內部類

    public class SingleTon {
        
        private static class InnerStaticClass{
            private static SingleTon singleTon  = new SingleTon();
        }
    
        public SingleTon getInstance(){
            return InnerStaticClass.singleTon;
        }
        
        private SingleTon() {
        }
    }
    
  • 直接同步方法

    public class SingleTon {
        //創建唯一實例
        private static SingleTon instance = null;
        
        private SingleTon() {
        }
        
        public static synchronized SingleTon getInstance() {
            if (instance == null) {
                instance = new SingleTon();
            }
            return instance;
        } 
    }
    

    優點:
    加鎖只有一個線程能實例該對象,解決了線程安全問題。

    缺點:
    對於靜態方法而言,synchronized關鍵字會鎖住整個 Class,每次調用getInstance方法都會線程同步,效率十分低下,而且當創建好實例對象之後,也就不必繼續進行同步了。

    備註:此處的synchronized保證了操作的原子性和內存可見性。

  • 同步代碼塊(雙重檢鎖方式DCL)

    public class SingleTon {
        //創建唯一實例
        private static volatile SingleTon instance = null;
        
        private SingleTon() {
        }
        
        public static SingleTon getInstance() {
            if (instance == null) {
                synchronized (SingleTon.class) {   
                    if (instance == null) {
                        instance = new SingleTon();
                    }
                }
            }
            return instance;
        } 
    }
    

    優點:
    添加了一個同步代碼塊,在同步代碼塊中去判斷實例對象是否存在,如果不存在則去創建,這個時候其實就完全可以解決問題了,因爲雖然是多個線程去獲取實例對象,但是在同一個時間也只會有一個線程會進入到同步代碼塊,那麼這個時候創建好對象之後,其他線程即便再次進入同步代碼塊,由於已經創建好了實例對象,便直接返回即可。但是爲什麼還要在同步代碼塊的上一步再次去判斷instance爲空呢?這個是由於當我們創建好實例對象之後,直接去判斷此實例對象是否爲空,如果不爲空,則直接返回就好了,就避免再次進去同步代碼塊了,提高了性能。

    缺點:
    無法避免暴力反射創建對象。

    備註:此處的volatile發揮了內存可見性及防止指令重排序作用。

三、枚舉實現單例

public enum SingletonEnum {
    INSTANCE;

    public static void main(String[] args) {
        System.out.println(SingletonEnum.INSTANCE == SingletonEnum.INSTANCE);
    }
}

枚舉實現單例是最爲推薦的一種方法,因爲就算通過序列化,反射等也沒辦法破壞單例性。(關於Android使用枚舉會產生性能問題的說法,這應該是Android 2.x系統之前內存緊張的時代了,現在已經Android Q了,相信某些場合枚舉所帶來的便利遠遠大於這點所謂的性能影響)

四. 如何避免單例模式反射攻擊

以最初的DCL爲測試案例,看看如何進行反射攻擊及又如何在一定程度上避免反射攻擊。
反射攻擊代碼如下:

 public static void main(String[] args) {

     SingleTon singleton1 = SingleTon.getInstance();
     SingleTon singleton2 = null;

     try {
         Class<SingleTon> clazz = SingleTon.class;
         Constructor<SingleTon> constructor = clazz.getDeclaredConstructor();
         constructor.setAccessible(true);
         singleton2 = constructor.newInstance();
     } catch (Exception e) {
         e.printStackTrace();
     }

     System.out.println("singleton1.hashCode():" + singleton1.hashCode());
     System.out.println("singleton2.hashCode():" + singleton2.hashCode());
 }

執行結果:

 singleton1.hashCode():1296064247
 singleton2.hashCode():1637070917

通過執行結果發現通過反射破壞了單例。
如何保證反射安全呢?只能以暴制暴,當已經存在實例的時候再去調用構造函數直接拋出異常,對構造函數做如下修改:

  public class SingleTon {
     //創建唯一實例
     private static volatile SingleTon instance = null;
   
     private SingleTon() {
         if (instance != null) {
             throw new RuntimeException("單例構造器禁止反射調用");
         }
     }
   
     public static SingleTon getInstance() {
         if (instance == null) {
           synchronized (SingleTon.class) {   
               if (instance == null) {
                   instance = new SingleTon();
               }
           }
       }
       return instance;
     } 
 }

此時可防禦反射攻擊,拋出異常如下:

 java.lang.reflect.InvocationTargetException
 at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
 at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
 at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
 at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
 at com.liujc.demo.TestUtil.testSingleInstance(TestUtil.java:45)
 at com.liujc.demo.TestUtil.main(TestUtil.java:33)
 Caused by: java.lang.RuntimeException: 單例構造器禁止反射調用
 at com.liujc.demo.SingleTon.<init>(SingleTon.java:16)
 ... 6 more
 Exception in thread "main" java.lang.NullPointerException
 at com.liujc.demo.TestUtil.testSingleInstance(TestUtil.java:49)
 at com.liujc.demo.TestUtil.main(TestUtil.java:33) 
 Process finished with exit code 1

然後我們把上述測試代碼修改如下(調換了singleton1的初始化順序)

 public static void main(String[] args) {
     SingleTon singleton2 = null;

     try {
         Class<SingleTon> clazz = SingleTon.class;
         Constructor<SingleTon> constructor = clazz.getDeclaredConstructor();
         constructor.setAccessible(true);
         singleton2 = constructor.newInstance();
     } catch (Exception e) {
         e.printStackTrace();
     }

     System.out.println("singleton2.hashCode():" + singleton2.hashCode());

     SingleTon singleton1 = SingleTon.getInstance(); //調換了位置,在反射之後執行
     System.out.println("singleton1.hashCode():" + singleton1.hashCode());
 }

執行結果:

 singleton2.hashCode():1296064247
 singleton1.hashCode():1637070917

發現此防禦未起到作用。

缺點:

  • 如果反射攻擊發生在正常調用getInstance之前,每次反射攻擊都可以獲取單例類的一個實例,因爲即使私有構造器中使用了靜態成員(instance) ,但單例對象並沒有在類的初始化階段被實例化,所以防禦代碼不生效,從而可以通過構造器的反射調用創建單例類的多個實例;
  • 如果反射攻擊發生在正常調用之後,防禦代碼是可以生效的;

如何避免序列化攻擊?
只需要修改反序列化的邏輯就可以了,即重寫 readResolve() 方法,使其返回統一實例。

   protected Object readResolve() {
       return getInstance();
   }

脆弱不堪的單例模式經過重重考驗,進化成了完全體,延遲加載,線程安全,反射及序列化安全。簡易代碼如下:

  • 餓漢模式

    public class SingleTon {
        private static SingleTon instance = new SingleTon();
        
        private SingleTon() {
            if (instance != null) {
                  throw new RuntimeException("單例構造器禁止反射調用");
             }
        }
        public static SingleTon getInstance() {
            return instance;
        } 
    }
    
  • 靜態內部類

    public class SingleTon {
        
        private static class InnerStaticClass{
            private static SingleTon singleTon  = new SingleTon();
        }
    
        public SingleTon getInstance(){
            return InnerStaticClass.singleTon;
        }
        
        private SingleTon() {
           if (InnerStaticClass.singleTon != null) {
                  throw new RuntimeException("單例構造器禁止反射調用");
           }
        }
    }
    
  • 懶漢模式

    public class SingleTon {
        //創建唯一實例
        private static SingleTon instance = null;
        
        private SingleTon() {
                if (instance != null) {
                  throw new RuntimeException("單例構造器禁止反射調用");
             }
        }
        
        public static SingleTon getInstance() {
            //延遲初始化 在第一次調用 getInstance 的時候創建對象
            if (instance == null) {
                instance = new SingleTon();
            }
            return instance;
        } 
    }
    

    缺點:

    • 如果反射攻擊發生在正常調用getInstance之前,每次反射攻擊都可以獲取單例類的一個實例,因爲即使私有構造器中使用了靜態成員(instance) ,但單例對象並沒有在類的初始化階段被實例化,所以防禦代碼不生效,從而可以通過構造器的反射調用創建單例類的多個實例;
    • 如果反射攻擊發生在正常調用之後,防禦代碼是可以生效的。

(枚舉實現單例是最爲推薦的一種方法,因爲就算通過序列化,反射等也沒辦法破壞單例性,底層實現比如newInstance方法內部判斷枚舉拋異常)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章