1. 程式人生 > >從原始碼中學習設計模式系列——單例模式序/反序列化以及反射攻擊的問題(二)

從原始碼中學習設計模式系列——單例模式序/反序列化以及反射攻擊的問題(二)

一、前言

這篇文章是學習單例模式的第二篇,之前的文章一下子就給出來看起來很高大上的實現方法,但是這種模式還是存在漏洞的,具體有什麼問題,大家可以停頓一會兒,思考一下。好了,不賣關子了,下面我們來看看每種單例模式存在的問題以及解決辦法。

二、每種Singleton 模式的演進

  •  模式一
public class LazySingleton
    {
        private static LazySingleton lazySingleton = null;
        private LazySingleton()
        {

        }

        
public static LazySingleton GetInstance() { if (lazySingleton == null) { lazySingleton = new LazySingleton(); } return lazySingleton; } }

 

問題:該模式下在多執行緒下就會存在問題,因為你不知道執行緒執行的先後順序,不信看下面的除錯,如下。

我們現在讓執行緒Two執行,它會進入到if裡面,因為執行緒one已經被凍結,除錯結果:

接著,我們把凍結的執行緒one解凍,執行完成的結果如下:

發現,竟然產生了兩個例項,這也就說明了上面實現單例模式在多執行緒下確實存在問題,為了解決在多執行緒的問題,引出了下面的單例模式。

  • 模式二:DoubleCheck雙重檢查

問題:上面的程式碼已經加上了lock,可以解決多執行緒的問題,但是這樣還是會出現問題,出現問題的地方在上面的兩處斷點處。多執行緒在多核CPU上執行時暫存器快取和指令的重新排序【也就是new關鍵字步驟2和步驟3交換】雖然出現的概率很小,但是這種隱患一定要消除。如果出現指令重排的話,一個執行緒還沒來得及把分配物件的指標複製給變數lazySingleton,另外一個執行緒就會進入到第一個斷點的if邏輯裡面。下面分別貼出暫存器快取和指令重新排序的示意圖:

快取資料示意圖:

 

(注意:圖片來源自https://theburningmonk.com/2010/03/threading-understanding-the-volatile-modifier-in-csharp/)

現代計算機中的記憶體很複雜,有多級快取,處理器暫存器和多個處理器共享主記憶體等。處理器可能會從主記憶體中讀取資料快取到暫存器中,另一個執行緒可能會使用快取的資料,並且如果修改僅更新主記憶體,再次期間併發執行在另外一個CPU上的執行緒,可能讀取的還是之前的值。 在此期間,在另一個CPU上併發執行的另一個執行緒可能已經從主儲存器中讀取了相同的資料位並使用了過時的資料版本。

指令重排示意圖(下面的示意圖來自:geely老師的Java設計模式課程):

 對於單執行緒來說既是指令重排也不會影響,但是對於多執行緒就會有影響,如下圖所示:

 

為了解決上面的問題有兩種做法:1)不允許2和3進行指令重排序。2)允許執行緒0可以重排序但是不允許執行緒1重排序。

對於解決辦法1:可以使用volatile關鍵字,它可以禁止重排序以及快取的問題。

對於解決辦法2:靜態內部類-基於類初始化的延遲加。

  • 模式三:解決辦法1示例程式碼:

  • 模式四:解決辦法2示例程式碼:
 public class StaticInnerClassSingleton
    {
        private static class InnerClass
        {
            internal static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
        }

        public static StaticInnerClassSingleton GetInstance()
        {
            return InnerClass.staticInnerClassSingleton;
        }
    }

 

static void GetInstancev5()
        {
            var hashCode = StaticInnerClassSingleton.GetInstance().GetHashCode();

            Console.WriteLine(hashCode);
        }

 

for (int i = 0; i < 10; i++)
            {
                Thread thread = new Thread(GetInstancev5);
                thread.Start();

                if (i%2==0)
                {
                    Thread.Sleep(1000);
                }
            }

 

驗證結果:

 

模式五:餓漢模式

public class CurrentSingleton
    {
        private static CurrentSingleton uniqueInstance = new CurrentSingleton();
        private CurrentSingleton() {
           
        }
        public static CurrentSingleton Instance
        {
            get { return uniqueInstance; }
        }
    }

 

 

 聊到這裡,關於單例模式的幾種模式已經差不多了,該聊的已經聊完了,大多小夥伴們可能就瞭解到這裡就結束了,先舒口氣,再繼續往下看,你會有意向不到的收穫。

 

三、單例模式下的問題解決辦法

  •  問題一:反射攻擊單例模式三

單例模式三(懶漢模式)程式碼:

 public class LazyDoubleCheckSingleton
    {
        private volatile static LazyDoubleCheckSingleton lazySingleton = null;
        private static readonly object _threadSafetyLock = new object();

        private LazyDoubleCheckSingleton(){}

        public static LazyDoubleCheckSingleton GetInstance()
        {
            if (lazySingleton == null)
            {
                lock(_threadSafetyLock)
                {
                    if (lazySingleton == null)
                    {
                        //注意:new關鍵字做了下面三步的工作:
                        //1、分配記憶體給這個物件
                        //2、初始化物件
                        //3、設定lazySingleton指向剛分配的記憶體地址
                        lazySingleton = new LazyDoubleCheckSingleton();
                    }
                }
            }

            return lazySingleton;
        }
    }

 

看到沒,我們通過反射也可以建立類的例項,那怕你的建構函式是private的,我通過反射都可以來建立物件的例項。同理你可以嘗試使用該方法來攻擊模式五(餓漢模式)。

那我們該如何防禦?對於餓漢模式、基於靜態類模式的單例,我們可以通過下面的方法來防禦:

在對應的private建構函式中新增一下程式碼:

 

對於懶漢模式的單例這種方法還適用嗎?不一定,請看下面的程式碼:

基於模式三【見上】的程式碼修改:

驗證結果:

發現該方式處理不起作用。對於這個問題我們該怎麼解決?嘗試的方法如下:

 public class LazyDoubleCheckSingleton
    {
        private volatile static LazyDoubleCheckSingleton lazySingleton = null;
        private static readonly object _threadSafetyLock = new object();
        private static bool flag = true;

        private LazyDoubleCheckSingleton(){
            if (flag)
            {
                flag = false;
            }
            else
            {
                throw new Exception("單例構造器進位制反射呼叫");
            }
        }

        public static LazyDoubleCheckSingleton GetInstance()
        {
            if (lazySingleton == null)
            {
                lock(_threadSafetyLock)
                {
                    if (lazySingleton == null)
                    {
                        //注意:new關鍵字做了下面三步的工作:
                        //1、分配記憶體給這個物件
                        //2、初始化物件
                        //3、設定lazySingleton指向剛分配的記憶體地址
                        lazySingleton = new LazyDoubleCheckSingleton();
                    }
                }
            }

            return lazySingleton;
        }
    }

 

Type type = typeof(LazyDoubleCheckSingleton);
            object sobj = Activator.CreateInstance(type, true);

            
            Console.WriteLine(LazyDoubleCheckSingleton.GetInstance().GetHashCode());
            Console.WriteLine(sobj.GetHashCode());

 

驗證結果:

這種方法看似解決了懶漢模式的問題,但是!它真的能解決這個問題嗎?大家可以想一下,為什麼解決不了?我也就不賣關子了,原因就是反射,反射的威力太強了,上面演示的,即使你的建構函式是private我也能建立物件,區區一個欄位,反射修改你的值不是很輕鬆嗎。

反射攻擊演示:

所以懶漢模式的單例,是防禦不了反射攻擊的,至於Java中有一個叫列舉模式的單例,可以解決這個問題,至於C#目前我還沒想出好的解決辦法,如果大家有好的解決辦法可以貢獻到評論區。好了問題一講到這裡已經差不多了,下面我們來介紹問題二。

  • 問題:序列化破壞單例模式

背景:在某些場景下我們需要把類序列化到檔案當中,正好這個類是單例的,正常的情況應該是:序列化到檔案中,再從檔案反序列化,應該是同一個類,但一般的處理方法真的能得到同一個類嗎?

例項程式碼:

[Serializable]
public
class StaticInnerClassSingleton { private static class InnerClass { internal static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton(); } public static StaticInnerClassSingleton GetInstance() { return InnerClass.staticInnerClassSingleton; } }
//序列化到檔案:

            var obj = StaticInnerClassSingleton.GetInstance();
            var formatter = new BinaryFormatter();
            var stream = new FileStream("D:\\Example.txt", FileMode.Create, FileAccess.Write);

            formatter.Serialize(stream, obj);
            stream.Close();


            //從檔案讀取出來反序列化

            stream = new FileStream("D:\\Example.txt", FileMode.Open, FileAccess.Read);

            var obj2 = (StaticInnerClassSingleton)formatter.Deserialize(stream);

            Console.WriteLine(obj.GetHashCode());
            Console.WriteLine(obj2.GetHashCode());

 

驗證結果:

看到沒,竟然是兩個不同的例項,如果大家遇到這樣的場景可以使用下面的方法來保障反序列化出來的是同一個物件,我們只需要修改單例模式的類。程式碼如下:

[Serializable]
    public class StaticInnerClassSingleton: ISerializable
    {
        private StaticInnerClassSingleton()
        {

        }

        private static class InnerClass
        {
            internal  static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
        }

        public static StaticInnerClassSingleton GetInstance()
        {
            return InnerClass.staticInnerClassSingleton;
        }

       
        public void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            info.SetType(typeof(SingletonHelper));
        }

        [Serializable]
        private class SingletonHelper : IObjectReference
        {
            public object GetRealObject(StreamingContext context)
            {
                return InnerClass.staticInnerClassSingleton;
            }
        }
    }

 

 

 如果想知道為什麼要這樣寫我就不在解釋了,大家可以參考這篇文章:http://geekswithblogs.net/maziar/archive/2012/07/19/serializing-singleton-objects-c.aspx   好了講到這裡基本上單例這種設計模式,你已經掌握的非常好了,希望對你有幫助,謝謝,如果覺得不錯的話,可以推薦一下。之前一直想寫這個系列的部落格,希望把自己平時學的和工作中的經驗分享出來,共同進步,這個系列的標題是“從原始碼中學習設計模式

 這裡的原始碼主要就是ASP.Net Core2.1的原始碼,現在.Net Core 3.0已經是預覽版,還沒有正式版,也希望.Net Core 越來越好。也希望我的文章能對你有幫助。

四、總結

 

 單例這種設計模式,具體使用哪種要看你的使用場景,並不是那種模式一定就好,這是需要權衡的,希望看完本篇文章,你在使用該模式能得心應手。另外大家不要和依賴注入中的單例混淆,之前再介紹依賴注入最佳實踐的文章中有園友就混淆了。

 

 

 

參考資料:

geely老師的《Java設計模式精講》

作者:郭崢

出處:http://www.cnblogs.com/runningsmallguo/

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連結。