大话 .Net 之内存管理

在一次偶然的机会中,我来到了恒生的大家庭。又在一次偶然的机会中,我很荣幸的被勇哥信任并让我写一篇季刊的文章。可能人生之中充满了无数次的偶然机会,我们只有抓住眼前的“偶然”,才可以创建人生。当我接到这个任务的时候,有一些激动又有一些害怕。激动的是我又有机会去分享自己知道的知识了,但是还是有些害怕,在恒生中大牛们太多了,写什么其实都是没有什么技术含量的。在激动和压力之中,最终还是写下了这一篇文章。用此文章来激励自己和那些当初选择了c#而转行的兄弟们。也许有一些地方说的不是很正确,希望读者不吝赐教。我的个人邮箱是:codeany@163.com。

读者类型:

这篇文章适应的读者为:未学习过c#、刚学c#、或者从未系统的学习过c#底层的程序员。

专业术语解析:

       GC堆:Garbage Collection,在c#中,当没有变量指向一块GC堆的内存时,他不会立即把这块内存回收,而是等到系统在合适的时间去回收这一块内存。

LOH堆:Large Object Heap,用于分配大对象实例。如果引用类型对象的实例大小不小于85000字节时,该实例将被分配到LOH堆上,不同于GC堆,垃圾收集器不会对LOH堆进行压缩。

类型句柄:TypeHandle,TypeHandle指向相关连的类型的MethodTable。任何一个声明了的类型都仅有一个MethodTable, 并且所有同样类型对象的实例都指向同一份MethodTable。

SyncBlockIndex:指针指向Synchronization Block的内存块,用于在多线程环境下对实例对象的同步操作。

IL代码:Intermediate Language,IL是.NET框架中中间语言的缩写。在.NET中支持的语言有C#、VB、F#等等,但是这些高级语言,最终生成IL代码,最后通过IL代码解析器解析,从而实现多语言的开发。

拆箱装箱:在c#中,如果把值类型转化为引用类型叫做装箱,反之如果把引用类型转化为值类型叫做拆箱。装箱和拆箱的操作都是非常耗性能的,所有我们平时编程的时候尽量避免装箱拆箱的操作。

简单的new说起

说起 new 我想大家并不会陌生,虽然我没有去深入的学习过java、c、c++的new,但是我想在这里和大家分享一下我自个认为的c#的new。在new之前我们还是先创建一个class类,对于程序员来说,最好的解释莫过于代码,那我就直接看代码吧:

/// <summary>

    /// 定义一个用户信息的类

    /// </summary>

    public class UserInfo {

        public Int32 age = ;          // 用户的年龄

        public char sex = 'M';         // 用户的性别

    }

    /// <summary>

    /// 用来定义一个用户类

    /// </summary>

    public class Person {

        public Int32 id = ;           // 用户的id号

        public UserInfo user;          // 用户信息

    }

    /// <summary>

    /// 从用户类中继承得到一个恒生用户类

    /// </summary>

    public class HsPerson : Person {

        public bool isGood = true;      // 是否是优秀 这里默认是优秀的

}

我想上面的代码大家一定不会陌生,而且肯定每个人都会有多多少少写过类似的代码。那我们今天也就从这些代码转入我们的正题。首先我们可以考虑一个问题,我们自己创建的类占用了多少内存,并且在内存中是如何分配的。好吧,我想有的人一定会说这个还不简单,直接调用sizeof来计算占用空间大小不就解决问题了。其实当初我也是这么想的,但是非常遗憾的告诉你,在c#中sizeof是计算值类型大小的。但是我们自己创建的类是引用类型。所以问题并不会这么简单。但是我们可以手动的计算得到我们需要的答案。在计算之前我们先来看一下什么叫值类型、引用类型。

值类型与引用类型(参考微软的MSDN):

值类型:

如果数据类型在它自己的内存分配中存储数据,则该数据类型就是值类型。 值类型包括:

  • 所有数字数据类型
  • Boolean 、Char 和 Date
  • 所有结构,即使其成员是引用类型
  • 枚举,因为其基础类型总是 SByteShortIntegerLongByteUShortUInteger 或 ULong

每个结构是值类型,因此,即使它包含引用类型成员。 因此,值类型 (如 Char 和 Integer 由 .NET framework 结构实现。

可以通过使用保留关键字(例如 Decimal)声明值类型。 也可以使用 New 关键字初始化值类型。 这对于值类型有一个带参数的构造函数的情况尤为有用。此示例有 Decimal(Int32, Int32, Int32, Boolean, Byte) 构造函数,它从提供的部分生成新的 Decimal 值。

引用类型:

引用类型包含指向存储数据的其他内存位置的指针。 引用类型包括:

  • String
  • 所有数组,即使其元素是值类型
  • 类类型,如 Form
  • 委托

类是一种“引用类型”。 因此,诸如 Object 和 String 之类的引用类型都受 .NET Framework 类支持。 请注意,每个数组都是一种引用类型,即使其成员是值类型。

在了解了值类型和引用类型之后,我们回归到我们的本文的正题。值类型的变量保存到内存的线程的堆栈中;而引用类型的变量会保存到托管堆中,其中这里说的托管堆又可以分为GC堆、LOH堆。其中GC堆、LOH堆是根据创建的对象的大小来分配到不同的堆中的,判断的平衡点是这个对象是否超过85000字节,如果小于85000字节,则系统把对象保存到GC堆中;如果大于或者等于85000字节,则系统保存到LOH堆中(一般LOH创建的对象是数组)。所以我们常说的托管堆就是指GC堆和LOH堆的集合。当然,我这里写的也不是完全正确的,其实在c#中创建对象是一个非常复杂过程,当中会涉及到系统程序域、共享程序域和默认程序域等等。我这么写只是想把问题简单化,让更多地人先了解一个大概的过程。

接下来我们来开始计算一下第一个问题——对象占用了多少空间,在UserInfo中我们定义了一个int32的age(4byte),一个char的sex(2byte),在Person中我们定义了int32的id(4byte),指针类型的user(4byte)类型,在HsPerson中我们继承了Person类,并添加了bool的isGood(1byte),所以我们一共占用了4+2+4+4+1=15byte。其实计算并没有结束,实例对象所占字节数还要加上对象附加成员所需的字节总数,其中附加成员包括TypeHandle和SyncBlockIndex,共计8Byte(在32位CPU平台下面),所以我们一共占用了23byte字节。然而在堆上分配的内存总是按照4byte的整数倍,所以最后我们可以得知我们在GC堆上要了24byte空间(我们创建的对象一般是不会超过85000byte字节的,所有大部分都是在GC堆中,有一些特别大的数组byte[85000],此事CLR就会把它保存到LOH堆中)。第二个问题解答从里氏替换原则图中找答案吧!

里氏替换内存管理

在上面的类的基础上,我们来创建几个对象,来看看内存是怎么分配的。

static void Main(string[] args)

{

// 完成一个简单的 class 操作

    HsPerson hs = new HsPerson();

    hs.user = new UserInfo();

    Console.WriteLine(@"显示信息1:

                          用户{0}优秀

                          用户id号是{1}

                          用户年龄是{2}

                          用户性别是{3}

                   ",hs.isGood==true?"是":"不是",hs.id,hs.user.age,hs.user.sex);

    // 引入一个难点 --氏替换原则

    Person hsperson = hs;

   //下面这样子打印会出错

    /*Console.WriteLine(@"显示信息2:

                            用户{0}优秀

                            用户id号是{1}

                            用户年龄是{2}

                            用户性别是{3}",

hsperson.isGood == true ? "是":"不是",hsperson.id, hsperson.user.age, hsperson.user.sex);*/

   Console.WriteLine(@"显示信息2:

                          {0}

                          用户id号是{1}

                          用户年龄是{2}

                          用户性别是{3}

                        ", "这里由于变量hsperson指向变量范围的控制,读取不到isGood 字段", hsperson.id, hsperson.user.age, hsperson.user.sex);

     Console.ReadKey();

}

  

大话 .Net 之内存管理

图 里氏替换内存分配图

这里有一点希望大家不要误解,就是我把Person类放到了HsPerson类中,这样只是方便的让读者更好地理解里氏替换原则的一些细节(实际上Person类也是在一块连续的GC堆中)。我们还是一边从代码分析,一边从图分析,首先我们声明了一个hs类型为HsPerson的变量,这个变量会在线程堆栈中占用4byte的空间,紧接着我们用new在GC堆中创建了一个HsPerson的实例,其实我们用“=”把这个创建的对象的实例的地址告诉了变量hs,此时变量已经初始化完成了。紧接着我们创建了一个hsperson类型为Person的类,并且从hs的变量中把地址拿过来也作为自己的对象的地址。但是奇怪的问题发生了,我们不可以用hsperson去调用isGood这个字段。为什么呢?其实这个是变量的偏移量在搞鬼,当我们创建变量的同时已经告诉了这个对象他可以有多少的偏移量,hs他具有访问所有的HsPerson这个对象的偏移量,而hsperson他只具有访问在HsPerson中的Person的偏移量。可能你还不是很明白,那我这里举一个例子,假如我们有一根1米的棒子,去摘树上的桃子,那么我们就只能摘到1米以下的桃子,而当我们去摘1米以上的桃子时,此时编译器就会报错,告诉我们超越我们能力了。(这里我们去掉了个人身高也去掉了有些特殊的方法去摘桃),所以当我们想要摘到所有的桃子,我们只需要有一根最高桃子树高度的棒子就可以了。但是这样你的变量可能拥有了全部的访问权限,但是在面向对象的编程之中,我们提倡基于接口编程,这样只需要对象访问到自己高度的桃子并能够完成任务就可以了。 当然里氏替换用的最为广泛的应该是动态的调用对象了(由于不是本文重点,就不一一展开了)。

本来还想写一些IL代码分析内存、拆箱装箱内存知识,由于时间有限,就先写到这里,如果你该兴趣,我可以和你在私下交流。写了这么多,我只是想告诉你在c#中对象的创建是个复杂的过程,主要包括内存分配和初始化两个环节。

参考:

http://www.cnblogs.com/anytao/

http://msdn.microsoft.com/zh-tw/library/dd229211.aspx

http://www.cnblogs.com/dudu/

http://www.cnblogs.com/artech/archive/2010/10/20/CLR_Memory_Mgt_01.html

http://msdn.microsoft.com/zh-cn/library/x9h8tsay.aspx

http://msdn.microsoft.com/zh-cn/library/t63sy5hs

上一篇:一致性Hash算法的原理与实现(分布式映射算法)


下一篇:关于SSMS显示select出来的数据行的疑问