單例模式使用餓漢式和懶漢式建立一定安全?很多人不知

2022-08-04 06:03:27

概述

單例模式大概是23種設計模式裡面用的最多,也用的最普遍的了,也是很多很多人一問設計模式都有哪些必答的第一種了;我們先複習一下餓漢式和懶漢式的單例模式,再談其建立方式會帶來什麼問題,並一一解決!還是老規矩,先上程式碼,不上程式碼,紙上談兵咱把握不住。

餓漢式程式碼

    public class SingleHungry
    {
        private readonly static SingleHungry _singleHungry = new SingleHungry();
        private SingleHungry()
        {
        }
        public static SingleHungry GetSingleHungry()
        {
            return _singleHungry;
        }
    }

程式碼很簡單,意思也很明確,接著我們寫點程式碼測試驗證一下;

第一種測試: 建構函式私有的,new的時候報錯,因為我們的建構函式是私有的。

 SingleHungry  _singleHungry=new SingleHungry();
第二種測試: 比對建立多個物件,然後多個物件的Hashvalue
public class SingleHungryTest
    {
        public static void FactTestHashCodeIsSame()
        {
            Console.WriteLine("單例模式.餓漢式測試!");
            var single1 = SingleHungry.GetSingleHungry();
            var single2 = SingleHungry.GetSingleHungry();
            var single3 = SingleHungry.GetSingleHungry();
            Console.WriteLine(single1.GetHashCode());
            Console.WriteLine(single2.GetHashCode());
            Console.WriteLine(single3.GetHashCode());
        }
    }
測試下來,三個物件的hash值是一樣的。如下圖:

餓漢式結論總結

餓漢式的單例模式不推薦使用,因為還沒呼叫,物件就已經建立,造成資源的浪費;

懶漢式程式碼

    public class SingleLayMan
    {
        //1、私有化建構函式
        private SingleLayMan()
        {

        }
        //2、宣告靜態欄位  儲存我們唯一的物件範例
        private static SingleLayMan _singleLayMan;
        //通過方法 建立範例並返回
        public static SingleLayMan GetSingleLayMan1()
        {
            //這種方式不可用  會建立多個物件,謹記
            return _singleLayMan = new SingleLayMan();
        }
        /// <summary>
        ///懶漢式單例模式只有在呼叫方法時才會去建立,不會造成資源的浪費
        /// </summary>
        /// <returns></returns>
        public static SingleLayMan GetSingleLayMan2()
        {
            if (_singleLayMan == null)
            {
                Console.WriteLine("我被建立了一次!");
                _singleLayMan = new SingleLayMan();
            }
            return _singleLayMan;
        }
    }

測試程式碼

 public class SingleLayManTest
    {
        /// <summary>
        /// 會建立多個物件.hash值不一樣
        /// </summary>
        public static void FactTest()
        {
            Console.WriteLine("單例模式.懶漢式測試!");
            var singleLayMan1 = SingleLayMan.GetSingleLayMan1();
            var singleLayMan2 = SingleLayMan.GetSingleLayMan1();
            Console.WriteLine(singleLayMan1.GetHashCode());
            Console.WriteLine(singleLayMan2.GetHashCode());
        }
        /// <summary>
        /// 單例模式.懶漢式測試:懶漢式單例模式只有在呼叫方法時才會去建立,不會造成資源的浪費,但會有執行緒安全問題
        /// </summary>
        public static void FactTest1()
        {
            Console.WriteLine("單例模式.懶漢式測試!");
            var singleLayMan1 = SingleLayMan.GetSingleLayMan2();
            var singleLayMan2 = SingleLayMan.GetSingleLayMan2();
            Console.WriteLine(singleLayMan1.GetHashCode());
            Console.WriteLine(singleLayMan2.GetHashCode());
        }
        /// <summary>
        /// 單例模式.懶漢式多執行緒環境測試!
        /// </summary>
        public static void FactTest2()
        {
            Console.WriteLine("單例模式.懶漢式多執行緒環境測試!");
            for (int i = 0; i < 10; i++)
            {
                new Thread(() =>
                {
                    SingleLayMan.GetSingleLayMan2();
                }).Start();
            }

            //Parallel.For(0, 10, d => {
            //    SingleLayMan.GetSingleLayMan2();
            //});
        }
    }

懶漢式結論總結

懶漢式的程式碼如上已經概述,上面GetSingleLayMan1()會建立多個物件,這個沒什麼好說的,肯定不推薦使用;GetSingleLayMan2()是大多數人經常使用的,可解決剛才因為餓漢式建立帶來的缺點,但也帶來了多執行緒的問題,如果不考慮多執行緒,那是夠用了。



話說回來,既然剛才餓漢式和懶漢式各有其優缺點,那我們該如何抉擇呢?到底選擇哪一種?

其它方式建立單例—餓漢式+靜態內部類

    public class SingleHungry2
    {
        public static SingleHungry2 GetSingleHungry()
        {
            return InnerClass._singleHungry;
        }       
        public static class InnerClass
        {
            public readonly static SingleHungry2 _singleHungry = new SingleHungry2();
        }
    }

這個程式碼,用了餓漢式結合靜態內部類來建立單例,執行緒也安全,不失為建立單例的一種辦法。

其它方式建立單例—懶漢式+反射

 首先我們解決一下剛才懶漢式建立單例的執行緒安全問題,上程式碼:

 /// <summary>
    /// 通過反射破壞建立物件
    /// </summary>
    public class SingleLayMan1
    { 
        //私有化建構函式
        private SingleLayMan1()
        {
        }
        //2、宣告靜態欄位  儲存我們唯一的物件範例
        private static SingleLayMan1? _singleLayMan;
        private static object _oj = new object();

/// <summary> /// //解決多執行緒安全問題,雙重鎖定,減少系統消耗,節約資源 /// </summary> public static SingleLayMan1 GetSingleLayMan() { if (_singleLayMan == null) { lock (_oj) { if (_singleLayMan == null) { _singleLayMan = new SingleLayMan1(); Console.WriteLine("我被建立了一次!"); } } } return _singleLayMan; } }

具體描述,在程式碼裡面已經說得足夠清楚,一看肯定明白,我們還是寫點測試程式碼,驗證一下,上程式碼:

public class SingleLayManTest1
    {
        public static void FactTestReflection()
        {
            var singleLayMan1= SingleLayMan1.GetSingleLayMan();

            var type = Type.GetType("_01單例模式.反射破壞單例模式.SingleLayMan1");
            //獲取私有的建構函式
            var ctors = type?.GetConstructors(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
            //執行建構函式
            SingleLayMan1 singleLayMan = (SingleLayMan1)ctors[0].Invoke(null);
            Console.WriteLine(singleLayMan1.GetHashCode());
            Console.WriteLine(singleLayMan.GetHashCode());
        }
    }

上面的程式碼分別通過SingleLayMan1.GetSingleLayMan2()和反射建立物件,輸出二者物件hash值比較,結果肯定是不一樣的,重點是我們可以通過反射建立物件。

通過上面的程式碼,不知道大家有沒有意識到我們雖通過加鎖解決了執行緒安全問題,但仍會出現問題;正常建立物件的順序是:

1、new 在記憶體中開闢空間
2、 執行建構函式 建立物件
3、 把空間指向我們的對像

但如果因為我們的程式使用多執行緒,則會發生"指令重排",本應執行順序為1、2、3,實際執行順序為1、3、2,但這種情況很少,不過我們寫程式嘛,肯定追求嚴謹一點準沒錯。

如果需要解決該問題需要給定義的私有區域性變數加關鍵字 加上volatile (意思不穩定的 ,可變的) ,加該關鍵字可以避免指令重排。具體程式碼主要是這句如下:

 private volatile static SingleLayMan? _singleLayMan;

 

到這裡,大家認為還有沒有問題?答案是肯定的,不然我就不會寫這篇文章了,通過反射既然可以建立物件,那麼我們寫的建立範例程式碼還有什麼意義,有沒有什麼辦法避免反射建立物件呢?

如果認真看了之前的反射建立物件程式碼,肯定發現反射是通過建構函式來建立物件的,那麼我們相應的就在建構函式處理一下。來,我們繼續上程式碼:

 /// <summary>
    /// 解決反射建立物件的問題
    /// </summary>
    public class SingleLayMan3
    {
        //2、宣告靜態欄位  儲存我們唯一的物件範例
        private volatile static SingleLayMan3? _singleLayMan;
        private static object _oj = new object();
        //私有化建構函式
        private SingleLayMan3()
        {
            lock (_oj)
            {
                if (_singleLayMan != null)
                {
                    throw new Exception("不要通過反射來建立對像!");
                }
            }
        }

        /// <summary>
        /// //解決多執行緒安全問題,雙重鎖定,減少系統消耗,節約資源
        /// </summary>
        public static SingleLayMan3 GetSingleLayMan()
        {
            if (_singleLayMan == null)
            {
                lock (_oj)
                {
                    if (_singleLayMan == null)
                    {
                        _singleLayMan = new SingleLayMan3();
                        Console.WriteLine("我被建立了一次!");
                    }
                }
            }           
            return _singleLayMan;
        }
       
    }

下面繼續上測試程式碼,驗證一下:

public class SingleLayManTest3
    {
        /// <summary>
        /// 第一次通過呼叫 SingleLayMan3.GetSingleLayMan()建立物件導致_singleLayMan不為空,之後再去通過反射建立物件時,建構函式裡面判斷建立物件導致_singleLayMan變數,報異常
        /// </summary>
        public static void FactTestReflection()
        {
            var singleLayMan1= SingleLayMan3.GetSingleLayMan();

            var type = Type.GetType("_01單例模式.反射破壞單例模式.SingleLayMan3");
            //獲取私有的建構函式
            var ctors = type?.GetConstructors(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
            //執行建構函式
            SingleLayMan3 singleLayMan = (SingleLayMan3)ctors[0].Invoke(null);
            Console.WriteLine(singleLayMan1.GetHashCode());
            Console.WriteLine(singleLayMan.GetHashCode());
        }
    }

結論其實測試方法已經說明:第一次通過呼叫 SingleLayMan3.GetSingleLayMan()建立物件導致_singleLayMan不為空,之後再去通過反射建立物件時,建構函式裡面判斷建立物件導致_singleLayMan變數,報異常。

其實到這裡,有人肯定發現了問題,第一次通過去執行自己寫的建立單例方法來建立物件,後面再執行反射時才會報異常,那有沒有什麼辦法,只要有人第一次反射建立物件時就報異常呢?

定義區域性變數解決反射建立物件問題

 public class SingleLayMan4
    {
        //2、宣告靜態欄位  儲存我們唯一的物件範例
        private volatile static SingleLayMan4? _singleLayMan;
        private static object _oj = new object();
        private static bool _isOk = false;
        //私有化建構函式
        private SingleLayMan4()
        {
            lock (_oj)
            {
                if (_isOk == false)
                {
                    _isOk = true;
                }
                else
                {
                    throw new Exception("不要通過反射來建立對像!只有第一次通過反射建立物件會成功!請做第一個吃葡萄的人!");
                }
            }
        }

        /// <summary>
        /// //解決多執行緒安全問題,雙重鎖定,減少系統消耗,節約資源
        /// </summary>
        public static SingleLayMan4 GetSingleLayMan()
        {
            if (_singleLayMan == null)
            {
                lock (_oj)
                {
                    if (_singleLayMan == null)
                    {
                        _singleLayMan = new SingleLayMan4();
                        Console.WriteLine("我被建立了一次!");
                    }
                }
            }           
            return _singleLayMan;
        }
       
    }

測試程式碼,驗證一下:

public static void FactTestReflection()
        {
            //第一次建立物件會成功
            var singleLayMan1 = GetReflectionSingleLayMan4Instance();

            //第二次建立物件會失敗,報異常
           var singleLayMan2 = GetReflectionSingleLayMan4Instance();

            Console.WriteLine(singleLayMan1.GetHashCode());
        }
        private static SingleLayMan4 GetReflectionSingleLayMan4Instance()
        {
            var type = Type.GetType("_01單例模式.反射破壞單例模式.SingleLayMan4");
            //獲取私有的建構函式
            var ctors = type?.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic);
            //執行建構函式
            SingleLayMan4 singleLayMan = (SingleLayMan4)ctors[0].Invoke(null);
            return singleLayMan;
        }

第一次建立物件會成功,因為執行建構函式時沒有執行GetSingleLayMan(),跨過了new,導致_isOk賦值true,第二次反射建立執行建構函式時判斷變數_isOk為true,走入異常邏輯。

但這樣做真的就安全了嗎?既然可以通過反射執行建構函式來建立物件,那也可以通過反射改變區域性變數_isOk 的值,上程式碼:

        /// <summary>
        /// 通過反射也可以改變區域性變數_isOk的值,繼續建立物件
        /// </summary>
        public static void FactTestReflection2()
        {
            Type type = Type.GetType("_01單例模式.反射破壞單例模式.SingleLayMan4");
            //獲取私有的建構函式
            var ctors = type?.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic);
            //執行建構函式
            SingleLayMan4 singleLayMan1 = (SingleLayMan4)ctors[0].Invoke(null);
            FieldInfo fieldInfo =  type.GetField("_isOk", BindingFlags.NonPublic | BindingFlags.Static);
            fieldInfo.SetValue("_isOk", false);
            SingleLayMan4 singleLayMan2 = (SingleLayMan4)ctors[0].Invoke(null);

            Console.WriteLine(singleLayMan1.GetHashCode());
            Console.WriteLine(singleLayMan2.GetHashCode());
        }

最後

大家或許發現了,只要有反射存在,哪怕你的邏輯寫的再嚴謹,它仍然可以反射建立物件,只因為它是反射!所以,單例模式的安全性也是相對而言的,具體選擇用哪個,取決專案的業務場景了。如有發現問題,歡迎不吝賜教!

原始碼地址:https://gitee.com/mhg/design-mode-demo.git