摘要:反射攻擊首先我們來看一下反射調用,以雙重檢驗方式為例反射攻擊輸出結果是反射攻擊結果私有構造方法被調用次私有構造方法被調用次從結果可以看到,私有的構造函數被調用了兩次,也就是說這樣的單例模式并不安全。
保證一個類僅有一個實例,并提供一個訪問它的全局訪問點。
——艾迪生維斯理 《設計模式》
版權聲明:本文為 冬夏 原創發表在公眾號「Android從入門到精通」,可以隨意轉載,但請注明出處。概述
在我們日常編寫程序的時候,經常需要一種這樣的對象。我們希望整個系統只有一個這樣的對象,不論在什么時候和不論在哪里獲取這個對象的時候,獲得的都是同一個對象。
比如說系統的任務管理器,我們希望整個系統只有一個任務管理器,不論什么時候打開任務管理器,都可以看到當前系統的所有任務,而不是把任務分散在很多個任務管理器里。
又比如說打印機,當電腦連接上一臺打印機的時候,我們會希望不管是在文檔A里使用或者在文檔B里使用的時候,都是同一臺打印機,而且能夠按順序打印。
我們把這種類似的需求不斷總結并歸納起來,就成了單例模式。
單例模式可以說是所有設計模式里面最簡單的了,但是要靈活并且準確地使用它也不是那么容易的。
首先觀察一下單例模式的 UML 圖。
從 UML 圖中我們可以觀察到單例模式的幾個特點
私有的、靜態的實例對象
私有的構造函數
公有的、靜態的獲取實例對象的方法
那么,什么樣的代碼可以同時滿足這幾個特點呢?
懶漢模式所謂的懶漢模式,就是一開始并不實例化對象,等到需要使用的時候才實例化。
{% codeblock 懶漢模式 lang:java %} public class Singleton { private static Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } {% endcodeblock %}
從上面的代碼我們可以看到,當第一次獲取 Singleton 實例的時候,instance 為空,將創建 Singleton 對象,并賦值給 instance 變量。以后的每次一獲取都將獲得第一次創建的 Singleton 對象,從而實現了唯一性。
線程安全驗證仔細想想這段代碼,可能存在什么問題呢?
假設有這么一種情況, Singleton 對象還沒有創建,這時候有很多個線程同時獲取 Singleton 對象,這時候會發生什么呢?
用下面的代碼可以驗證
{% codeblock 懶漢模式 線程安全驗證 lang:java %} public class Singleton { private static int count = 0; private static Singleton instance = null; private Singleton(){ try { Thread.sleep(10); }catch (InterruptedException e){ } System.out.println("Singleton 私有構造方法被調用 " + ++count + "次"); } public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } public class Test { public static void main(String[] args){ Runnable runnable = new Runnable() { @Override public void run() { Singleton singleton = Singleton.getInstance(); System.out.println("當前線程:" + Thread.currentThread().getName() + " Singleton: " + singleton.hashCode()); } }; for (int i = 0; i < 10; i++){ new Thread(runnable).start(); } } } {% endcodeblock %}
從上面的代碼可以看到,我們對懶漢模式做了一點小修正,在創建 Singleton 對象的時候讓當前線程休眠了10ms,這主要是因為計算機運算速度太快了,不讓當前線程休眠一下的話很難出現想要的結果。關于休眠我們可以把它想象成創建對象的過程中需要消耗一定的時間。
運算部分結果如下:
{% codeblock 懶漢模式 線程安全驗證結果 lang:java %} Singleton 私有構造方法被調用 1次 當前線程:Thread-1 Singleton: 2044439889 Singleton 私有構造方法被調用 4次 Singleton 私有構造方法被調用 3次 Singleton 私有構造方法被調用 2次 當前線程:Thread-0 Singleton: 605315508 當前線程:Thread-2 Singleton: 2298428 當前線程:Thread-3 Singleton: 1005746524 當前線程:Thread-4 Singleton: 1005746524 當前線程:Thread-5 Singleton: 1005746524 當前線程:Thread-6 Singleton: 1005746524 當前線程:Thread-7 Singleton: 1005746524 當前線程:Thread-8 Singleton: 1005746524 當前線程:Thread-9 Singleton: 1005746524 {% endcodeblock %}
從上面的結果可以看到,Singleton 的私有構造方法被調用了不止一次。對此的解釋是,當第一次獲取 Singleton 對象還沒完成的時候,線程被系統掛起了,這時候有其他線程剛好也獲取了 Singleton 對象,那么就會產生多個 Singleton 對象。
由此我們可以得出結論:懶漢模式是 非線程安全 的。
同步方法為了解決懶漢模式非線程安全的缺點,就出現了改進的懶漢模式。其原理是當多個線程同時獲取 Singleton 對象時,一次只讓一個線程獲取,其他線程都在等待,這樣就解決了多線程下的對象獲取問題。
{% codeblock 同步方法 lang:java %} public class Singleton { private static Singleton instance = null; private Singleton(){} public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } {% endcodeblock %}
我們通過 synchronized 關鍵字讓 getInstance()方法一次只能讓一個線程調用,但是隨著而來的又有另外一個問題。
那就是 效率問題,因為只有第一次獲取 Singleton 對象時有可能發生線程安全問題,但是使用同步方法卻讓每次只讓一個線程能訪問getInstance()方法,而不管 Singleton 對象是不是已經被創建出來了。
那么有沒有辦法能同時解決線程安全和效率問題呢?
雙重校驗雙重校驗 方式就是為了解決懶漢模式的線程安全和效率問題而產生的。
{% codeblock 雙重校驗 lang:java %} public class Singleton { private static Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } } {% endcodeblock %}
雙重校驗就是將前面兩種懶漢模式結合起來。當第一次獲取 Singleton 對象時, instance 為空, 這時候為了解決可能存在的線程安全問題,同步了 Singleton 這個類對象。也就是說,同一時刻只能有一個線程能夠執行 synchronized 之后的代碼。同時因為同步代碼外層有一個條件語句,所以同步代碼只有在第一次獲取 Singleton 對象的時候執行到,這樣就解決了效率問題。
但是這種方法還是有一個問題,那就是 instance = new Singleton() 這一行代碼并不是原子性的
具體來說,JVM執行這一行代碼時主要做了三件事
給 instance 分配內存空間
調用 Singleton 的構造函數來初始化成員變量
將 instance 變量指向分配的內存空間(執行完這一步之后 instance 就不為 null 了)
由于 JVM 的指令優化存在,上面的第二點和第三點并不能保證一定按順序執行。也就是說執行順序有可能為 1-2-3 或者 1-3-2。
假設是 1-3-2,那么如果執行到3的時候,線程被搶占了,有另外一個線程獲取了單例對象(這時候 instance 不為 null,但是還沒有初始化),那么自然就會出現錯誤。
為了解決這個問題,我們只要將 instance 變量聲明成 volatile 就可以了。
private static volatile Singleton instance = null;
volatile 關鍵字主要有兩個特性
可見性:保證線程沒有變量的本地副本,每次都去主內存獲取最新版本
禁止指令重排序:生成內存屏障
很明顯,我們這里利用的是 volatile 的第二個特性。
特別注意的是只有在 Java 5 之后使用這種方式才是完全安全的,原因是 Java 5 之前的 Java 內存模型(Java Memory Model,JMM)存在缺陷,即使變量聲明為 volatile 也不能完全避免重排序,這個問題在 Java 5 之后才修復。
惡漢模式這時候我們可以換個思路,既然懶漢模式是因為需要的時候才創建對象,所以才讓程序有機會可以產生多個對象。那如果我一開始就把對象創建好了,不就行了嗎?這就出現了惡漢模式。
惡漢模式的意思是不管對象目前有沒有使用,都會先創建出來。
{% codeblock 惡漢模式 lang:java %} public class Singleton { private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } } {% endcodeblock %}
從代碼中可以看到,由于在 Singleton 類加載時就創建了 Singleton 對象,所以惡漢模式是 線程安全 的。
但是惡漢模式存在的問題就是不管目前對象有沒有被使用,都被創建了出來,浪費了內存空間。
靜態方法靜態方法的單例模式和惡漢模式的原理一樣,都是利用了classloader,在類加載的時候就創建了 Singleton 對象。
{% codeblock 靜態方法 lang:java %} public class Singleton { private static Singleton instance = null; static { instance = new Singleton(); } private Singleton(){} public static Singleton getInstance() { return instance; } } {% endcodeblock %}靜態內部類
靜態內部類的方法和上面兩種方法既有相似的地方,也有不同的地方。
{% codeblock 靜態內部類 lang:java %} public class Singleton { private static class SingletonHolder{ private static final Singleton INSTANCE = new Singleton(); } private Singleton(){} public static Singleton getInstance() { return SingletonHolder.INSTANCE; } } {% endcodeblock %}
從代碼種我們可以看到,靜態內部類的方法和前兩種方法一樣,都是利用了classloader,在加載類的時候創建 Singleton 對象。
不同的地方在于加載的類不同。靜態內部類方法在加載 Singleton 類的時候不會創建 Singleton 對象。而是在加載 SingletonHolder 類的時候才會。那么 SingletonHolder 類是什么時候加載的呢?
根據JVM(Java 虛擬機)的類加載規則,靜態內部類只有在主動調用的時候才會加載。也就是說,在第一次調用 getInstance() 方法時才會加載 SingletonHolder 類,同時創建了 Singleton 對象。
也可以說,靜態內部類的方法利用JVM解決了前兩種方法占用內存的問題。
防止單例受到攻擊到目前為止,我們所分析的所有單例模式都有一個前提,那就是調用者非常聽話地使用了 Singleton.getInstance() 方法獲取單例對象。但是在現實生活中是不是都是這樣的呢?會不會有不懷好意的人使用其他方式破壞我們的單例模式呢?
我們先思考一下,獲取一個對象有幾種方式
使用 new 關鍵字
通過反射調用
序列化
我們前面的單例模式都是通過第一種方式獲取對象的,那么如果采用其他兩種方式,之前的單例模式還安全嗎?答案是否定的。
反射攻擊首先我們來看一下反射調用,以雙重檢驗方式為例
{% codeblock 反射攻擊 lang:java %} 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; } } public class Test { public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException{ Singleton singleton1 = Singleton.getInstance(); Class> classType = Singleton.class; Constructor> constructor = classType.getDeclaredConstructor(null); constructor.setAccessible(true); Singleton singleton2 = (Singleton) constructor.newInstance(); System.out.println(singleton1 == singleton2); //false } } {% endcodeblock %}
輸出結果是
{% codeblock 反射攻擊結果 lang:java %} Singleton 私有構造方法被調用 1次 Singleton 私有構造方法被調用 2次 false {% endcodeblock %}
從結果可以看到,私有的構造函數被調用了兩次,也就是說這樣的單例模式并不安全。
為了防止單例模式被反射攻擊,我們可以添加一個標志位,在新建對象時判斷是否已經新建過對象了。
{% codeblock 防止反射攻擊 lang:java %} public class Singleton { private static boolean flag = false; private static volatile Singleton instance = null; private Singleton(){ if (!flag){ flag = true; }else { throw new RuntimeException("構造函數被調用多次"); } } public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } } {% endcodeblock %}
當然這種方式也有一個缺點,那就是必須保證 Singleton.getInstance() 方法在反射之前調用,否則將不能正確獲取單例對象。
而且,既然我們可以通過反射創建出對象,那么也可以通過反射修改標志位的值,這樣一來,使用標志位的方法就不能完全防止反射攻擊了。
序列化攻擊接下來我們看一下序列化如何破壞單例模式,以惡漢模式為例。
{% codeblock 序列化攻擊 lang:java %} public class Singleton implements Serializable{ private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } } public class Test { public static void main(String[] args) throws IOException,ClassNotFoundException{ Singleton singleton1 = Singleton.getInstance(); Singleton singleton2; FileOutputStream fos = new FileOutputStream("SerSingleton.obj"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(singleton1); oos.flush(); oos.close(); FileInputStream fis = new FileInputStream("SerSingleton.obj"); ObjectInputStream ois = new ObjectInputStream(fis); singleton2 = (Singleton)ois.readObject(); System.out.println(singleton1==singleton2); } } {% endcodeblock %}
輸出結果為 false 表明我們的單例收到了攻擊,那么如何防止這種情況呢?
我們可以在被序列化的類中添加readResolve方法
{% codeblock 防止序列化攻擊 lang:java %} public class Singleton implements Serializable{ private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } private Object readResolve(){ return instance; } } {% endcodeblock %}
說了這么多,不知道大家有沒有這樣一種感慨 「 都說單例模式是最簡單的一種模式,這么還這么復雜,以后還讓不讓人活了 」。
那么有沒有一種又簡單有能防止所有攻擊的方法呢?
枚舉枚舉( enum )是 Java1.5 之后新加的特性。
大家一定很奇怪,為什么枚舉可以實現單例呢?其實和 Java 的編譯特性有關。因為枚舉是 Java1.5 之后新加的,一般新加入的功能有一個很重要的問題需要解決,就是對以前代碼的兼容性問題。而 Java 是通過 語法糖 的方式解決的。簡單來說就是編寫代碼的時候可以使用新的關鍵字 enum 編寫程序,但是 Java 編譯器在編譯成字節碼的時候,還是會利用現有的技術編譯成之前的 JVM 能夠識別并正確運行的字節碼,這就是語法糖技術。
我們先來看一下枚舉編寫的單例是什么樣子的。
{% codeblock 枚舉 lang:java %} public enum Singleton { INSTANCE; public static Singleton getInstance(){ return INSTANCE; } public void otherMethods(){ System.out.println("do something"); } } {% endcodeblock %}
這段代碼看起來很簡單,我們定義了一個枚舉類型 INSTANCE, 這就是我們需要的單例。但是為什么這樣就能實現線程安全的單例呢?要解決這個疑問,我們必須把這段代碼進行反編譯,看看 java 編譯器究竟是如何編譯這段代碼的。
我們使用 java 自帶的反編譯工具 javap 就可以將這段代碼反編譯
javap -c Singleton
反編譯結果如下:
{% codeblock 反編譯 lang:java %} public final class Singleton extends java.lang.Enum{ public static final Singleton INSTANCE; public static Singleton[] values(); Code: 0: getstatic #1 // Field $VALUES:[LSingleton; 3: invokevirtual #2 // Method "[LSingleton;".clone:()Ljava/lang/Object; 6: checkcast #3 // class "[LSingleton;" 9: areturn public static Singleton valueOf(java.lang.String); Code: 0: ldc #4 // class Singleton 2: aload_0 3: invokestatic #5 // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum; 6: checkcast #4 // class Singleton 9: areturn public static Singleton getInstance(); Code: 0: getstatic #7 // Field INSTANCE:LSingleton; 3: areturn public void otherMethods(); Code: 0: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #9 // String do something 5: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return static {}; Code: 0: new #4 // class Singleton 3: dup 4: ldc #11 // String INSTANCE 6: iconst_0 7: invokespecial #12 // Method " ":(Ljava/lang/String;I)V 10: putstatic #7 // Field INSTANCE:LSingleton; 13: iconst_1 14: anewarray #4 // class Singleton 17: dup 18: iconst_0 19: getstatic #7 // Field INSTANCE:LSingleton; 22: aastore 23: putstatic #1 // Field $VALUES:[LSingleton; 26: return } {% endcodeblock %}
可能這段代碼對于剛剛接觸 java 的人來說一時可能看不懂,但是我們只要關注到一下幾點就好了。
public final class Singleton extends java.lang.Enum
public static final Singleton INSTANCE,說明我們定義的枚舉值 INSTANCE 實際上被 java 編譯器轉換成了不可變對象,只可以初始化一次。
關注到 INSTANCE 實際上是在 static {} 這段代碼里初始化的。也就是說, INSTANCE 是在 Singleton 類加載的時候初始化的,所以一旦 Singleton 類加載了,INSTANCE 也就初始化了,不能再改變了,這就實現了單例模式。
然后如果我們嘗試使用序列化或者反射的方式去攻擊枚舉單例,會發現都不能成功,這是由于 JVM 實現枚舉的機制決定的。
最后,引用一下 《Effective Java》一書中的話。
單元素的枚舉類型已經成為實現Singleton的最佳方法。
——《Effective Java》
歡迎關注我的個人公眾號,一起學習Android、Java、設計模式等技術!
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/68109.html
摘要:用來指向已創建好的實例構造函數為空注意這里是關鍵這是我們需要調用的方法把函數也定義為空,這樣就大功告成啦。 接上一篇大話PHP設計模式之單例模式 這一篇介紹一下升級版的單例模式,廢話不說先上代碼 不完美的單例模式 class singleMode { //用來指向已創建好的實例 public static $instance; //判斷是...
摘要:博主按每天一個設計模式旨在初步領會設計模式的精髓,目前采用靠這吃飯和純粹喜歡兩種語言實現。單例模式用途如果一個類負責連接數據庫的線程池日志記錄邏輯等等,此時需要單例模式來保證對象不被重復創建,以達到降低開銷的目的。 博主按:《每天一個設計模式》旨在初步領會設計模式的精髓,目前采用javascript(_靠這吃飯_)和python(_純粹喜歡_)兩種語言實現。誠然,每種設計模式都有多種實...
摘要:博主按每天一個設計模式旨在初步領會設計模式的精髓,目前采用靠這吃飯和純粹喜歡兩種語言實現。單例模式用途如果一個類負責連接數據庫的線程池日志記錄邏輯等等,此時需要單例模式來保證對象不被重復創建,以達到降低開銷的目的。 博主按:《每天一個設計模式》旨在初步領會設計模式的精髓,目前采用javascript(_靠這吃飯_)和python(_純粹喜歡_)兩種語言實現。誠然,每種設計模式都有多種實...
摘要:上面是簡單的單例模式,自己寫程序的話夠用了,如果想繼續延伸,請傳送至大話設計模式之單例模式升級版 看了那么多單例的介紹,都是上來就說怎么做,也沒見說為什么這么做的。那小的就來說說為什么會有單例這個模式以便更好的幫助初學者真正的理解這個設計模式,如果你是大神,也不妨看完指正一下O(∩_∩)O首先我不得不吐槽一下這個模式名字單例,初學者通過字面很難理解什么是單例,我覺得應該叫唯一模式更貼切...
摘要:最近開展了三次設計模式的公開課,現在來總結一下設計模式在中的應用,這是第一篇創建型模式之單例模式。不過因為不支持多線程所以不需要考慮這個問題了。 最近開展了三次設計模式的公開課,現在來總結一下設計模式在PHP中的應用,這是第一篇創建型模式之單例模式。 一、設計模式簡介 首先我們來認識一下什么是設計模式: 設計模式是一套被反復使用、容易被他人理解的、可靠的代碼設計經驗的總結。 設計模式不...
摘要:原文博客地址單例模式系統中被唯一使用,一個類只有一個實例。中的單例模式利用閉包實現了私有變量兩者是否相等弱類型,沒有私有方法,使用者還是可以直接一個,也會有方法分割線不是單例最簡單的單例模式,就是對象。 原文博客地址:https://finget.github.io/2018/11/06/single/ 單例模式 系統中被唯一使用,一個類只有一個實例。實現方法一般是先判斷實例是否存在,...
閱讀 837·2021-11-18 10:07
閱讀 2354·2021-10-14 09:42
閱讀 5315·2021-09-22 15:45
閱讀 584·2021-09-03 10:29
閱讀 3462·2021-08-31 14:28
閱讀 1873·2019-08-30 15:56
閱讀 3038·2019-08-30 15:54
閱讀 994·2019-08-29 11:32