• 匹夫细说C#:不是“栈类型”的值类型,从生命周期聊存储位置


    0x00 前言:

    匹夫在日常和别人交流的时候,常常会发现一旦讨论涉及到“类型”,话题的热度就会立马升温,因为很多似是而非、或者片面的概念常常被人们当做是全面和正确的答案。加之最近在园子看到有人翻译的《C#堆vs栈》系列,觉得也挺有趣,挺不错的,所以匹夫今天也想从存储位置的角度聊聊所谓的值类型,同时也想反驳一下单纯的把值类型当成总是存储在栈上的观点。

    0x01 堆vs栈?

    很多看官在想到存储空间的分配的时候,往往会想到有一个东西叫内存,当然如果知识更牢靠的朋友能进一步知道还有所谓的堆和栈的概念。不错,堆和栈应该是一谈到存储空间时,我们第一时间想到的。但是还有没有什么遗漏呢?的确有遗漏,如果你没有考虑到寄存器的话。这里匹夫先把寄存器提出来,是为了下面尾首呼应,关于寄存器的话题先按下不表。那抛开寄存器,又回到了我们看似熟悉的堆和栈的话题上。那就分别聊聊吧。

    其实我更喜欢叫它托管堆,不过为了简便,匹夫还是一律使用堆来代替了(要明白托管堆和堆不是一个东西)。为什么先聊堆呢?因为下面聊到栈的时候你会发现原来它们有很多相似的地方,不过栈做的更讲究。堆的实现细节有很多(比如GC),所以避重就轻,我们就聊聊它的设计思路,而不去考虑它是如何实现具体细节的。

    假设,我们有很大一块内存是为了引用类型的实例准备的。同时,由于可能有的实例还“活着”,换句话说就是还在这块内存的某个地方,但是有的实例却死了,换言之之前存放这个实例的内存已经解放了,所以这块内存上以“是否存放有引用类型的实例”为标准来看,是不连续的,或者说存在很多“洞”。而这些“洞”,才是我们可以用来为新实例分配的空间。

    所以一个思路就是造一个链表,用来存放这些不连续的“洞”,但是每一次分配空间时,都要去这个链表里面检查以寻找合适的“洞”,这显然是一笔额外的开销(所以pass掉)。

    所以,我们显然更希望存放有类实例的内存在一起,空闲的内存在一起(顶端)。只有在这个前提下,我们才能放心大胆的给新的类实例分配存储空间,同时内存分配实现起来也十分容易,容易到什么地步呢?你只需要一个指针的移动就可以实现内存的分配。

    为了实现这个目的,下面就引入了我们的常说的GC。(注:当然要具体聊聊GC,可能需要查阅更多的资料和写更多的篇幅,而且可能更加索然无味,所以这里匹夫只是简单的引入,如果有错误也欢迎各位指出。)

    GC的行为过程可以分为三个阶段,各位可能也都十分熟悉:

    1. 标记阶段:首先堆上所有的实例在默认状态下都假设是“死的”,但是CLR显然知道哪些实例是活的,这样在GC开始的时候,会将这些活着的实例标记为活着。
    2. 清理阶段:没有被标记的实例释放空间
    3. 压缩阶段:堆重新组织,使存放活着的类实例的空间连在一起,已经释放掉的空闲的空间连在一起。

    当然,GC的开销还是比较大的,所以为了对实例区别对待,以提高效率,GC还有一个“代”的概念。简单的说,就是按照实例的存活时间,将实例划归不同的部分。目的就是针对不同的存活时间,GC有不同的执行频率。

    所以可以看到堆的开销很大一部分是由于有GC的存在,而GC的存在本身又是为了使堆分配新的空间更加容易。

     栈

    栈和堆很像,假设你同样有一块空间用来存储数据。那我们需要增加什么样的限定,来区分堆和栈呢?

    还记得上面介绍堆时候匹夫说过的话吗?“我们显然更希望存放有类实例的内存在一起,空闲的内存在一起(顶端)”。而栈之所以是栈,就是因为栈底部存储的数据总是会比顶部数据活的更长,也就是说,栈中的空间是有序的。顶部的数据总是先于底部的数据先死掉,也正是因为如此,栈中没有堆中存在的“洞”,存储空间的连续就意味着我们无需GC来对空间进行压缩。(图片来自网络)

    也正是因为我们总是知道栈顶是空的,而栈顶往下都是存活的数据,所以我们在分配新的数据时,只需要移动指针即可。想起了什么吗?不错,栈无需GC就实现了堆所追求的分配新空间时的最佳形式。

    还有什么好处呢?对,我们同样只需要移动指针就能重新分配栈的空间。由于完全只是指针的移动,所以和使用GC的堆相比(GC的标记,清理,压缩,以及代的概念的引入),时间更少。

    所以,如果只考虑在内存上分配存储空间,堆和栈其实很相似。不同之处主要体现在GC的开销上。

    0x02 谁“能”使用栈?

    显然,使用栈的效率要高于使用堆。但为什么不都去使用栈呢?因为匹夫之前说过的,栈之所是栈的原因,就是因为栈底部存储的数据总是会比顶部数据活的更长,只有能保证这个条件,我们才能使用栈。

    那么谁能够保证呢?在回答这个问题之前,匹夫先提一个新的问题。

    值(value)的第三种形式

    如果匹夫问你,C#中的值有几种形式呢?一定逃不掉的是值类型的实例,引用类型的实例。

    但你有没有发现一个问题呢?你真的直接操作过引用类型的实例吗?

    为什么这么问呢?

    首先要提个问题:

    TypeA a = new TypeA();

    这里的a是什么呢?

    首先,它不是值类型的实例。

    其次,看着有点像是TypeA的实例啊?

    错,你可以说它指向一个TypeA的实例,但不能说它就是TypeA的实例。

    不错,a既不是值类型也不是引用类型的实例,而是我们常说但也经常忽视的“引用”(reference)了。我们都是通过“引用”去操作某个引用类型的实例的。

    所以,值有三种形式:

    1. 值类型的实例
    2. 引用类型的实例
    3. 引用

    但是,这里就有了一个很有趣的问题。我们都知道,引用类型的实例的空间分配在堆上。但是上例中a的值的空间该如何分配呢?它是一个引用,而非引用类型的实例。它的值指向一块分配在堆上的引用类型实例。但是这个值自己难道不需要存储空间吗?

    所以我们应该明确,所有的值都会被分配给相应的存储空间。而以“引用”这种形式出现的值,关联着另外一块存储空间。

    空间的生命周期

    既然匹夫已经提了一个问题了,那么就再提一个问题好了。既然上文多处提到了所谓的生命时间或者说生命周期,那么“空间的生命周期”究竟应该如何定义?

    那么匹夫就先下个一个定义:存储空间的生命周期指的是这块空间中的内容的有效期。

    生命周期有了,但是显然还需要一个基准,来作为衡量生命周期长短的标准吧?

    我们知道,方法是过程抽象的一种表现形式。所以,我们再定义一个以方法执行时间为标准的称呼“活动周期”:从该方法开始执行到正常返回或抛出异常所消耗的时间。

    而在这个方法的方法体内的变量,显然要获取其对应的存储空间。如果变量要求的空间的生命周期要比该方法的活动周期还要长,那么就被标记为“长寿”空间,否则就是“短寿”空间

    M$的空间分配的策略

    OK,回答完匹夫上面提到的2个问题,再结合上文匹夫提到过存储空间类型,我们来看看微软的处理。

    1. 三种存储类型:栈,堆,寄存器
    2. “长寿”空间永远是堆空间。
    3. “短寿”空间永远是栈空间或寄存器。
    4. 如果运行时很难判断所需的存储空间究竟是“长寿”的还是“短寿”的,为了避免错误,一律当做“长寿”空间处理。例如,引用类型的实例(不是引用本身哦)需要的空间永远被当做“长寿”的。所以引用类型实例分配在堆上。

    0x03 结论

    OK,看完了微软的处理方式之后,匹夫再给各位总结一下,顺带回答一下0x02节标题上的问题。

    首先,我们可以看到在空间分配这个问题上,值类型实例和引用(不是引用类型实例哦)并无本质区别。也就是说,它们可以被分配在栈上、寄存器中以及堆上,这和它们是什么类型无关,只和它们需要的空间的生命周期是“长寿”还是“短寿”有关。

    其次,某天在某技术群中有人提问过lamda表达式中的值类型实例应该如何分配。在此匹夫也回答一下这个问题,数组中的元素、引用类型的字段、迭代器块中的局部变量、闭包情况下匿名函数(lamda)中的局部变量所需要的空间生命周期都要长于方法的活动周期,即便是短于方法的活动周期,但是由于上述第4点,即对运行时来说难以判断其生命周期的长短,故都按“长寿”空间计。所以都会被分配到堆上

    最后,回答一下本节题目中的问题。究竟谁能使用栈呢?

    其实上文都已经回答过了,不过这里匹夫还是举个例子作答吧:一般方法中的值类型局部变量或临时变量。

    原因如下:

    1. 生命周期符合栈底部存储的数据总是会比顶部数据活的更长
    2. 值类型实例的值就是它自己,所以它们的存储位置就是它们所在的位置。不会有引用指向它们。
    3. 同2,由于值类型的实例的值就是它自己,所以它不引用别人,不必关系引用的实例的生命周期。
    4. 说到底,还是和它的空间生命周期是长寿还是短寿有关

    所以,单纯的把值类型当成总是存储在栈上是不准确的。而值类型之所叫“值类型”,其实和它的语义(semantic)有关,也就是说基于值类型的变量直接包含值(将一个值类型变量赋给另一个值类型变量时,将复制其包含的值。这与引用类型变量的赋值不同,引用类型变量的赋值只复制对对象的引用,不复制对象本身)。而和它的存储空间分配策略无关,否则,为什么不叫“栈类型”和“堆类型”这样的名称呢?

    0x04 后记补充

    当然,从园友的回复来看,对迭代器块中的局部变量、闭包情况下匿名函数中的局部变量也分配在堆上比较有异议。所以匹夫就写个小例程,同时从更底层的CIL代码的角度来看看这个问题。

    using System;                                                                                                                                
    using System.Collections.Generic;
    class Program
    {
        static void Main()
        {   
        }   
       //测试1
        static IEnumerable<int> Test1() {
            int i = 0;
            yield return i;
        }   
       //测试2
        static void Test2() {
            int i = 0;
            Action act = delegate {Console.WriteLine(i);};
        }   
    }

    之后,我们将这个小例子的源代码编译成CIL的形式,再来看看Test1和Test2的CIL实现。

    Test1:

    //迭代器部分Test1
    .field  assembly  int32 '<i>__0' //声明
    .....
    IL_0022:  ldc.i4.0  //取常数0压栈
    IL_0023:  stfld int32 Program/'<Foo>c__Iterator0'::'<i>__0' //stfld给字段'<i>__0' 赋值
    ...
    IL_002a:  ldfld int32 Program/'<Foo>c__Iterator0'::'<i>__0'//从字段中'<i>__0'取值压栈
    IL_002f:  stfld int32 Program/'<Foo>c__Iterator0'::$current//赋值给$current

    Test2:

    //匿名函数部分Test2
    .field  assembly  int32 i //声明字段
    ....
    IL_0007:  ldc.i4.0  //常数0压栈
    IL_0008:  stfld int32 Program/'<Test2>c__AnonStorey1'::i  //赋值给字段i
    ....
    IL_0001:  ldfld int32 Program/'<Test2>c__AnonStorey1'::i //字段i中值压栈
    IL_0006:  call void class [mscorlib]System.Console::WriteLine(int32) //调用输出

    到此,不明真相的群众可能又要说了。匹夫你的注释里面写的不都是栈栈栈栈吗?那你还说是在堆上?你又骗人?

    当然没骗你,因为CIL的指令的确是运行在栈上的,匹夫之前的CIL系列也说过这一点。但是,可不要搞混指令和数据啊。

    所以,可以看到闭包情况下的匿名函数和迭代器块将它们的局部变量做成了类的字段,从而存储在了堆上。

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    码字不易。求个推荐

  • 相关阅读:
    ###STL学习--标准模板库
    打包C#程序
    ###学习《Effective C++》
    [ZJOI2007]棋盘制作
    [NOI2012]美食节
    [SCOI2012]奇怪的游戏
    5120: [2017国家集训队测试]无限之环
    序列取数
    1028: [JSOI2007]麻将
    1011: [HNOI2008]遥远的行星
  • 原文地址:https://www.cnblogs.com/murongxiaopifu/p/4419040.html
Copyright © 2020-2023  润新知