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

时间:2022-05-12 05:18:02

0x00 前言:

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

0x01 堆vs栈?

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

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

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

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

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

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

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

标记阶段:首先堆上所有的实例在默认状态下都假设是“死的”,但是CLR显然知道哪些实例是活的,这样在GC开始的时候,会将这些活着的实例标记为活着。

清理阶段:没有被标记的实例释放空间

压缩阶段:堆重新组织,使存放活着的类实例的空间连在一起,已经释放掉的空闲的空间连在一起。

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

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

 栈

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

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

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

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

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

0x02 谁“能”使用栈?