说起单例模式、静态类、线程内唯一对象,想必大家都不陌生,或多或少都用过,然而在什么场景下我们该选用哪种模式?以及它们之间到底有什么根本的区别?今天我们就来详细的研究一下它们之间的联系和区别。
这样的问题也是在面试的时候经常会被问到或提起的一个问题。
可能这三者,我们最常用的也就是单例模式了,单例模式用在什么场合,为什么不用静态类而用单例?首先,我们得从静态方法和非静态方法的区别和联系说起。
静态方法常驻内存,实例方法只有在使用的时候才加载?
一般我们都是这样理解的,并且是为了防止静态方法占用过多内存而建议使用实例方法,其实这样的理解不完全正确。
为什么要这样说?我们得从内存的分配开始讲起:
托管堆的定义:对于应用程序来说,应用程序完成进程的初始化之后,CLR将会在应用程序域中的可用内存地址分配一块保留的内存空间,它是进程(假设是32位应用程序,那么每个进程理论上可使用4GB)中可用地址空间上的一片区域,但并不针对任何物理内存,这块内存空间就是托管堆。
而托管堆又分为了多个区域,其中最重要的也就是垃圾回收堆(GC Heap)和加载堆(Loader Heap)了,垃圾回收堆用于存储对象实例,这样就受GC管理了,而加载堆又分为了High-Frequency Heap、Low-Frequency Heap和Stub Heap,不同的堆上又存储不同的信息。Loader Heap最重要的信息就是元数据相关的信息,也就是Type对象,每个Type在Loader Heap上体现为一个Method Table(方法表),而Method Table中则记录了存储的元数据信息,例如基类型、静态字段、实现的接口、所有的方法等等。Loader Heap不受GC控制,其生命周期为从创建到AppDomain卸载。(选自《你必须知道的.Net》)
到这里我们就应该明白了,静态方法和实例方法在内存里其实都放在了方法区(Method Table)里了,当一个类第一次加载的时候,它会在加载堆里面将静态方法和实例方法都写入到方法区,而且加载堆不受GC控制,所以一旦被加载,就不会被回收了,直到应用程序生命周期结束。
由此我们明白了,静态方法和实例方法它们都是在首次加载后常驻内存,所以方法本身就在内存里,没有什么区别,在C#里面,方法也本身也是作为一个特殊的对象而存在,而刚才也说了加载堆里存放的是类的元数据Type对象,而这个Type对象在之前的这篇文章中也提到,Type是所有class实例所共有的,所有实例的每个方法对应的都是同一对象。所以也就不存在“静态方法常驻内存,实例方法只有在使用的时候才加载”这个说法了。
静态类和实例对象的区别?
在内存中的区别是,实例对象在创建对象时,因为属性的值对于每个对象都各不相同,因此在new创建对象时,会把这个实例属性在GC堆中拷贝一份,同时将这个对象放到堆内存上,内存指针指向了刚才拷贝的一份实例的内存地址,而静态类则不需要,因为静态类里的静态方法和属性,已经在方法表里了,只有一份。
为什么要有实例方法?
早期的时候,还是结构化编程,也就是面向过程,还没有面向对象这个概念,几乎所有的方法都是“静态方法”,而引入了面向对象这个概念以后,区分静态方法和实例方法不能单方面的从性能上去理解,发明C++、java、C#语言的这些大师们引入实例方法并不是解决什么性能、内存的问题,而是为了让开发更加模块化、面向对象化。归根结底其实静态方法和实例方法的区分是为了解决开发模式的问题。
那我们继续思考,如果我们全部用静态方法,而不用实例方法,不也一样的能实现功能嘛,像stackoverflow一样,大量的静态方法支撑起了世界第56大网站,这也不是不可以,但是,这样的代码是基于对象的,而不是面向对象的,只是这样看上去有悖于面向对象的概念,因为面向对象的继承和多态,都是非静态方法。
其次,为什么不建议都用静态方法,试想如果我们在高并发的情况下,某个静态方法引用了一个静态字段,那么这个静态字段就会被多个线程共享,因此,静态方法里面用了静态变量,这就会有线程安全问题,当然,就算不是多线程,因为静态字段只有一份,同样有可能会被其他地方修改的问题。
结论
什么时候用静态方法,什么时候使用实例方法?
既然静态方法和实例方法只是解决开发模式的问题,那么如果我们不考虑继承和多态,就可以使用静态方法,但就算不考虑继承和多态,就一概使用静态方法也不是什么好的编程习惯。
换个角度考虑,如果一个方法和它所在的实例对象无关,那么它应该是静态的,否则就因该是非静态的,因此就像一个工具类,它一般是静态的。
那么为什么用单例而不是静态类?
从面向对象的角度讲,虽然都能实现相同的功能,但是它们一个是基于对象,一个是面向对象的,就像我们不用面向对象也能解决问题一样,面向对象只是提供了一个更高层次的编程思想。
如果一个方法和它所在的实例对象无关,那么它应该是静态的,否则就因该是非静态的。如果我们确实应该使用实例方法,但是在整个应用程序的生命周期中又确实只需要维护一份实例时,就需要用单例模式了。
比如我们应用程序启动的时候需要加载一些配置和属性,这些配置和属性一定是存在的咯,又是公共的,同时又要伴随应用程序的整个生命周期存在,所以只需要加载一次就行,而这个时候我们需要再new一个的时候,再指定值,这显然是浪费内存并且再次赋值是没有意义的事情,所以这样的场景我们就需要使用单例模式或者静态类去维持有且仅有的一份,但这样的配置和属性又是通过面向对象的编码方式而得到的,那就只能是单例模式,或者不面向对象,但本身的属性是面向对象的,我们使用静态类也能解决这样的问题,但最好的解决方案还是单例模式。
从功能上讲,单例模式可以控制实例的数量,同时也是个实例类,可以进行有意义的派生,对实例的创建有更自由的控制。
静态类和单例类有什么区别?
我们已经基本上得出了结论,似乎发现静态类和单例类除了开发模式上,就真的没什么区别了么?仍然从内存的角度考虑,如果静态类中包含了一些预先初始化的字段,那么静态类是一启动就常驻内存,而单例类延迟加载了。诶,这不和一开篇所提出的问题相矛盾了么?请注意,这并不矛盾,开篇提到的仅仅是静态方法和实例方法,并没有说到字段的存在,字段是个内存引用,而属性,其实也是对应的get、set方法。
上文说了这么多,都是单例和静态类的,也没有半句提到线程内唯一对象,那这个线程内唯一对象跟它们又有什么联系和区别呢,有了上文的认知,我们接下来才能更清楚的了解线程内唯一对象。
典型场景:数据库访问为什么要用线程内唯一对象?
我们做.NET的一定都用过EntityFramework,当初在学习的时候老师也教我们说封装的时候要将DbContext封装成一个线程内唯一对象,为什么用单例不行?
假设用了单例,那么造成的问题可能就是:
1.因为单例的存在,造成了数据库连接connection对象也封存在了单例中,而数据库连接池中是有很多连接可以用的,那么被单例化了,在web并发访问的时候,所有请求就只能共用一个数据库连接,那是不是造成悲剧了;
2.其次,因为DbContext要跟踪实体的上下文,如果使用了单例,那么所有用户的请求都共用了这个数据上下文对象,当你在执行某个修改操作的时候,但并没有提交,可能同时其他人在执行保存数据的操作,那么你的数据也就被其他人操纵了;
3.正是由于数据上下文对象对实体的跟踪,排除刚才的数据被其他人操作的情况,如果是单例,那么所有用户操作数据时,数据实体都被跟踪到DbContext,假设有百万千万级的用户同时在线,那这个DbContext的负担是不是很大,内存会不会爆了?
我们再举个场景,比如我们在同一个请求里,这个请求从开始到结束肯定是同一线程咯,也就是HttpContext在请求的开始到结束都唯一存在,我们的DAL层在某个方法里调用某张数据表,要进行修改操作,在另一个方法里调用另外一张数据表,要进行插入操作,如果我们每次调用都实例化一个DbContext,那么这样就执行了两次数据保存操作,而如果使用了线程内唯一对象,那么我们只需要在请求结束前将两次数据的更新和添加,执行一次SaveChanges,就可以了。
针对数据库访问的单例,其实连接池可以做成单例,其实也不叫单例,确切点说是对象池模式,这种模式类似于单例,对象池实例在初始化的时候,比如创建100个Connection对象先存到一个集合里,这个集合就像是个池子,所以叫对象池,每次GetInstance的时候从对象池里面拿出一个实例,用完了再放回来或者释放掉,当池子里面的对象都用完了,GetInstance的时候检测到拿不出对象,则再创建一定数量的对象存到池子里,并拿出一个对象实例。