匹夫细说C#:不是“栈类型”的值类型,从生命周期聊存储位置
0x00 前言:
匹夫在日常和别人交流的时候,常常会发现一旦讨论涉及到“类型”,话题的热度就会立马升温,因为很多似是而非、或者片面的概念常常被人们当做是全面和正确的答案。加之最近在园子看到有人翻译的《C#堆vs栈》系列,觉得也挺有趣,挺不错的,所以匹夫今天也想从存储位置的角度聊聊所谓的值类型,同时也想反驳一下单纯的把值类型当成总是存储在栈上的观点。
0x01 堆vs栈?
很多看官在想到存储空间的分配的时候,往往会想到有一个东西叫内存,当然如果知识更牢靠的朋友能进一步知道还有所谓的堆和栈的概念。不错,堆和栈应该是一谈到存储空间时,我们第一时间想到的。但是还有没有什么遗漏呢?的确有遗漏,如果你没有考虑到寄存器的话。这里匹夫先把寄存器提出来,是为了下面尾首呼应,关于寄存器的话题先按下不表。那抛开寄存器,又回到了我们看似熟悉的堆和栈的话题上。那就分别聊聊吧。
堆
其实我更喜欢叫它托管堆,不过为了简便,匹夫还是一律使用堆来代替了(要明白托管堆和堆不是一个东西)。为什么先聊堆呢?因为下面聊到栈的时候你会发现原来它们有很多相似的地方,不过栈做的更讲究。堆的实现细节有很多(比如GC),所以避重就轻,我们就聊聊它的设计思路,而不去考虑它是如何实现具体细节的。
假设,我们有很大一块内存是为了引用类型的实例准备的。同时,由于可能有的实例还“活着”,换句话说就是还在这块内存的某个地方,但是有的实例却死了,换言之之前存放这个实例的内存已经解放了,所以这块内存上以“是否存放有引用类型的实例”为标准来看,是不连续的,或者说存在很多“洞”。而这些“洞”,才是我们可以用来为新实例分配的空间。
所以一个思路就是造一个链表,用来存放这些不连续的“洞”,但是每一次分配空间时,都要去这个链表里面检查以寻找合适的“洞”,这显然是一笔额外的开销(所以pass掉)。
所以,我们显然更希望存放有类实例的内存在一起,空闲的内存在一起(顶端)。只有在这个前提下,我们才能放心大胆的给新的类实例分配存储空间,同时内存分配实现起来也十分容易,容易到什么地步呢?你只需要一个指针的移动就可以实现内存的分配。
为了实现这个目的,下面就引入了我们的常说的GC。(注:当然要具体聊聊GC,可能需要查阅更多的资料和写更多的篇幅,而且可能更加索然无味,所以这里匹夫只是简单的引入,如果有错误也欢迎各位指出。)
GC的行为过程可以分为三个阶段,各位可能也都十分熟悉:
- 标记阶段:首先堆上所有的实例在默认状态下都假设是“死的”,但是CLR显然知道哪些实例是活的,这样在GC开始的时候,会将这些活着的实例标记为活着。
- 清理阶段:没有被标记的实例释放空间
- 压缩阶段:堆重新组织,使存放活着的类实例的空间连在一起,已经释放掉的空闲的空间连在一起。
当然,GC的开销还是比较大的,所以为了对实例区别对待,以提高效率,GC还有一个“代”的概念。简单的说,就是按照实例的存活时间,将实例划归不同的部分。目的就是针对不同的存活时间,GC有不同的执行频率。
所以可以看到堆的开销很大一部分是由于有GC的存在,而GC的存在本身又是为了使堆分配新的空间更加容易。
栈
栈和堆很像,假设你同样有一块空间用来存储数据。那我们需要增加什么样的限定,来区分堆和栈呢?
还记得上面介绍堆时候匹夫说过的话吗?“我们显然更希望存放有类实例的内存在一起,空闲的内存在一起(顶端)”。而栈之所以是栈,就是因为栈底部存储的数据总是会比顶部数据活的更长,也就是说,栈中的空间是有序的。顶部的数据总是先于底部的数据先死掉,也正是因为如此,栈中没有堆中存在的“洞”,存储空间的连续就意味着我们无需GC来对空间进行压缩。(图片来自网络)
也正是因为我们总是知道栈顶是空的,而栈顶往下都是存活的数据,所以我们在分配新的数据时,只需要移动指针即可。想起了什么吗?不错,栈无需GC就实现了堆所追求的分配新空间时的最佳形式。
还有什么好处呢?对,我们同样只需要移动指针就能重新分配栈的空间。由于完全只是指针的移动,所以和使用GC的堆相比(GC的标记,清理,压缩,以及代的概念的引入),时间更少。
所以,如果只考虑在内存上分配存储空间,堆和栈其实很相似。不同之处主要体现在GC的开销上。
0x02 谁“能”使用栈?
显然,使用栈的效率要高于使用堆。但为什么不都去使用栈呢?因为匹夫之前说过的,栈之所是栈的原因,就是因为栈底部存储的数据总是会比顶部数据活的更长,只有能保证这个条件,我们才能使用栈。
那么谁能够保证呢?在回答这个问题之前,匹夫先提一个新的问题。
值(value)的第三种形式
如果匹夫问你,C#中的值有几种形式呢?一定逃不掉的是值类型的实例,引用类型的实例。
但你有没有发现一个问题呢?你真的直接操作过引用类型的实例吗?
为什么这么问呢?
首先要提个问题:
TypeA a = new TypeA();
这里的a是什么呢?
首先,它不是值类型的实例。
其次,看着有点像是TypeA的实例啊?
错,你可以说它指向一个TypeA的实例,但不能说它就是TypeA的实例。
不错,a既不是值类型也不是引用类型的实例,而是我们常说但也经常忽视的“引用”(reference)了。我们都是通过“引用”去操作某个引用类型的实例的。
所以,值有三种形式:
- 值类型的实例
- 引用类型的实例
- 引用
但是,这里就有了一个很有趣的问题。我们都知道,引用类型的实例的空间分配在堆上。但是上例中a的值的空间该如何分配呢?它是一个引用,而非引用类型的实例。它的值指向一块分配在堆上的引用类型实例。但是这个值自己难道不需要存储空间吗?
所以我们应该明确,所有的值都会被分配给相应的存储空间。而以“引用”这种形式出现的值,关联着另外一块存储空间。
空间的生命周期
既然匹夫已经提了一个问题了,那么就再提一个问题好了。既然上文多处提到了所谓的生命时间或者说生命周期,那么“空间的生命周期”究竟应该如何定义?
那么匹夫就先下个一个定义:存储空间的生命周期指的是这块空间中的内容的有效期。
生命周期有了,但是显然还需要一个基准,来作为衡量生命周期长短的标准吧?
我们知道,方法是过程抽象的一种表现形式。所以,我们再定义一个以方法执行时间为标准的称呼“活动周期”:从该方法开始执行到正常返回或抛出异常所消耗的时间。
而在这个方法的方法体内的变量,显然要获取其对应的存储空间。如果变量要求的空间的生命周期要比该方法的活动周期还要长,那么就被标记为“长寿”空间,否则就是“短寿”空间。
M$的空间分配的策略
OK,回答完匹夫上面提到的2个问题,再结合上文匹夫提到过存储空间类型,我们来看看微软的处理。
- 三种存储类型:栈,堆,寄存器
- “长寿”空间永远是堆空间。
- “短寿”空间永远是栈空间或寄存器。
- 如果运行时很难判断所需的存储空间究竟是“长寿”的还是“短寿”的,为了避免错误,一律当做“长寿”空间处理。例如,引用类型的实例(不是引用本身哦)需要的空间永远被当做“长寿”的。所以引用类型实例分配在堆上。
0x03 结论
OK,看完了微软的处理方式之后,匹夫再给各位总结一下,顺带回答一下0x02节标题上的问题。
首先,我们可以看到在空间分配这个问题上,值类型实例和引用(不是引用类型实例哦)并无本质区别。也就是说,它们可以被分配在栈上、寄存器中以及堆上,这和它们是什么类型无关,只和它们需要的空间的生命周期是“长寿”还是“短寿”有关。
其次,某天在某技术群中有人提问过lamda表达式中的值类型实例应该如何分配。在此匹夫也回答一下这个问题,数组中的元素、引用类型的字段、迭代器块中的局部变量、闭包情况下匿名函数(lamda)中的局部变量所需要的空间生命周期都要长于方法的活动周期,即便是短于方法的活动周期,但是由于上述第4点,即对运行时来说难以判断其生命周期的长短,故都按“长寿”空间计。所以都会被分配到堆上。
最后,回答一下本节题目中的问题。究竟谁能使用栈呢?
其实上文都已经回答过了,不过这里匹夫还是举个例子作答吧:一般方法中的值类型局部变量或临时变量。
原因如下:
- 生命周期符合栈底部存储的数据总是会比顶部数据活的更长
- 值类型实例的值就是它自己,所以它们的存储位置就是它们所在的位置。不会有引用指向它们。
- 同2,由于值类型的实例的值就是它自己,所以它不引用别人,不必关系引用的实例的生命周期。
- 说到底,还是和它的空间生命周期是长寿还是短寿有关
所以,单纯的把值类型当成总是存储在栈上是不准确的。而值类型之所叫“值类型”,其实和它的语义(semantic)有关,也就是说基于值类型的变量直接包含值(将一个值类型变量赋给另一个值类型变量时,将复制其包含的值。这与引用类型变量的赋值不同,引用类型变量的赋值只复制对对象的引用,不复制对象本身)。而和它的存储空间分配策略无关,否则,为什么不叫“栈类型”和“堆类型”这样的名称呢?
0x04 后记补充
当然,从园友的回复来看,对迭代器块中的局部变量、闭包情况下匿名函数中的局部变量也分配在堆上比较有异议。所以匹夫就写个小例程,同时从更底层的CIL代码的角度来看看这个问题。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
}
//测试1
static IEnumerable<int> Test1() {
int i = ;
yield return i;
}
//测试2
static void Test2() {
int i = ;
Action act = delegate {Console.WriteLine(i);};
}
}
之后,我们将这个小例子的源代码编译成CIL的形式,再来看看Test1和Test2的CIL实现。
Test1:
//迭代器部分Test1
.field assembly int32 '<i>__0' //声明
.....
IL_0022: ldc.i4. //取常数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压栈
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系列也说过这一点。但是,可不要搞混指令和数据啊。
所以,可以看到闭包情况下的匿名函数和迭代器块将它们的局部变量做成了类的字段,从而存储在了堆上。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
码字不易。求个推荐
匹夫细说C#:不是“栈类型”的值类型,从生命周期聊存储位置的更多相关文章
- 【基础概念】匹夫细说C#:不是“栈类型”的值类型,从生命周期聊存储位置
转载地址 https://www.cnblogs.com/murongxiaopifu/p/4419040.html 0x00 前言: 匹夫在日常和别人交流的时候,常常会发现一旦讨论涉及到" ...
- 匹夫细说C#:可以为null的值类型,详解可空值类型
首先祝大家中秋佳节快乐~ 0x00 前言 众所周知的一点是C#语言是一种强调类型的语言,而C#作为Unity3D中的游戏脚本主流语言,在我们的开发工作中能够驾驭好它的这个特点便十分重要.事实上,怎么强 ...
- 脚踏实地学C#2-引用类型和值类型
引用类型和值类型介绍 CLR支持两种类型,引用类型和值类型两种基本的类型: 值类型下有int.double.枚举等类型同时也可以称为结构,如int结构类型.double结构类型,所有的值类型都是隐式密 ...
- C#中的基元类型、值类型和引用类型
C# 中的基元类型.值类型和引用类型 1. 基元类型(Primitive Type) 编译器直接支持的类型称为基元类型.基元类型可以直接映射到 FCL 中存在的类型.例如,int a = 10 中的 ...
- CLR-2-2-引用类型和值类型
引用类型和值类型,是一个老生常谈的问题了.装箱拆箱相信也是猿猿都知,但是还是跟着CLR via C#加深下印象,看有没有什么更加根本和以前被忽略的知识点. 引用类型: 引用类型有哪些这里不过多赘述,来 ...
- C语言中标识符的作用域、命名空间、链接属性、生命周期、存储类型
Technorati 标签: C,标识符,作用域,命名空间,链接属性,生命周期,存储类型,scope,name space,linkage,storage durations,lifetime 无论学 ...
- [No0000B9]C# 类型基础 值类型和引用类型 及其 对象复制 浅度复制vs深度复制 深入研究2
接上[No0000B5]C# 类型基础 值类型和引用类型 及其 对象判等 深入研究1 对象复制 有的时候,创建一个对象可能会非常耗时,比如对象需要从远程数据库中获取数据来填充,又或者创建对象需要读取硬 ...
- [No0000B5]C# 类型基础 值类型和引用类型 及其 对象判等 深入研究1
引言 本文之初的目的是讲述设计模式中的 Prototype(原型)模式,但是如果想较清楚地弄明白这个模式,需要了解对象克隆(Object Clone),Clone其实也就是对象复制.复制又分为了浅度复 ...
- C#的两种类据类型:值类型和引用类型
注:引用类型相等赋值是地址赋值,不是值赋值. 什么是值类型,什么是引用类型 概念:值类型直接存储其值,而引用类型存储对其值的引用.部署:托管堆上部署了所有引用类型. 引用类型:基类为Objcet 值类 ...
随机推荐
- 详解树莓派Model B+控制蜂鸣器演奏乐曲
步进电机以及无源蜂鸣器这些都需要脉冲信号才能够驱动,这里将用GPIO的PWM接口驱动无源蜂鸣器弹奏乐曲,本文基于树莓派Mode B+,其他版本树莓派实现时需参照相关资料进行修改! 1 预备知识 1.1 ...
- 关于自己写C++的一点风格
现在,我学了很长时间的C++,但是自己就是无法精通.许多知识是入门书上没有的.现在写C++最重要的就是风格问题. 我现在的C++风格: 把自己所有的东西都放在一个名称空间下. 没有全局的函数,有的函数 ...
- 在docker中运行ASP.NET Core Web API应用程序(附AWS Windows Server 2016 widt Container实战案例)
环境准备 1.亚马逊EC2 Windows Server 2016 with Container 2.Visual Studio 2015 Enterprise(Profresianal要装Updat ...
- 前端学HTTP之重定向和负载均衡
前面的话 HTTP并不是独自运行在网上的.很多协议都会在HTTP报文的传输过程中对其数据进行管理.HTTP只关心旅程的端点(发送者和接收者),但在包含有镜像服务器.Web代理和缓存的网络世界中,HTT ...
- Carousel 旋转画廊特效的疑难杂症
疑难杂症 该画廊特效的特点就是前后元素有层级关系. 我想很多人应该看过或者用过这个插件carousel.js,网上也有相关的教程.不知道这个插件的原型是哪个,有知道的朋友可以告诉我. 该插件相对完美, ...
- Discuz论坛黑链清理教程
本人亲测有效,原创文章哦~~~ 论坛黑链非常的麻烦,如果你的论坛有黑链,那么对不起,百度收录了你的黑链,不会自动删除,需要你手动去清理. 什么是黑链 黑链,顾名思义,就是一些赌博网站的外链,这些黑链相 ...
- slf4j中的MDC
slf4j中MDC是什么鬼 slf4j除了trace.debug.info.warn.error这几个日志接口外,还可以配合MDC将数据写入日志.换句话说MDC也是用来记录日志的,但它的使用方式与使用 ...
- SpringMVC+Shiro权限管理【转】
1.权限的简单描述 2.实例表结构及内容及POJO 3.Shiro-pom.xml 4.Shiro-web.xml 5.Shiro-MyShiro-权限认证,登录认证层 6.Shiro-applica ...
- Android开发学习—— shape标签的使用
参考这片文章http://www.cnblogs.com/armyfai/p/5912414.html
- VS2015 Git 源码管理工具简单入门
1.VS Git插件 1.1 环境 VS2015+GitLab 1.2 Git操作过程图解 1.3 常见名词解释 拉取(Pull):将远程版本库合并到本地版本库,相当于(Fetch+Meger) 获取 ...