这几天花了些时间,相对仔细的阅读了《你必须知道的.NET》这本书,因为没有多少时间,请大家在看该书的时候一定要理解内容,转变成自己的经验。下面仅做简单的书评。
该书详细的介绍了C#类型的存储分配问题,对于值类型和引用类型的存储和类型的转换,都用了大篇幅来进行说明,如果还想再详细些,就得去看.net framework中的底层方法和机制了。其实它的这个存储分配,不该说是C#,应该是.net这个框架的存储分配方式。对于其它语言,比如VB.NET,VC++.NET也是一致的,因为在该.NET框架下,任何语言都是编译为IL。这个由底层的公共类型语言CTL来处理语言间的类型统一,即把各语言的不同类型统一成CTL中定义的某种类型。
该书前面对于面向对象设计的思想描述得很精彩,强烈建议大家体会其中的滋味。
在这里还是补充一下,关于值类型和引用类型的存储问题,看过的好几本书都说得不够具体,原理没有详细覆盖,且给下定义的时候容易误导读者。我在这里做个相对全面的分析。(如果我这里没提到的有遗漏的地方,请大家留言补充,一起学习)
“值类型实例存储在栈空间里“,”引用类型实例存储在托管堆里,由GC控制释放“等等相关的说法,都是片面的,不完全正确。
1、首先从命名空间说起。如果在源代码中没有指定命名空间,则代码就处于C#的全局命名空间(Global Namespace)下。在所有命名空间下,才能定义结构、枚举这两个值类型,以及类类型、接口,委托等引用类型,即你需要用到的类型的所有声明和结构定义(所有的类型都是一种数据结构)。这个由代码编辑器做了限制,即语法限制。C# 程序是利用命名空间组织起来的,无论是在内部组织系统,还是在外部组织系统,C#程序是封闭在命名空间里的。
2、根据1中进行分析。
类型定义于所有命名空间下,而数据是封装在某个类中的,方法是定义,继承,重写,重载在某个类中的。所以,所有的类型实例,包括值类型实例和引用类型实例以及指针类型实例,只存在于三个地方:某个类的字段;类静态方法和对象动态方法中的局部变量;类静态方法和对象动态方法的形式参数(即值类型的拷贝及引用类型的引用拷贝,实际上也是方法中的变量了)。 即对象的基本作用域。从作用域才能具体分析出类型的存储方式。
3、根据2进行分析。
在这个大环境下,必须先从引用类型开始说明,因为对象只存在于类中(作用域在类中),而类是引用类型。
在程序运行时,CLR将所有使用到的类型在内存中分配一个空间来存储,即类型定义的影像。所有在类中使用到的类型,都是这个空间里存储的类型的副本。比如int i=9;将根据该影像空间里的int类型定义,在栈上创建一个int型的影像副本,同时将9这个值赋值给该栈上的这个变量。这样的好处是不需要去查找int型的定义,然后根据定义去创建内存空间,再对该空间进行操作。而是直接根据类型在内存中影像的数据结构来创建类型副本用以保存对象。从而缩短了对象创建的过程和消耗的资源,但是也占用了内存空间来存储那些虽然没有使用到的类型。所以,在VS中,程序里如果没有使用到的程序集,则应该从该程序集的引用中删除,以减少程序集中存储的程序集元数据的存储(虽然这些程序集中的类型没有被加载则不会被加载到内存中)。在VS2008中,右键菜单里就有”移除未使用到的using“操作项,但是对于程序集引用的移除,还得手工处理。
3.1 静态类的字段和方法。
静态类因为没有实例,且它的字段,方法都是静态的,所以,在程序加载的时候,它已经在堆上分配一个空间来存储其数据结构。所以,静态类的字段,无论是值类型还是引用类型,无论是静态和动态的,无论是只读的还是可读可写的,都是存储在堆(加载堆 Loader Heap)上的。而它的方法,则映射存储在分配的虚拟函数表里,在调用方法的时候,CLR将该方法的原型复制到内存中运行。静态类的加载效率上比动态类(对象)的效率高。
3.2 动态类(对象)的字段和方法
对象在创建时,将根据类定义原型,在堆上分配该原型的影像副本来创建数据结构,并初始化该副本,同时在栈上分配一个空间来保存对该副本的引用。对象中的字段的存储与静态类一致,都是存储在堆(GC堆或大对象堆)上。对象的方法,也是映射在虚拟函数表中。因为对象在创建的时候,需要回溯该对象所有的父类,并复制父类的影像副本到自己的存储空间里并根据类的继承性决定方法的覆盖,重写等内容,所以,对象的创建是一个复杂的过程,消耗的资源比较大,所以就是为什么要在程序中尽量减少的创建对象的原因。依赖注入使对象的初始化方式发生了改变,在此不赘述。
3.3 类方法的局部变量和参数存储
无论是静态类还是动态类,对于其类方法里的形参,都由CLR在调用方法时分配对应的类型空间来进行存储,所以类方法的参数,其实也是方法开辟的与形参名称一致的局部变量。对于类方法,CLR在调用时将该方法原型代码复制到内存中的副本来运行。所以,方法运行时的局部变量,才是分配存储在栈上的。所以方法的调用,会涉及到压栈和出栈的概念。对于值类型,直接存储在栈上;对于引用类型,在堆上分配类型的数据结构,并在栈上存储该数据结构的引用;对于指针类型,同样是直接存储在栈上。
3.4 总结
类中的字段,因为分配在堆上,所以它受GC的控制(大的对象存储在大对象堆(Large Object Heap)上,只在GC完全回收时控制)。对于类方法中的局部变量,因为分配在栈上,所以不受GC控制,由操作系统处理(因为函数运行的机制)。对于局部变量,不需要手工释放资源;对于类字段,由GC控制资源的释放。当然,你的数据结构也不能创建得过多,否则将导致内存资源不足,而导致GC的效率低下。如果是这样,则你将需要添加内存硬件了。
4、值类型的装箱内存存储分析。
通过装箱操作,比如 int num=1;object obj = num;这里,因为obj是引用类型,所以,将在GC堆上创建一个object类型的变量,存储num的值的一个拷贝,即1,原num值将不变化,而obj这个变量,将占用栈上的空间,来存储该GC堆上分配的这个空间的地址。所以,在装箱后,对obj的操作,实际上是对GC堆上这个内容的操作。而不是值类型的对栈上的变量进行操作。所以装箱操作将消耗系统资源(创建堆对象),且对该对象的操作消耗的资源比操作栈上的变量消耗的资源多(栈的效率相对来说比堆的效率高,因为栈直接对应栈地址里的内容直接寻址获取,而堆是通过引用间接寻址来获取)。
5、类型的ref和out内存存储分析。
对于值类型形参函数void Test(ref int it){};来说,CLR将在调用函数的时候,将新建一个int变量it,并将该it变量添加到栈中,而该it变量存储的是一个指向实际调用该函数的实参int型变量在栈中所对应的地址。即使用一个栈空间存储栈空间里的另一个地址,该地址保存的是被调用的变量的值。
而对于引用类型形参函数void Test(ref object obj)来说,同样的,使用一个栈空间存储栈空间里的另一个地址,但是该地址保存的是一个指向GC堆上变量的引用,即GC堆里的一个内存地址。
指针类型同样使用一个栈空间存储引用的地址,但是它这个地址可能是栈空间的地址,也可能是托管堆里的地址。
通过上述类型存储的分析总结,可以在系统设计时根据值类型和引用类型的存储方式知道其优缺点,从而为系统的存储和运行性能提高奠定了基础。
注:上述分析基于VS2008中验证,这里不提供代码,请大家自行编码验证,加深印象。ref和out因为必须使用固定的变量地址,无法使用指针类型来验证,所以只能通过IL中的代码进行分析。 同样的,因为无法获取托管堆中的地址,所以也是根据使用指针类型来间接推断对象的存储问题。
ps.由于本人水平有限,如果大家对上述内容存在异议,烦请留言以纠正,非常感谢。