由一个性能问题引出的 .net 概念
关键字: .net 性能 GC 值类型 引用类型 堆 堆栈 string
1 引子
我们先来看一下两组代码,每组中的哪一段代码效率更高呢?
第一组:
代码 1 :
for(int i = 0; i < 10000; i++)
{
AddressData ds = new AddresssData();
ds = addressS.GetAddress();
}
代码 2 :
for(int i = 0; i < 10000; i++)
{
AddressData ds;
ds = addressS.GetAddress();
}
第二组:
代码一:
string strNames = "@"+Guid.NewGuid().ToString().Replace("-","")+ ",@"+Guid.NewGuid().ToString().Replace("-","")+ ",@"+Guid.NewGuid().ToString().Replace("-","");
for(int i = 0; i < 10000; i++)
{
……
}
代码 2 :
for(int i = 0; i < 10000; i++)
{
string strNames = "@"+Guid.NewGuid().ToString().Replace("-","")+ ",@"+Guid.NewGuid().ToString().Replace("-","")+ ",@"+Guid.NewGuid().ToString().Replace("-","");
……
}
每一组代码中,两段代码实现的功能是一样的,它们之间的区别也很小,但是其效率会相差得惊人,其中一种会很频繁的 GC ,为什么呢?
在回答这个问题的时候我们先了解几个 .net 概念。
2 什么是 GC
GC 的全称是 ** garbage collection ** ,中文名称垃圾回收,是 .net 中对内存管理的一种功能。垃圾回收器跟踪并回收托管内存中分配的对象,定期执行垃圾回收以回收分配给没有有效引用的对象的内存。当使用可用内存不能满足内存请求时, GC 会自动进行。
在进行垃圾回收时,垃圾回收器回首先搜索内存中的托管对象,然后从托管代码中搜索被引用的对象并标记为有效,接着释放没有被标记为有效的对象并收回内存,最后整理内存将有效对象挪动到一起。这就是 GC 的四个步骤。
由上可见, GC 是很影响性能的,所以一般说来这种事情况还是尽量少发生为好。
为了减少一些性能影响, .net 的 GC 支持对象老化,或者说分代的概念,代是对象在内存中相对存现时期的度量单位,对象的代数或存现时期说明对象所属的代。目前 .net 的垃圾回收器支持三代。每进行一次 GC, 没有被回收的对象就自动提升一代。较近创建的对象属于较新的代,比在应用程序生命周期中较早创建的对象的代数低。最近代中的对象位于零代中。每一次 GC 的时候,都首先回收零代中的对象,只有在较低代数的对象回收完成后仍不能满足需求的情况下才回收较高代数的对象。
3 堆栈和堆
内存有堆栈和堆的概念。堆栈遵循后进先出的原则,后被推入堆栈的对象必定实现本拉出堆栈,这样保证了这部分内存的紧凑,也基本上不需要考虑内存地址的问题。而堆则没有这个原则,任何一个对象都有可能在任何时候进入堆中,也可能在任何时候被移出堆。这样很明显,我们就要考虑每一个对象保存在哪里,所以就需要在堆栈中保存每一个对象保存在堆中的地址。同时在经过一段时间之后我们就会发现堆中产生了许多空隙,也就是碎片,为了提高系统性能,我们这个时候经常需要整理堆,以清除碎片。关于堆栈和堆,如下图所示:
4 GC 和堆栈、堆
由前述堆栈和堆的概念可以看出,堆栈不存在垃圾收集的问题,只需要直接压栈即可,而堆,则面临着很复杂的垃圾回收的问题。 GC 完全是对堆进行操作的,而对堆中对象是否有效的判断则是通过遍历堆栈来实现的。这里涉及到一个引用计数的概念,引用计数是对堆中对象被引用次数的统计,当一个对象的引用计数为零了,那么这个对象就可以被回收了。在进行 GC 的时候,垃圾回收器遍历堆栈,当发现一个堆地址的时候,它就将堆中该地址上的对象的引用计数加 1 ,然后销毁堆中所有引用计数为零的对象,回收内存并整理堆中的碎片。
5 值类型和引用类型
我们都知道,计算机中的数据类型分为值类型和引用类型两种。那么到底什么是值类型什么是引用类型呢?
大多数编程语言提供内置的数据类型(比如整数和浮点数),这些数据类型会在作为参数传递时被复制(即,它们通过值来传递)。在 .NET Framework 中,这些称为值类型。运行库支持两种值类型:内置值类型和用户定义的值类型。
引用类型则存储对值的内存地址的引用。引用类型可以是自描述类型、指针类型或接口类型。引用类型的类型可以由自描述类型的值来确定。自描述类型进一步细分成数组和类类型。类类型是用户定义的类、装箱的值类型和委托。
作为值类型的变量,每个都有自己的数据副本,因此对一个变量的操作不会影响其他变量。作为引用类型的变量可以引用同一对象;因此对一个变量的操作会影响另一个变量所引用的同一对象。
6 值类型、引用类型和堆栈、堆
了解了值类型和引用类型,那么,这两种类型在内存中又是怎样表现的呢?
值类型存储在堆栈中,而引用类型则存储在堆中,然后在堆栈中存储对堆中对象的引用(又叫指针),如下图所示:
因为这样的一种存储方式,于是就造成了对变量操作影响的不同,例如通过引用指针 b 对数据所作的更改也会表现在通过引用指针 c 的得到的数据中。而对值类型进行操作却不会有这样的情况。
这两种不同也会表现在我们的方式上,例如:
我们假设 ModifyClass() 方法是对 ClassA 中的字段 Value1 加 2 ;
ClassA ca = new ClassA() ;
ca.Value1 = 2 ;
ModifyClass(ca) ;
int getValue = ca.Value1 ;
……
这时你可以看到 getValue 的值是 4 ,而 ModifyClass() 方法并没有返回任何数据。
而对于值类型,我们就会发现这样做是不可以的,你必须要让方法有返回数据,如:
int ca = 2
int getValue = ModifyValue(ca) ;
值类型和引用类型的区别还在于声明新变量的时候,如 ClassA ca = null 是合法的,而 int ca = null 则是非法的。
7 类实例化的步骤
类是最常见也是我们用的最多的一种引用类型,我们知道实例化一个类使用的是一个我们司空见惯的语句:
ClassA ca = new ClassA() ;
那么这短短的一句话中,计算机又做了些什么事情呢?
实际上,计算机在这个过程中大致做了这么几件事:
首先,在 ClassA ca 的时候,生成一个空的引用指针,并将它推入堆栈中:
然后,在 new ClassA() 的时候,生成 ClassA 的新的实例,并放入堆中:
在赋值号 = 这一步,将 ca 的引用指针指向刚刚生成的新实例:
这个时候,才算完成了整条语句的操作。
好了,了解了以上这些概念之后,我们可以来回答本文开始的问题了:
8 回答本文开始的问题
关于第一组代码,我们首先要了解
AddressData ds = new AddresssData();
ds = addressS.GetAddress();
在内存中的情况,我们已经知道,在上述代码的第一句完成之后,会是这样的一个样子:
而在第二句代码完成之后,就会变成这样一个样子:
其中 ClassA 的实例 1 是第代码一句产生的,实例 2 是 addressS.GetAddress() 方法产生的,实例 2 才是有效的对象,而实例 1 则不幸落了个一产生就只能等待着被垃圾回收器回收掉的命运。
一般而言,这种做法不会带来太大的性能问题,但是在某些情况下呢?例如本文一开始演示的这样一个循环?
这个时候就会在堆中产生非常多的垃圾,占用了大量的内存,于是不得不不断的 GC ,从而严重影响性能。
那么对于第二组代码又会怎样呢?
看起来,第二组代码和第一组代码很不一样,一个是类这种典型的引用类型,而一种是字符串这种通常看起来像是值类型的东西。
实际上,字符串是一种很特殊的东东,它兼有值类型和引用类型的特征,例如我们必须用这样的方式对他进行处理:
string ds = “This is a Test”;
ds = ModifyString(ds);
其中需要注意的是第二句,这是典型的对值类型进行操作的方式, ModifyString() 方法必须要返回数据;但另一方面,我们初始化一个新的字符串变量的时候却又可以这样写: string ds = null 。很奇怪对吧,我感觉,之所以会出现这种情况的原因可能是设计者希望它能尽量的和其他的值类型如 int,float 等数据类型的使用方式一致,毕竟它本身的特征和给我们的感觉是如此的相似,但它却又是无法固定长度的, int 和 float 等等我们都明确的规定了它是多少位的, string 却不行。
由于这个原因,造成了第二组两段代码之间的性能差异。
9 一些题外话:一定要用 Class 吗?
我们知道 Class 是引用类型的, struct 则是一种值类型, Class 是面向对象编程时代所特有的,而我们称作结构的 struct 则只是面向对象编程萌芽阶段出现的一个实现对象化的一个变通做法,以至于 java 就抛弃了 struct 的概念,那么 .net 为什么没有放弃 java 放弃了的东西呢?
struct 不是垃圾。首先, struct 是一种值类型,这样使得它可以存放在堆栈中,而不是堆中,也就是说,它不会带来 GC 的性能影响;其次,例如一个对象中有 100 个元素,如果把这个对象分别定义成类和结构各会占用多少内存空间呢?答案是,定义为类会占用 101 块内存空间,分别是 100 个元素和引用指针的;而定义成结构只占用 100 块而已。也许你会说,才多占用这么一块没什么关系,不过增加了 1% 而已,那么,如果这个对象只有三个元素呢?
所以我们可以很明白的得出结论, Class 不是唯一。