.net core csharp初级篇 1-7

本节内容为类的生命周期

引言

对象究竟是一个什么东西?对于许多初学者而言,对象都是一个非常抽象的知识点。如果非要用一句话描述,我觉得“万物皆对象”是对于对象最全面的概述了。本节内容中,我们将以在富土康打工的张全蛋组装一台水果手机作为例子,详细的讲解面向对象的各个方面。

对象类的构造

“张全蛋,你去水果公司,把他们的组装零件需求清单带过来~,并且还要带上组装的技术说明书。”车间主任吆喝着叫张全蛋办事。张全蛋前往了水果公司,如愿以偿的拿到了他想要的东西,组装零件清单上写着:

  • amoled屏幕*1
  • 电池3000ma *1
  • cpu*1
  • 内存*1

技术说明上写着:

  • 组装零件:屏幕放置在顶部,电池在底部,中间夹着pcb板,pcb上面封住cpu和内存
  • 开机方法:长按开机键三秒

限于篇幅,我们只列举这些,你可以发现,我们的组装清单上面,事实上就是我们手机的组成部分,需要占用手机内部空间,并且是这个手机的重要组成参数。这就和我们类中的属性和字段的功能是一样的;而技术说明,是对于这里的具体操作,他们是一个工序,一个操作,并不是一个实体,因此他们就是和我们类中的函数是一个意思。

突然一位老员工对张全蛋说,其实啊,每一款水果手机都几乎没多大差别,你可以在机器中预设好内存大小和cpu的型号,这样你就可以直接将模具做好了。面对这种情况,张全蛋想出了一个绝妙的方法,那就是在构造函数中传入参数。

因此我们可以构造出这样一个类

class fruitphone
{
    public fruitphone(int msize,string cputype)
    {
        cputype = cputype;
        memsize = msize;
    }

    public string screentype{get;set}
    public string cputype{get;set;} 
    public int memsize{get;set;}
    public int battery{get;set;}

    void make()
    {
        //todo
    }
    void open()
    {
        //todo
    }
}

对象的出生

对象就像个体的人,生而入世,死而离世。我们的故事就从对象之生开始吧。首先,看看在上面的例子中,一个对象是如何出生的。

fruitphone p = new fruitphone(2,"a12");

我们通过调用构造函数,成功的创造了一个手机对象,在手机被创建的同时,虽然我们还没有组装好屏幕一类的,但是我们在手机模具中也需要预留他们的空间,因此在对象实例化的时候,其内部的每个字段都会被初始化。

对于屏幕和电池一类的,我们后续可能会根据成本等等进行调整,对
象的出生也只是完成了对必要字段的初始化操作,其他数据要通过后面的操作来完成。例如对属性赋值,通过方法获取必要的信息等。

对象在内存中的创建过程

关于内存的分配,首先应该了解分配在哪里的问题。clr 管理内存的区域,主要有三块,分别为:

  • 线程的堆栈,用于分配值类型实例。堆栈主要由操作系统管理,而不受垃圾收集器的控制,当值类型实例所在方法结束时,其存储单位自动释放。栈的执行效率高,但存储容量有限。
  • gc 堆,用于分配小对象实例。如果引用类型对象的实例大小小于 85000 字节,实例将被分配在 gc 堆上,当有内存分配或者回收时,垃圾收集器可能会对 gc 堆进行压缩,详情见后文讲述。
  • loh(large object heap)堆,用于分配大对象实例。如果引用类型对象的实例大小不小于 85000 字节时,该实例将被分配到 loh 堆上,而 loh 堆不会被压缩,而且只在完全 gc 回收时被回收。

对于分配在堆栈上的局部变量来说,操作系统维护着一个堆栈指针来指向下一个自由空间的地址,并且堆栈的内存地址是由高位到低位向下填充。

而对于引用类型的实例分配于托管堆上,而线程栈却是对象生命周期开始的地方。对 32 位处理器来说,应用程序完成进程初始化后,clr 将在进程的可用地址空间上分配一块保留的地址空间,它是进程(每个进程可使用 4gb)中可用地址空间上的一块内存区域,但并不对应于任何物理内存,这块地址空间即是托管堆。托管堆又根据存储信息的不同划分为多个区域,其中最重要的是垃圾回收堆(gc heap)和加载堆(loader heap),gc heap 用于存储对象实例,受 gc 管理;loader heap 又分为 high-frequency heap、low-frequency heap 和 stub heap,不同的堆上又存储不同的
信息。loader heap 最重要的信息就是元数据相关的信息,也就是 type 对象,每个 type 在 loader heap 上体现为一个 method table(方法表),而 method table 中则记录了存储的元数据信息,例如基类型、静态字段、实现的接口、所有的方法等等。loader heap 不受 gc 控制,其生命周期为从创建到 appdomain 卸载。

对于本例中的对象创建,首先会在栈中声明一个指向堆中数据的指针(引用),它占用4个字节,然后调用newobj指令,搜索该类是否含有父类,如果有,则从父类开始分配内存,对于本例中,fruitphone对象所需要的内存为4字节的string引用两个,4字节的int*2。实例对象所占的字节总数还要加上对象附加成员所需的字节总数,其中附加成员包括 typehandle 和 syncblockindex,共计 8 字节(在 32 位 cpu 平台下),共计24字节。

clr 在当前 appdomain 对应的托管堆上搜索,找到一个未使用的 20 字节的连续空间,并为其分配该内存地址。事实上,gc 使用了非常高效的算法来满足该请求,nextobjptr 指针只需要向前推进 20 个字节,并清零原 nextobjptr 指针和当前 nextobjptr 指针之间的字节,
然后返回原 nextobjptr 指针地址即可,该地址正是新创建对象的托管堆地址,也就是p引用指向的实例地址。而此时的 nextobjptr 仍指向下一个新建对象的位置。注意,栈的分配是向
低地址扩展,而堆的分配是向高地址扩展。

最后,调用对象构造器,进行对象初始化操作,完成创建过程。该构造过程,又可细分为
以下几个环节:

  • 构造 fruitphone 类型的 type 对象,主要包括静态字段、方法表、实现的接口等,并将其
    分配在上文提到托管堆的 loader heap 上。
  • 初始化 p 的两个附加成员:typehandle 和 syncblockindex。将 typehandl
    e 指针指向 loader heap 上的 methodtable,clr 将根据 typehandle 来定位具体的 type;
    将 syncblockindex 指针指向 synchronization block 的内存块,用于在多线程环境下对实例
    对象的同步操作。
  • 调用 fruitphone 的构造器,进行实例字段的初始化。实例初始化时,会首先向上递归执
    行父类初始化,直到完成 system.object 类型的初始化,然后再返回执行子类的初始化,直到
    执行 fruitphone 类为止。以本例而言,初始化过程为首先执行 system.object 类,直接执行 fruitphone。最终,newobj 分配的托管堆的内存地址,被传递给 fruitphone 的 thi
    s 参数,并将其引用传给栈上声明的 p。

上述过程,基本完成了一个引用类型创建、内存分配和初始化的整个流程,然而该过程只能看作是一个简化的描述,实际的执行过程更加复杂,涉及到一系列细化的过程和操作。

(插入内存图像)

补充

静态字段的内存分配和释放,又有何不同?

静态字段也保存在方法表中,位于方法表的槽数组后,其生命周期为从创建到 appdomain
卸载。因此一个类型无论创建多少个对象,其静态字段在内存中也只有一份。静态字段只能由静
态构造函数进行初始化,静态构造函数确保在类型任何对象创建前,或者在任何静态字段或方法
被引用前执行,其详细的执行顺序请参考相关讨论。

对象的消亡

在这一部分,我们首先观察对象之死,以此反思和体味人类入世的哲学,两者相比较,也会给我们更多关于自己的启示。对象的生命周期由 gc 控制,其规则大概是这样:gc 管理所有的托管堆对象,当内存回收执行时,gc 检查托管堆中不再被使用的对象,并执行内存回收操作。不被应用程序使用的对象,指的是对象没有任何引用。关于如何回收、回收的时刻,以及遍历可回收对象的算法,是较为复杂的问题,我们将在 后续进行深度探讨。不过,这个回收的过程,同样使我们感慨。大自然就是那个看不见的 gc,造物而又终将万物回收,无法改变。我们所能做到的是,将生命的周期拓宽、延长、书写得更加精彩

reference

你必须知道的.net

github

bilibili主页

warrenryan’s blog