内存堆栈结构

发布时间 2023-12-13 20:41:26作者: mc宇少

参考:

秒懂 栈内存和堆内存(深入底层) (xjx100.cn)

堆栈与堆(Stack vs Heap):有什么区别?图文并茂拆解代码解析! - 知乎 (zhihu.com)

学习CLR via C#(二) - 类型基础-CSDN博客

我们都知道值类型存在“栈”中,引用类型存在“堆”中。这篇文章深入讨论下内存的栈和堆的结构。

1.前言

1.指针

人们通常把“内存地址”形象的称作“指针”。栈内存和堆内存都是通过指针找到值(基本数据、对象、函数体等内容)。CPU为了管理内存而建立的虚拟地址空间指针页表,map或表结构),将虚拟地址映射到物理地址。

指针页表:指针变量(后面会提到)到物理地址的映射。 管理内存的Map表由操作系统按一定规则创建,不同的应用程序管理自己独占的内存空间。

2.变量

变量是栈内存指针的别名

声明变量是在指针页表里建立变量信息。而赋值才是真正的开辟内存空间。但是为了节省内存,会现在内存中查找有没有相同的值(栈的内存共享),如果有就把找到的内存地址更新到对应的map页表中,如果没有才会开辟内存空间。

所以变量名与值数据是分开存放的。保存变量名的内存地址称为指针变量

我们读取或修改变量就是CPU和内存等计算机硬件根据“执行上下文”对内存进行寻址和修改的过程。

3.垃圾回收

当变量赋值为null时,也就是将指针页表中的值地址指向null,表示该变量可以被释放。是否释放或销毁该变量,需要看作用域中是否有其他地方引用了该变量。

垃圾回收机制每隔一段时间自动扫描指针页表,检索所有变量,判断被引用的次数,同时在作用域树上查找是否引用了该变量。如果引用标记次数为0,或者通过扫描未找到变量的引用。那该变量就会被释放,即从指针页表中删除。

4.内存溢出

内存溢出是指程序在申请内存的时候,没有足够的空间供其使用。比如内存用完了或者申请了一个int,但给他存了一个double才能存下的数

什么情况会导致这个:

1.栈帧过多导致栈内存溢出。一个函数内调用另一个函数,不断重复这个过程,每次调用都会分配一个栈帧,导致栈爆掉。

2.栈帧过大导致内存溢出,比如int去存double的值。

5.内存泄漏

内存泄露是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。比如,栈内存指向堆内存的地址丢失,导致无法及时回收。

2.内存分配

一般来说,内存可以分为以下几个部分:

1.全局段:存储全局变量和静态变量,这些变量的生命周期等于程序执行的整个持续时间。

2.代码段:包含组成我们程序的实际机器代码或指令,包括函数和方法。

3.堆栈段:用于管理局部变量、函数参数和控制信息(例如返回地址)

4.堆段:提供了一个灵活的区域来存储大型数据结构和具有生命周期的对象,堆内存可以在程序执行期间分配或释放

 2.栈内存(有序连续存储)

“栈”具有线程和“先进后出”的特点,也就是每个栈桢一般会保存下一个栈桢的地址,指向next节点(即指向下一个栈桢),从而手牵手形成类似队列的链式结构。同时先入栈的会先执行,后入栈的会先弹出(执行完销毁)。

特点:

1.数据一执行完毕,变量会立即释放,节约内存空间(函数运行完其申请的栈会全部释放(函数申请的栈是在总栈的最上面))

2.由于有序连续,存取速度快。

3.存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。

 3.堆内存(无序不连续)

堆内存允许我们在程序执行期间随时分配和释放内存。它非常适合存储大刑数据结构或大小事先未知的对象。

特点:

1.堆内存大小可以在程序执行过程中发生变化

2.在堆中分配和释放内存速度较慢,因为它涉及寻找合适的内存帧和处理碎片

3.存储在堆内存中的数据将一直保留在那里,直到我们手动释放它或程序结束

4.语言没有自动管理的话,需要手动管理

4.函数调用(C#)

调用函数时会创建称为堆栈帧的内存块。堆栈帧存储与局部变量、参数和函数的返回地址相关的信息。该内存是在堆栈段上创建的。

例子:

internal class Employee {
    public               int32         GetYearsEmployed()       { ... }
    public    virtual    String        GenProgressReport()      { ... }
    public    static     Employee      Lookup(String name)      { ... }    
}
internal sealed class Manager : Employee { 
    public    override   String         GenProgressReport()    { ... }
}    

1.线程栈就是前面提到的栈帧,走到M3函数的时候建好线程栈。

2.当JIT编译器将M3的IL代码转换成本地CPU指令时,会注意到M3的内部引用的所有类型:Employee、Int32、Manager以及String(因为"Joe")。假设走进M3之前string和Int32已经创建了类型对象。这里只加载Employee和Manager

 

 

3.在线程栈分配局部变量,初始化为null或0

 4.在托管堆创建Manager对象实例,使其类型对象指针指向对应的类型对象,并调用该类的构造器。创建完成后将其在堆上的地址返回给e

 

 5.调用Employee的静态方法Lookup。调用一个静态方法时,CLR会定位到与定义静态方法的类型对应的类型对象。然后,JIT再在其方法表中找到方法并编译。构造一个新的Manager对象并将地址赋给e。这时原来的Manager会被GC回收掉

 6.调用非虚实例方法GetYearsEmployed。调用一个非虚实例方法时,JIT编译器会找到e对应的类型对象的方法并执行,如果没有找到,就依次回溯其类型对象的基类型的类型对象,之所以能这样回溯,是因为每个类型对象都有一个字段引用了他的基类型

 7.调用虚实例方法GenProgressReport。调用一个虚实例方法时(虚表todo)

8类型对象的类型对象指针是指向Type类型对象的。