0x00 前言:
匹夫在日常和别人交流的时候,常常会发现一旦讨论涉及到“类型”,话题的热度就会立马升温,因为很多似是而非、或者片面的概念常常被人们当做是全面和正确的答案。加之最近在园子看到有人翻译的《C#堆vs栈》系列,觉得也挺有趣,挺不错的,所以匹夫今天也想从存储位置的角度聊聊所谓的值类型,同时也想反驳一下简单的“值栈类型”理论(自己起的名,指单纯的把值类型当成总是在栈上存储的类型)。
0x01 堆vs栈?
很多看官在想到存储空间的分配的时候,往往会想到有一个东西叫内存,当然如果知识更牢靠的朋友能进一步知道还有所谓的堆和栈的概念。不错,堆和栈应该是一谈到存储空间时,我们第一时间想到的。但是还有没有什么遗漏呢?的确有遗漏,如果你没有考虑到寄存器的话。这里匹夫先把寄存器提出来,是为了下面尾首呼应,关于寄存器的话题先按下不表。那抛开寄存器,又回到了我们看似熟悉的堆和栈的话题上。那就分别聊聊吧。
堆
其实我更喜欢叫它托管堆,不过为了简便,匹夫还是一律使用堆来代替了(要明白托管堆和堆不是一个东西)。为什么先聊堆呢?因为下面聊到栈的时候你会发现原来它们有很多相似的地方,不过栈做的更讲究。堆的实现细节有很多(比如GC),所以避重就轻,我们就聊聊它的设计思路,而不去考虑它是如何实现具体细节的。
假设,我们有很大一块内存是为了引用类型的实例准备的。同时,由于可能有的实例还“活着”,换句话说就是还在这块内存的某个地方,但是有的实例却死了,换言之之前存放这个实例的内存已经解放了,所以这块内存上以“是否存放有引用类型的实例”为标准来看,是不连续的,或者说存在很多“洞”。而这些“洞”,才是我们可以用来为新实例分配的空间。
所以一个思路就是造一个链表,用来存放这些不连续的“洞”,但是每一次分配空间时,都要去这个链表里面检查以寻找合适的“洞”,这显然是一笔额外的开销(所以pass掉)。
所以,我们显然更希望存放有类实例的内存在一起,空闲的内存在一起(顶端)。只有在这个前提下,我们才能放心大胆的给新的类实例分配存储空间,同时内存分配实现起来也十分容易,容易到什么地步呢?你只需要一个指针的移动就可以实现内存的分配。
为了实现这个目的,下面就引入了我们的常说的GC。(注:当然要具体聊聊GC,可能需要查阅更多的资料和写更多的篇幅,而且可能更加索然无味,所以这里匹夫只是简单的引入,如果有错误也欢迎各位指出。)
GC的行为过程可以分为三个阶段,各位可能也都十分熟悉:
- 标记阶段:首先堆上所有的实例在默认状态下都假设是“死的”,但是CLR显然知道哪些实例是活的,这样在GC开始的时候,会将这些活着的实例标记为活着。
- 清理阶段:没有被标记的实例释放空间
- 压缩阶段:堆重新组织,使存放活着的类实例的空间连在一起,已经释放掉的空闲的空间连在一起。
当然,GC的开销还是比较大的,所以为了对实例区别对待,以提高效率,GC还有一个“代”的概念。简单的说,就是按照实例的存活时间,将实例划归不同的部分。目的就是针对不同的存活时间,GC有不同的执行频率。
所以可以看到堆的开销很大一部分是由于有GC的存在,而GC的存在本身又是为了使堆分配新的空间更加容易。
栈
栈和堆很像,假设你同样有一块空间用来存储数据。那我们需要增加什么样的限定,来区分堆和栈呢?
还记得上面介绍堆时候匹夫说过的话吗?“我们显然更希望存放有类实例的内存在一起,空闲的内存在一起(顶端)”。而栈之所以是栈,就是因为栈底部存储的数据总是会比顶部数据活的更长,也就是说,栈中的空间是有序的。顶部的数据总是先于底部的数据先死掉,也正是因为如此,栈中没有堆中存在的“洞”,存储空间的连续就意味着我们无需GC来对空间进行压缩。(图片来自网络)
也正是因为我们总是知道栈顶是空的,而栈顶往下都是存活的数据,所以我们在分配新的数据时,只需要移动指针即可。想起了什么吗?不错,栈无需GC就实现了堆所追求的分配新空间时的最佳形式。
还有什么好处呢?对,我们同样只需要移动指针就能重新分配栈的空间。由于完全只是指针的移动,所以和使用GC的堆相比(GC的标记,清理,压缩,以及代的概念的引入),时间更少。
所以,如果只考虑在内存上分配存储空间,堆和栈其实很相似。不同之处主要体现在GC的开销上。
0x02 谁“能”使用栈?
显然,使用栈的效率要高于使用堆。但为什么不都去使用栈呢?因为匹夫之前说过的,栈之所是栈的原因,就是因为栈底部存储的数据总是会比顶部数据活的更长,只有能保证这个条件,我们才能使用栈。
那么谁能够保证呢?在回答这个问题之前,匹夫先提一个新的问题。
变量的第三种形式
如果匹夫问你,C#中的变量有几种形式呢?一定逃不掉的是值类型的实例,引用类型的实例。
但你有没有发现一个问题呢?你真的直接操作过引用类型的实例吗?
为什么这么问呢?
首先要提个问题:
TypeA a = new TypeA();
这里的a是什么呢?
首先,它不是值类型的实例。
其次,看着有点像是TypeA的实例啊?
错,你可以说它指向一个TypeA的实例,但不能说它就是TypeA的实例。
不错,它就是我们常说但也经常忽视的引用了。我们都是通过实例的引用去操作某个引用类型的实例的。
所以,变量有三种形式:
- 值类型的实例
- 引用类型的实例
- 引用
但是,这里就有了一个很有趣的问题。我们都知道,引用类型的实例的空间分配在堆上。但是上例中的a的空间该如何分配呢?它是一个引用,而非引用类型的实例。它的值指向一块分配在堆上的引用类型实例。但是它自己难道不需要存储空间吗?
所以我们应该明确,所有的变量都会被分配给相应的存储空间。而引用的内容,指向另一块存储空间。
空间的生命周期
既然匹夫已经提了一个问题了,那么就再提一个问题好了。既然上文多处提到了所谓的生命时间或者说生命周期,那么“空间的生命周期”究竟应该如何定义?
那么匹夫就先下个一个定义:存储空间的生命周期指的是这块空间中的内容的有效期。
生命周期有了,但是显然还需要一个基准,来作为衡量生命周期长短的标准吧?
我们知道,方法是过程抽象的一种表现形式。所以,我们再定义一个以方法执行时间为标准的称呼“活动周期”:从该方法开始执行到正常返回或抛出异常所消耗的时间。
而在这个方法的方法体内的变量,显然要获取其对应的存储空间。如果变量要求的空间的生命周期要比该方法的活动周期还要长,那么就被标记为“长寿”空间,否则就是“短寿”空间。
M$的空间分配的策略
OK,回答完匹夫上面提到的2个问题,再结合上文匹夫提到过存储空间类型,我们来看看微软的处理。
- 三种存储类型:栈,堆,寄存器
- “长寿”空间永远是堆空间。
- “短寿”空间永远是栈空间或寄存器。
- 如果运行时很难判断所需的存储空间究竟是“长寿”的还是“短寿”的,为了避免错误,一律当做“长寿”空间处理。例如,引用类型的实例(不是引用本身哦)需要的空间永远被当做“长寿”的。所以引用类型实例分配在堆上。
0x03 结论
OK,看完了微软的处理方式之后,匹夫再给各位总结一下,顺带回答一下0x02节标题上的问题。
首先,我们可以看到在空间分配这个问题上,值类型实例和引用(不是引用类型实例哦)并无本质区别。也就是说,它们可以被分配在栈上、寄存器中以及堆上,这和它们是什么类型无关,只和它们需要的空间的生命周期是“长寿”还是“短寿”有关。
其次,某天在某技术群中有人提问过lamda表达式中的值类型实例应该如何分配。在此匹夫也回答一下这个问题,数组中的元素、引用类型的字段、迭代器块中的局部变量、匿名函数(lamda)中的局部变量所需要的空间生命周期都要长于方法的活动周期,即便是短于方法的活动周期,但是由于上述第4点,即对运行时来说难以判断其生命周期的长短,故都按“长寿”空间计。所以都会被分配到堆上。
最后,回答一下本节题目中的问题。究竟谁能使用栈呢?
其实上文都已经回答过了,不过这里匹夫还是举个例子作答吧:一般方法中的值类型局部变量或临时变量。
原因如下:
- 生命周期符合栈底部存储的数据总是会比顶部数据活的更长
- 值类型实例的值就是它自己,所以它们的存储位置就是它们所在的位置。不会有引用指向它们。
- 同2,由于值类型的实例的值就是它自己,所以它不引用别人,不必关系引用的实例的生命周期。
- 说到底,还是和它的空间生命周期是长寿还是短寿有关
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
码字不易。求个推荐
- 7楼双鱼座
- 问题蛮多,无法推荐。,1.C#是一种语言,而堆、栈这些概念应该是关于CIL或执行机的。,2.所谓变量的三种形式似是而非。无所谓长寿或短寿对象,“局部变量”或“域变量(字段)”策略肯定不同,临时变量就是不需要GC参与的变量。简简单单和事情弄那么复杂。,3.在IL里,使用栈空间是需要先申明的,只是这个工作通常是由编译器代我们做了。
- Re: 慕容小匹夫
- @双鱼座,恩,看完你说的三点,这个,只能说欢迎回复了......
- 6楼amazonove
- 关于变量的三种形式 我不太认同 我认为 C#一切皆为引用 引用无非指向值类型或引用类型的对象 我们永远都是操作引用
- 5楼『大雪无痕』
- 膜拜 慕容小匹夫。
- Re: 慕容小匹夫
- @『大雪无痕』,@澐飞扬,多谢支持~~
- 4楼amazonove
- 我在类里面声明一个 值类型的变量 int a = 10; 这个变量和值是怎么分配的呢 之前学习的时候 就是说值类型就分配在栈上 引用类型对象分配在堆里 后来又说函数调用创建栈帧 什么先入后出 又是内存又是数据结构 反正不大明白 栈帧和栈内存是一回事吗
- Re: 慕容小匹夫
- @amazonove,文中有说。
- 3楼小七不乖
- 我只是想说。我好不容易啃完了。。
- Re: 慕容小匹夫
- @小七不乖,哈哈。
- 2楼澐飞扬
- 好东西,讲的很详实。
- 1楼乱舞春秋
- 本来挺清楚的一件事,被楼主一顿描述不清的解释+瞎起一通”名称“,弄成一锅粥了。
- Re: 慕容小匹夫
- @乱舞春秋,多看看文档。