问:当我们new一个对象时,会发生什么事?
答:调用该类型的构造函数。
问题看似简单,不过事实上,CLR做的比这要多。。。
要准确回答这个问题,还要分情况来说。
new一个引用类型
首先,要实例化一个引用类型,就一定需要在堆上分配内存。要分配内存,就需要先计算出这个引用类型占多大空间,需要给它分配多少内存。
那怎么计算呢?简单!只要计算该类型所有字段的长度总和就可以啦。我们知道,引用类型的字段,占一个指针的长度(32位机器上是4个字节,64位机器上是8个字节)。值类型的字段长度可以通过递归的方法计算得出(递归终点是遇到引用类型或基本类型)。根据这些信息,我们就可以轻松计算出所有字段长度的总和了。
但是实际上计算方法会比这个复杂一点点,因为还要考虑到内存对齐的情况,关于内存对齐的解释我附在了本文的最后,这里就不多说了。考虑了内存对齐之后,得到的结果可能会比之前的要稍大一些。
不过这个仍然不是最终的结果。要得到最终的结果,还需要加上两个指针的长度。原因是,每个分配在堆上的对象都会有两个指针的“额外开销”,这两个开销分别是同步块索引和类型指针。关于同步块索引,一两句话也说不清楚,不过可以把它简单地理解成一个指向“同步块”的指针,而这个“同步块”的作用则是为了让拥有该同步块的对象能够支持线程同步。所谓类型指针,你可以这样来理解:每个对象都是一个类型的实例,而每个类型本身都有一个Type类型的实例来表示,对象的类型指针就是指向该类型的Type实例的指针。举个例子就清楚多了,我们知道,typeof(String)的值是一个Type类型的实例,这个Type类型的实例也就是所有的String对象的类型指针所指向的东西。
好了,到此为止,就可以得出实例化一个引用类型需要为其分配的内存数了。不过,要注意的是,CLR并不是在运行时计算分配内存的大小的,而是早在编译的时候就已经计算好这个量了。
接下来要做的是初始化分配得到的内存块。这个很简单,只要把这段内存的所有二进制位都设为0就可以了。
然后就是初始化两个“额外开销”的值了。对于同步块索引,CLR把它初始化为一个负数,并不指向任何的同步块。这是因为对于绝大多数对象,我们不要求它支持线程同步,所以不必急着给他实例化一个同步块,等到真的需要的时候再实际进行分配。而对于类型指针,则将其指向一个实实在在的对象——即该类型的类型对象实例。
再然后,就是调用类型的构造函数了。
完成了上述步骤,一个引用类型的对象实例就做好了,new操作符只要返回这个实例的引用就算完成任务了。
new一个值类型
首先,也是要计算需要分配多少内存。因为值类型是没有所谓的“额外开销”的,所以值类型所需的内存长度就是其内部字段的大小总和(同样需要考虑内存对齐)。同样的,CLR在编译的时候就已经计算好这个量了,不需要在运行时计算。
然后,CLR分配所需的内存。在哪里分配呢?这可说不准,在堆上或在栈上都有可能。
再然后就是调用类型构造函数了。这里需要注意,CLR并没有初始化这段内存块,而是把初始化内存块的任务都交给构造函数了。这样做是为了保证值类型轻量性的特点。这也是为什么C#语言在值类型的构造函数中强制要求为所以字段赋值的原因。另外,所有值类型的默认构造函数都会把内部字段都初始化为0。
到此,一个值类型也做好了。一般来说,对于值类型,new操作符并不需要返回其地址。原因在于,值类型的位置相对固定,因此在编译时就可以基本确定它们的位置。比如说,函数栈上的值类型实例都有一个相对于栈的偏移量,这个偏移量在编译时就是确定的。再比如说,作为引用类型的字段的值类型,都有一个相对于该引用类型地址的偏移量,这个偏移量也是早在编译时就固定下来的。所以,new操作符无需返回值类型实例的地址。
现在我们知道每new一个对象时CLR所需要做的工作了。可以看出,CLR的任务并不轻松。若是考虑到new一个对象之后还要垃圾回收该对象,那CLR就更辛苦了。所以,每当我们想要实例化一个类型的时候,都需要三思而后行。。。
附:关于内存对齐(这个是我之前学习的笔记,记得不是很系统,有兴趣的同学凑合看一下吧。。。)
为什么要内存对齐?
为了提高程序的性能,内存中的数据结构应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问,而对齐的内存访问仅需要一次访问。(对字,双字,和四字来说,自然边界分别是偶数地址,可以被4整除的地址,和可以被8整除的地址。)
怎样才算内存对其?
一个字或双字操作数跨越了4字节边界,或者一个四字操作数跨越了8字节边界,被认为是未对齐的,从而需要两次总线周期来访问内存。一个字起始地址是奇数但却没有跨越字边界被认为是对齐的,能够在一个总线周期中被访问。