URLhttps://learnscript.net/zh/programming/data/stack-and-heap/
    复制链接转到说明  示例

    栈,堆,指针介绍

    我被代码海扁署名-非商业-禁演绎
    阅读 9:26·字数 2832·发布 

    前提

    阅读本节的前提是对值,数据类型,变量等概念有所掌握,你可以查看值,数据类型介绍变量,常量介绍,变量与常量的区别两节来了解他们。

    请注意,本节讲述的并非数据结构中的栈和堆。

    简介

    栈(Stack)和堆(Heap)是两种不同的存储空间分配方案,其运行机制在不同的操作系统和语言运行环境中有所不同。

    栈(Stack)是一种高效连续的数据存储空间分配方案,数据个体之间紧密相连,当每个数据个体的大小和排列顺序已知时,将可以算出所有个体在栈中的相对地址。在程序的运行过程中,根据栈的实际地址和数据个体的相对地址,访问数据将是简便快捷的。

    除了上述特征,栈还遵循后进先出的原则,即最后进栈的数据会最先出栈,这种设计有利于嵌套调用的场景,当一个新的场景开始时,他需要的数据会进栈,当新场景结束时,他需要的数据会出栈,这相当于还原了上一个场景所需的数据,如果上一个场景存在的话。

    栈和变量

    栈是一个底层概念,与代码中书写的变量没有直接关联,但变量对应的值是应该保存在栈中的,以获得较高的访问速度,要完成这一目标,值或者值的数据类型所占用的存储空间大小应该是已知和确定的,这是非常重要的一点。

    占用的存储空间大小不确定

    并非所有存储空间大小都是可以确定的,原因可能是以下两种,变量能够接受的值的实际数据类型不确定,比如,某一个变量的类型声明为类Plant,但其被赋予的值却可能是类Tree或类Flower的实例,或者,变量的类型本身占用的空间不确定,比如,变量的类型是长度随时可变的数组。

    对于空间大小的不确定性,栈是难于处理的,比如,变量被赋予了比之前占用更多空间的新值,如何拓展空间来存储该新值?无论采取什么方案,有两种结果是极可能出现的,需要移动栈内的部分数据,导致写入变量的效率降低,一个数据的存储空间被分为多块,导致读取变量的效率降低。这些情况并不符合栈的设计初衷,他们应该使用另一套方案,那就是堆。

    栈和函数

    基于后进先出的特征,函数与栈是契合的,当出现一个新的函数调用时,栈会为该函数分配空间以读写相关数据,这些数据可能是参数的值,局部变量的值,或返回值。当函数结束时,栈会清除之前分配的空间,并还原函数被调用前的状况,以让函数调用方恢复执行。

    栈和线程

    每一个线程都维护了自己的栈,并且不与其他线程共享,因为你不能接受一个线程正在使用栈中的数据,而另一个线程却要求数据进栈或出栈,当所有线程共享同一个栈时,这会是经常性的问题。

    堆(Heap)是一种非连续的数据存储空间分配方案,每个数据个体不必是紧密相连的,他们之间可能存在一些闲置空间。因此,对于分配新的空间,堆的效率不及栈。

    堆碎片

    当堆中的数据被清除后,其对应的存储空间将处于闲置状态,并可用于新的分配请求,如果其大小小于请求所需的大小,那么这些闲置空间可能会一直处于闲置状态,成为堆中的碎片,无法被利用。

    也许你已经想到了,通过简单的合并相邻的闲置空间,堆碎片的问题会得到缓解,但这一般不会涉及移动堆内的数据,因为会付出较大成本并具有一定风险,当数据的位置改变后,你需要保证该数据的使用方依然可以正确访问他,这样的做法虽然在理论上可行,但现实依然要考虑效率和空间之间的平衡。

    堆和引用

    鉴于堆的空间分配方式,堆中数据的地址是无法被预知的,当某个数据对应的空间被分配后,该空间的地址或访问令牌应该被保留,以便再次访问,这些地址或访问令牌即是对堆数据的引用。

    对于变量来说,如果他的值被存放堆中,那么对应的引用应该存放在栈中,他是一个可以想到且合理的位置。读取该变量的过程将是,首先获取其在栈中对应的数据,也就是一个引用,然后使用引用获取堆中的数据,也就是变量希望表示的实际值。

    什么是指针?

    指针(Pointer)是一种简单但并非唯一的引用实现,也由于他的简单,并没有太多规则来约束其行为,一些错误的使用可能导致程序或系统崩溃,指针访问了不应该访问的数据。这里有两个与指针有关的概念,取值和取地址。取值是指获取指针对应的实际值,取地址是指根据实际值产生一个对应的指针。

    什么是值类型和引用类型?

    如果某种数据类型的值应该存储在栈中,那么该数据类型为值类型,如果值应该存储在堆中,则该数据类型为引用类型。值类型变量在栈中对应的数据是实际值,引用类型变量在栈中对应的数据是实际值的引用。

    什么是值传递和引用传递?

    值传递是一种默认行为,他将变量在栈中的数据赋予另一个变量,作为其栈中的数据,无论这些数据是实际值还是引用。需要指出,值传递中的“值”一词不应理解为实际值,虽然他有可能是。

    引用传递类似于别名,变量们看上去好像共享了栈中的同一个存储空间,不过,不要认为这样的事情一定是现实,作为别名的变量可能在自己的栈存储空间中存放了一个地址,以指向栈中的另一个存储空间,也许在具体实现上存在不同,但最终效果是读写任意一个变量,其余的变量均会受到影响。

    在下面的 C# 代码中,将变量num1以引用传递的方式赋值给变量num2,然后将num2赋值为200,最后的输出结果显示变量num1的值也是200

    *.cs
    int num1 = 100;
    // 以引用传递的方式,将 num1 赋值给 num2
    ref int num2 = ref num1;
    // 对 num2 赋值,等同于对 num1 赋值
    num2 = 200;
    Console.WriteLine($"num1={num1}");
    num1=200

    堆和进程

    与栈不同,堆通常在进程中维护,并在所有的线程中共享。

    堆垃圾回收

    通过维护数据的引用计数,可以知道堆中的某个数据有多少引用,这些引用可能来自同一个或不同的线程。至于引用计数为零的数据,将被垃圾回收机制以某种策略清理,由于垃圾回收可能是不定时的,不再被使用的数据有机会存活更长的时间。

    装箱,取消装箱

    装箱是指将栈中存储的值,转换为一个等价的存储在堆中的值。取消装箱则是指将堆中存储的值,转换为一个等价的存储在栈中的值。

    为何有装箱和取消装箱?

    对于将所有数据类型都视为类的语言,如果完全依照类的特点,那么所有的变量都不能是值类型变量,因为在变量声明为某种类型后,其存储的实际值却可能是另外的某种类型,这导致栈无法确定应该为变量分配多大的存储空间。

    这本身是一个矛盾的问题,既希望将所有数据类型都视为类,又希望将某些值存放在栈中,要实现这一目标,某些数据类型所需的存储空间必须是确定的,变量在声明为这些数据类型后,其接受的实际值也只能是这些数据类型,比如,.NET 中的System.Int32

    以上的这些情况,导致某些值类型变量和引用类型变量的相互赋值,是被允许且看上去正常的,因为他们派生自同一个类。如果这种赋值真的发生,那么必然需要栈与堆中的值的等价转换操作。

    当然,我们不能将装箱和取消装箱的原因都归于类,因为每种语言都可以有自己的规则。

    在下面的 C# 代码中,num是值类型变量,将其赋值给引用类型变量something需要装箱,将something赋值给值类型变量age需要取消装箱。

    *.cs
    // num 是值类型变量
    int num = 123;
    // something 是引用类型变量,因此需要装箱
    object something = num;
    // age 是值类型变量,因此需要取消装箱
    int age = (int)something;

    源码

    stack_and_heap.cs·codebeatme/programming·GitHub