你的位置:首页 > ASP.net教程

[ASP.net教程]匹夫细说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 谁“能”使用栈?

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

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

变量的第三种形式

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

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

为什么这么问呢?

首先要提个问题:

TypeA a = new TypeA();

这里的a是什么呢?

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

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

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

不错,它就是我们常说但也经常忽视的引用了。我们都是通过实例的引用去操作某个引用类型的实例的。

所以,变量有三种形式:

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

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

所以我们应该明确,所有的变量都会被分配给相应的存储空间。而引用的内容,指向另一块存储空间。

空间的生命周期

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

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

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

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

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

M$的空间分配的策略

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

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

0x03 结论

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

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

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

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

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

原因如下:

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

 

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

码字不易。求个推荐