设计模式:单例(Singleton)

设计模式:单例(Singleton)

吴剑 2013-06-05

原创文章,转载必需注明出处:http://www.cnblogs.com/wu-jian

 

前言

单例模式是最早了解并经常使用到的设计模式之一,很早就想将其整理成文,但因为对一些细节准备尚未充分,一再延误。本文通过实例与代码,分析了单例模式的需求、原理、实现,以及探讨在B/S开发中单例模式与性能优化。对自己学习的总结,也希望给学习设计模式的朋友带来帮助。同时个人能力有限,文中如有不足之处请及时指正。

 

为什么需要单例模式

首先看看单例模式的定义:单例模式属于对象创建型模式,其特征可以概括为如下三点:

1、保证一个类仅有一个实例

2、必须自已创建自己的唯一实例

3、提供唯一实例的全局访问点

单例模式的应用场景很多,打印机程序能比较简单的说明问题:假使有一台打印机,我们写了一个打印程序,如果这个打印程序能被到处new(),甲new出来一个,乙new出来一个,甲还没打印完,乙又开始打印,那就乱了套。所以打印机程序必须使用单例模式,即只有一个对象与打印机通讯,甲乙丙丁同时打印,那就在单例对象中排队吧。

如果要通过一个最简单的例子来理解单例,我想奥巴马较具代表性:单例模式就好比美国总统,美国总统只能存在一个,如果存在多个?估计中国人民尤其是朝鲜人民会比较高兴。

 

单例模式的饿汉实现方式

单例模式的实现分为饿汉和懒汉两种方式,先看饿汉,代码如下: 

//饿汉
    public sealed class HungryMan
    {
        //类加载时实例化
        private static HungryMan mInstance = new HungryMan();

        //私有构造函数
        private HungryMan() { }

        //简单工厂方式提供全局访问点
        public static HungryMan Instance
        {
            get { return mInstance; }
        }
    }

饿汉为实现单例最为简单的方式,它也是典型的空间换时间,当类被加载即创建实例,而不论这个实例是否需要使用。以后使用实例时,均不再进行判断,节省了运行时间,但占用了空间。一些使用频繁的对象适合使用饿汉方式。

 

要求完美的饿汉

如果你不是完美主义者,可以忽略本节,因为本节的代码在大多数情况下并不能带来性能的提升。

在使用饿汉方式时,C#并不保证实例的创建时机,如下:

private static HungryMan mInstance = new HungryMan();

静态字段可能在类被加载时赋值,也可能在被调用之前赋值,总之我们不能确定到底什么时候创建类的实例。于是有完美主义者提出,这种CLR机制导致的不确定性会带来性能的损耗,能不能做到mInstance在被调用前的瞬间初始化,这样就可以节省了一段时间的内存开销。于是有饿汉的优化版本如下:

//要求完美的饿汉
    public sealed class PerfectHungryMan
    {
        private static readonly PerfectHungryMan mInstance = new PerfectHungryMan();

        private PerfectHungryMan() { }

        //通过静态构造函数实现延迟初始化
        static PerfectHungryMan() { }

        public static PerfectHungryMan Instance
        {
            get { return mInstance; }
        }
    }

如代码中的注释,通过静态构造函数实现了类的延迟初始化(即被调用之前初始化)。对比两个类生成的中间代码,可以看到只有一处不同:PerfectHungryMan比HungryMan少了一个特性:beforefieldinit,也就是说静态构造函数抑制了beforefieldinit 特性,而该特性会影响类的初始化,获取IL如下图所示:

包含beforefieldinit的类会由CLR选择合适的时机来初始化;不包含beforefieldinit的类会被强制在调用前初始化。如本节开头所描述的,大多数情况下废弃beforefieldinit延迟类的初始化并不能带来性能的提升,或提升的性能也是微乎其微。

所以除非特殊情况,否则我们没必要捡了芝麻丢了西瓜。

 

单例模式的懒汉实现方式(非线程安全)

懒汉即需要时才创建,如下代码所示:

//懒汉
    public sealed class LazyMan
    {
        private static LazyMan mInstance = null;

        private LazyMan() { }

        //需要时实例化
        public static LazyMan Instance
        {
            get{
                if (mInstance == null)
                    mInstance = new LazyMan();
                return mInstance;
            }
        }
    }

但代码中存在一个问题,即多线程环境下当两个以上请求同时调用时,会创建出多个对象,这违反了单例的基本原则。

 

线程安全的懒汉

//线程安全的懒汉
    public sealed class MultiLazyMan
    {
        private static MultiLazyMan mInstance = null;
        private static readonly object syncLock = new Object();

        private MultiLazyMan() { }

        //需要时实例化
        public static MultiLazyMan Instance
        {
            get{
                //确保单线程访问
                lock (syncLock)
                {
                    if (mInstance == null)
                        mInstance = new MultiLazyMan();
                    return mInstance;
                }
            }
        }
    }

以上代码的实现是线程安全的,首先创建了一个静态只读的进程辅助对象,lock确保当一个线程位于代码的临界区时,另一个线程不能进入临界区(同步操作)。如果其他线程试图进入锁定的代码,则将一直等待,直到该对象被释放。从而确保在多线程下不会创建多个对象实例。

这种实现方式确保了多线程环境下实例的唯一性,但从代码中可以发现,每个线程都需占用lock,如果一个WEB程序有100个请求同时到达,就要lock 100次,并且始终有人排队。从性能上来说,这种方式的效率低且性能开销大。

 

完美的懒汉

其实在多线程环境下我们只需在第一次创建实例时使用lock来确保实例唯一。实例创建出来以后,完全可以大家公用,那就加一行小判断,如下代码:

//完美的懒汉
    public sealed class PerfectLazyMan
    {
        //volatile 关键字指示一个字段可以由多个同时执行的线程修改。
        //声明为 volatile 的字段不受编译器优化(假定由单个线程访问)的限制。
        //这样可以确保该字段在任何时间呈现的都是最新的值。 
        private static volatile PerfectLazyMan mInstance = null;
        private static readonly object syncLock = new Object();

        private PerfectLazyMan() { }

        public static PerfectLazyMan Instance
        {
            get
            {
                //判断一下首次创建实例时才进行lock
                if (mInstance == null)
                {
                    //确保单线程访问
                    lock (syncLock)
                    {
                        if (mInstance == null)
                            mInstance = new PerfectLazyMan();
                    }
                }
                return mInstance;
            }
        }
    }

OK,通过一行举手之劳的判断得到了完美的懒汉。

如果查查资料,这个举手之劳的判断居然有个看似高深莫测的专门术语:双重检查成例(Double Check Idiom)

该术语是由C语言搬到JAVA,然后再从JAVA搬到C#。有幸在阎宏的《JAVA与模式》中读到相关细节,顺便也推荐下这本书,虽然厚了点,写得还是很好,如果作者教条主义再少一点,自由发挥再多一点,这本书就该是中文设计模式类书籍的典范了。

 

通过Lazy<T>实现懒汉

//Lazy<T>
    public sealed class GenericLazyMan
    {
        private static readonly Lazy<GenericLazyMan> mInstance = new Lazy<GenericLazyMan>(() => new GenericLazyMan());

        private GenericLazyMan() { }

        public static GenericLazyMan Instance
        {
            get
            {
                return mInstance.Value;
            }
        }
    }

Lazy<T>是.Net Framework 4.x提供的一个针对大对象延迟加载的封装,它提供了系列便捷功能,同时提供了线程安全,此处不作详述。

Lazy<T>参考资料:http://msdn.microsoft.com/en-us/library/dd997286(VS.100).aspx

 

单例模式与性能

 在B/S开发中我想每个人都写过类似如下的代码:

protected void Page_Load(object sender, EventArgs e)
        {
            businessObject = new Somewhere.BusinessObject();
            businessObject.DoSomething();
        }

创建一个业务对象,然后调用对象的方法来完成一些操作。

因为.Net、Java等高级语言中内置了垃圾回收机制,我们可以把并发的内存开销完全交由GC(Garbage Collection,垃圾回收)来打理,所以如上的代码在大多数情况下不会出现问题,以至于逐渐让我们遗忘了并发、遗忘了内存、遗忘了性能。

B/S开发是面向多用户处理并发请求的,在Page_Load中new出来的对象针对每一次请求。当1000人同时访问一个页面,我们实际就new出了1000个对象放在内存中,如果不凑巧这个对象有10M以上,那就代表了内存开销将大于10G,这是一个恐怖的数字,只是我们不常碰到1000的并发和10M的对象,当然,我们还有垃圾回收在保驾护航。

下面我模拟了一个1.5M左右的对象,100的并发,看看如下的CPU和内存曲线图:

首先CPU飙升,然后内存开销出现规律的峰谷。很明显,每个峰顶代表垃圾回收开始,每个谷底代表垃圾回收结束。垃圾回收虽然给我们带来了便捷,但其性能损耗也是妇孺皆知。附上本次测试的源代码:

namespace WuJian.DesignModel.Singleton
{
    public class TestObject
    {
        //加载一个1.5M的图片
        public TestObject()
        {
            this.mData = System.Drawing.Image.FromFile(HttpRuntime.AppDomainAppPath + @"app_data\1500k.jpg");
        }

        private System.Drawing.Image mData;

        public System.Drawing.Image Data
        {
            get { return this.mData; }
            set { this.mData = value; }
        }
    }

    public partial class StaticDemo : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            TestObject obj = new TestObject();
            Response.Write("image width is " + obj.Data.Width + "px");
        }
    }
}

 

 接下来看看使用单例模式同样1.5M的对象,100并发:

 内存开销是一条直线,没有峰谷,没有垃圾回收,并且几乎不受并发数量影响,100的并发与1000的并发在内存开销上完全相同。贴出代码变动部分:

public partial class StaticDemo : System.Web.UI.Page
    {
        //单例模式
        private static readonly TestObject obj = new TestObject();

        protected void Page_Load(object sender, EventArgs e)
        {
            Response.Write("image width is " + obj.Data.Width + "px");
        }
    }

 

示例很简单,其目的也只是为了引发大家的一些思考,你是否关心了可重用对象的内存开销?你是否成为了垃圾回收的俘虏?

 

DEMO下载

DEMO环境:Visual Studio 2012、.Net Framework 4.5

点击下载DEMO

注:本文中并发压力测试使用了JMeter,下载地址:http://jmeter.apache.org/ 

.Net内存分析使用了CLR Profiler,下载地址:http://search.microsoft.com/en-us/DownloadResults.aspx?q=clr%20profiler

 

参考文献:

《JAVA与模式》

《Head First设计模式》

TOM大叔:别再让面试官问你单例

 

<全文完>

 

微信打赏
如果您觉得本文对您有所帮助,可扫描两侧的二维码向作者打赏。您的支持是原创的源动力!
作者:吴剑
出处:http://www.cnblogs.com/wu-jian/
本文版权归作者所有,欢迎转载,但必需注明出处,并且在转载页面明显位置给出原文连接,否则保留追究法律责任的权利。
支付宝打赏
posted @ 2013-06-05 18:51  吴 剑  阅读(1834)  评论(3编辑  收藏  举报
@2010-2017 WuJian, All Rights Reserved.