下面的列表总结了重要的 CLR 内存概念:
-
每个进程都有其自己单独的虚拟地址空间。 同一台计算机上的所有进程共享相同的物理内存和页文件(如果有)。
-
默认情况下,32 位计算机上的每个进程都具有 2 GB 的用户模式虚拟地址空间。
-
作为一名应用程序开发人员,你只能使用虚拟地址空间,请勿直接操控物理内存。 垃圾回收器为你分配和释放托管堆上的虚拟内存。
-
如果你编写的是本机代码,请使用 Windows 函数处理虚拟地址空间。 这些函数为你分配和释放本机堆上的虚拟内存。
-
虚拟内存有三种状态:
| 状态 | 描述 |
|---|---|
| 免费 | 该内存块没有引用关系,可用于分配。 |
| 保留 | 内存块可供你使用,不能用于任何其他分配请求。 但是,在该内存块提交之前,你无法将数据存储到其中。 |
| 已提交 | 内存块已分配给物理存储。 |
-
虚拟地址空间可能会出现碎片,这意味着地址空间中存在一些称为缺口的空闲块。 当请求虚拟内存分配时,虚拟内存管理器必须找到满足该分配请求的足够大的单个可用块。 即使有 2 GB 可用空间,2 GB 分配请求也会失败,除非所有这些可用空间都位于一个地址块中。
-
在物理内存压力(对于物理内存的需求)较低的情况下,页文件也会被使用。 首次出现物理内存压力较高的情况时,操作系统必须在物理内存中腾出空间来存储数据,并将物理内存中的部分数据备份到页文件中。 该数据仅在需要时才会分页,因此即使在物理内存压力较低的情况下,也有可能会出现分页。
实现垃圾回收主要涉及两个算法:分别是:标记压缩算法和升代回收算法
- Mark阶段:假设所有对象都可以回收,则找到不可以被回收的部分,打上标记
- Compact阶段:移动不连续内存,从heap上基址开始排列,即进行碎片内存整理
内存经过整理压缩后,栈上的地址此时并没有被更新,所以在compact过程中,还需要修复引用对象的栈上的引用地址、CPU寄存器变量和堆上的其他引用
- 我们将内存中的对象根据存在时间分为三个代:第0代,第1代,第2代。并分别给这三个代的对象分配了三段内存用于存储对象,比如第0代分配5m,第1代分配15m,第2代分配2g
- 如果一个对象第一次进入内存(托管堆)中,不超过一定大小(一般指小于85000个字节), 那么它就是第0代,并进入给第0代分配的内存空间中,当第0代内存已经被占满,后续有新的对象需要引入,此时触发第0代GC,清理不需要引用的对象,如果第0代经过一轮GC后,还有对象存在,则升为第1代,并将它存放到第1代分配的内存中,一样,如果第1代的内存占满,触发第1代的GC,还有对象存在,则升级为第2代,分配到第2代的内存中
升代回收算法是一个统计学的方法,我们通常给第0代和第1代分配较少的内存空间,每次内从空间被占满而需要新向托管堆中推入新的对象时,会触发GC,从而确保第0代和第1代可以及时清理不需要的垃圾
- A 位 (Addressability):1位,表示这个地址能不能访问。
- V 位 (Validity):8位,表示这个字节里的8个比特,哪些是已初始化的。
- 全是0:要么全是已经初始化的数据
- 全是1:要么全是未初始化的数据
| 状态 | 状态名 | 状态描述 |
|---|---|---|
| 00 | NOACCESS | 地址不可访问。既然不可访问,其V位状态无意义。 |
| 01 | UNDEFINED | 地址可访问,但值完全未定义。即A=1,8个V位全为1。 |
| 10 | DEFINED | 地址可访问,且值完全定义。即A=1,8个V位全为0。 |
| 11 | PARTDEFINED | 地址可访问,但值部分定义。即A=1,8个V位不全相同。需查次级表。 |
注意:这里状态机的两位数据,它的每一位并不代表A位或者V位,只是用与描述四种状态,比如,我们将地址可访问 (A=1),但值全未定义 (8个V位全为1)的情况下定义位01状态
我们使用影子内存有一个核心矛盾:需要为4GB的内存空间快速提供一个对应的影子内存,但是为了保证不过度消耗资源,绝对不能使影子内存的占用超过4GB。所以我们参考操作系统的两级页表来实现,这两个表叫做:Primary Map(一级表)和Secondary Map(二级表)
- 我们定义页大小:64K。(这里的页大小是只影子内存的管理粒度,不是CPU世界的页大小)
- 高16位:作为一级表的索引,表示有2^16 = 65536个条目。
- 低16位:作为二级表内的偏移,一个二级表正好覆盖2^16 = 65536个字节,总共64Kb
二级表是一个由一级表映射出来的,管理范围覆盖本地内存64K的影子内存,我们使用二级表来表示本地内存的状态。因为我们使用2bit的大小来表示,所以本地64K的内存空间,映射到二级表就需要64K x 2bit = 16K(一个bit为8个字节,1K为1024个bit),也就是说,对于64K的本地内存,我们只需要16K的影子内存来表示。
总结起来就是:一级表(前16位)用来映射(说简单点就是指向了二级表)出二级表(后16位),而这个二级表,就是影子内存,用来存储本地内存通过一级表映射出来的状态,通过这种方法,我们4GB的本地内存,理论上就只需要1GB的影子内存,整体的比例是4:1.
截止到目前为止,我们会发现一个和前文有巨大矛盾的地方,就是:我们不使用二级表,单纯位4GB提供的影子空间是4GB x 2bit = 1GB,不也是4:1吗?使用二级表也是4:1分配,为何二级表会更省内存呢?请继续往下看
前文提到了,影子内存用来存储本地内存的状态,而工程师将其简化成了2位数的状态机来表示,总共有四种状态。聪明的你一定想到了,那么多数据,却只有四种状态,而且我们用一级表作为指向二级表的指针,那假如有很多本地内存都都是同一个状态,那么通过一级表指向二级表的时候,都指向同一个状态不就行了?
- 一个全00的模板:代表地址不可访问,状态无意义
- 一个全10的模板:代表地址可访问,状态已定义
那么所有未映射的区域,他的指针都指向同一个00的影子内存,程序加载时,已经有的代码段和数据段都指向同一个10的影子内存,这样就节省了大量的内存消耗,而当程序需要给一个原本指向模板的地址写入新的状态时,他的状态只需要从00编程01,此时表示这是一个可访问的,可读写的内存段。实现这些操作的步骤就是:
- 1.我们需要写入新的数据
- 2.Memcheck发现此时一级表上的指针指向一个只读的共享模板,这时指向的影子内存是不可被写入的
- 3.Memcheck立刻从影子内存上分配一段16K的新的可读写的私有二级表,此时表中是空的,没有保存任何状态
- 4.在分配新的空间后,Memcheck再将原模版(00)复制一份到刚分配的可读写的表上,然后再将此表的状态改成01,表示此表现在可读写