栈与堆

程序运行时数据需要存入内存,数据占用空间、存储位置由数据类型决定;运行程序会使用两块内存区域存放数据:栈 (Stack)堆 (Heap)

栈(Stack)

栈是一段内存数组,遵循后进先出 (LIFO) 数据结构,存储三类数据:

  • 部分类型变量的值
  • 程序当前执行上下文
  • 传入方法的参数
栈就类似存硬币的硬币筒,只能从一端进出。

栈的内存操作由系统自动管理,开发者无需手动操作;理解栈的底层逻辑,能更好读懂程序运行逻辑与 C# 官方文档。

栈的核心特性

  • 仅能在栈顶新增、删除数据
  • 向栈顶存入数据:入栈(Push)
  • 从栈顶取出数据:出栈(Pop)

栈内存特点

  • 内存分配释放速度极快,连续内存空间
  • 生命周期绑定方法调用:方法执行完毕,栈帧自动销毁
  • 存储:值类型变量、方法参数、局部变量、执行上下文

为了更好理解堆,我们看一个简单的例子

class Program
{
    static void Main(string[] args)
    {
        int a = 10;
        int b = 20;
        int sum = Add(a, b);
    }

    static int Add(int x, int y)
    {
        return x + y;
    }
}
Code language: C# (cs)

这个例子,很简单,从程序入口Main函数第一行代码开始,创建了一个变量a int类型的,第二行创建了创建了b变量,然后第三行调用Add函数,传入a和b的值,然后进入函数里面。x 的值是a的值,y的值是b的值,然后函数里面返回x+y的结果。然后把结果返回调用的地方 ,结果是30,Add函数执行结束,x,y会出栈,然后把结果复制给sum。sum的值就变成了30,然后main函数结束,程序退出。整个过程是这样,下面我们逐步分析。它们是如何在栈里面完整这个过程。

程序执行流程和入栈出栈的过程

创建a变量的时候,对于内置的基本类型,是直接在栈中创建,入栈
执行第二句代码,创建第二个int变量b,值是20,先进来的在底部(栈底),后进来的在顶部(栈顶)
第三句代码,先执行等号右侧的Add(a,b) 这句代码是调用Add函数,它会把a的值传给x,b的值传给y,注意是把值传递过去而已,a还是原来的a,x还是x,是两个不同的变量。
这一步,只是计算x+y 然后把结果返回去,不需要在栈中存储,x+y的结果是30,这个30会返回给调用的地方,也就是int sum = 30; 类似这样,但是在执行完return x+y; Add函数要准备结束了,这时候就需要出栈了,一个函数一旦执行完毕,在函数内部创建的局部变量,需要出栈,出栈就是把这个变量原来的内存区域销毁,清空,留给后面来使用。
在栈中最顶部的变量的位置叫栈顶(Top),出栈后栈顶会往下移动。
Add函数执行完毕后,x和y属于Add内部的局部变量(函数参数),需要出栈,销毁变量x y先后出栈,腾空栈中的可用空间,保证系统流畅。
出栈后,x y 就没了。Add函数返回的结果,30,就到达调用的地方,准备下一步操作,也就是Int sum = 30;类似这样的操作。
执行int sum = 30; 又开始入栈操作了。在栈顶入栈sum:30,执行完这个语句后,Main函数就准备结束了,程序要退出了,下一步 sum b a这几个属于Main函数的局部变量就要准备销毁了(出栈Pop)
出栈的过程是从顶部出去的,栈本身是一个只有一个入口和出口的死胡同,进来和出去都只能从一端,所以进来后,出去的时候,也需要从上面出去,所以Main函数中的三个变量销毁是从下到上,先sum 后b,然后a。顺序和创建的时候反过来
b变量出栈,销毁b变量,栈顶top会自动往下移动。
现在所有的变量都出栈了,最后一个a变量也出去了,其实严格上来说,我们的Main函数还有一个局部变量的,就是Main(string[] args),这里有一个字符串数组,是用来接收,调用控制台的时候传入的命令参数的。这个args是一个数组,它的存储有点特别,分开两个位置存储的,是我们后面学习的堆有关。为了演示,我们这里就不讨论这个args。

读者记住一点,函数执行的时候,变量的创建是从上往下,所以创建的时候,先a -> b-> ,然后进入到Add函数,然后就 x ->y 然后Add结束,x y销毁,出栈,然后到sum入栈,之后Main结束,sum销毁,b销毁 a销毁,顺序是倒过来的。


堆(The Heap)

堆是一块内存区域,用于分配内存块存放特定数据对象;和栈不同,堆的数据可任意顺序存入、删除。

程序无法手动删除堆内对象,由 CLR(公共语言运行时)的垃圾回收器 GC自动清理无引用、无法访问的孤立堆对象;这省去了其他语言手动释放内存的易错操作。

堆就好比一张书桌,很大一块,你可用在上面随意放东西。而我们前面的栈,它就好比一个硬币筒,只能从一端进出。

堆好比一张书桌,它是一大块区域,可用随便在上面放东西,堆中放的东西,不需要程序员删除,是交给一个叫GC(垃圾回收器)的东西负责管理,一旦一个对象,没有地方使用它,GC会不定时去销毁它们。这样的好处的,大大减轻程序员的负担,也不担心没有释放某些资源导致内存占满程序崩溃。以往C++的程序,需要程序员来释放内存的。

GC 回收流程

  1. 程序在堆中创建 3 个对象,持有引用
  2. 程序不再使用其中一个对象,失去引用
  3. GC 检测到无引用孤立对象,释放其占用内存
  4. 回收完成,释放的内存可分配给新对象使用

堆内存特点

  • 内存不连续,分配速度慢于栈
  • 生命周期不受方法限制,由 GC 统一管理
  • 存储:引用类型实例(class、数组、委托等),栈仅保存对象引用地址

为了更好演示,看一个例子,我们前面说的局部基本类型的变量是存放在栈中,如果是用户自定义的类型,通常是存放在堆的。下面看例子:

class Program
{
    static void Main(string[] args)
    {
        int a = 10;
        int b = 20;
        Student tom = new Student("Tom", 12);
        Student lucy = new Student("Lucy", 12);
    }

}

class Student
{
    //成员变量
    private string name;

    private int age;

    //构造函数,后面会讲解
    public Student(string name, int age)
    {
        this.name = name;
        this.age = age;
    }

    //成员函数
    public void DisplayInfo()
    {
        Console.WriteLine($"Name: {name}, Age: {age}");
    }
}   
Code language: C# (cs)

下面是Main函数中,四个变量a,b,tom,lucy 在内存中的存储示意图。和前面的栈的学习一样,从上往下,执行,a 入栈,然后b入栈,a和b的值 是直接存储在栈里面的。

基本类型的变量,直接存储在栈中
对于Student 自定义的类型,数据是存储在堆的,栈中存储的是堆中存储区域的内存地址
例如上面 tom的数据是存储在堆中的0001区域,而栈中会存储00001的地址。存储地址是方便后面通过地址找到数据。

当Main函数执行完毕后,栈中的lucy:01010出栈,销毁,但是销毁的是01010这个地址,并不是真正在堆中的数据。这个时候,堆中的数据就变成了没有栈中的变量记录它的地址。也就是变成了这样。

这个时候堆中的Lucy 01010这个区域的数据,就变成了没有任何栈中的变量保存它的地址了,它变成了孤独的,每人认领的东西了,那么垃圾回收器GC,就可能在某个时间,把它销毁了。
堆中的Lucy对象的数据,没有地方引用它了。因为栈里面记录它地址的引用出栈了。这时候,堆中的Lucy变成没人认领的东西,那么垃圾回收器GC会在某个时间销毁它。清除内存,让程序在后面继续使用这块内存。只要有地方引用它,那么就GC就不会销毁

内存区域

堆栈是内存的区域,是属于软件层面的。不是物理硬件层面的。

栈、堆都属于软件层面的内存逻辑划分,不是硬件物理分区,硬件本身没有专门标记 “栈区 / 堆区” 的硬件模块。

物理内存条 RAM,只有统一的内存芯片,只有连续的物理地址,没有栈、堆的区分标识

操作系统给每个运行程序分配一块独立虚拟内存空间,把这块虚拟内存预先划分成 4 大逻辑段:

代码段:存放程序编译后的指令

全局静态段:静态变量、常量

栈 (Stack) 段:程序主线程、子线程各自独立拥有一块栈内存

堆 (Heap) 段:一块大的共享内存池,动态分配

这里的栈、堆,只是操作系统给进程划分的逻辑区域,硬件无隔离。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注