转载地址 https://www.cnblogs.com/murongxiaopifu/p/4419040.html

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系列也说过这一点。但是,可不要搞混指令和数据啊。

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

【基础概念】匹夫细说C#:不是“栈类型”的值类型,从生命周期聊存储位置的更多相关文章

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

    0x00 前言: 匹夫在日常和别人交流的时候,常常会发现一旦讨论涉及到“类型”,话题的热度就会立马升温,因为很多似是而非.或者片面的概念常常被人们当做是全面和正确的答案.加之最近在园子看到有人翻译的& ...

  2. 匹夫细说C#:可以为null的值类型,详解可空值类型

    首先祝大家中秋佳节快乐~ 0x00 前言 众所周知的一点是C#语言是一种强调类型的语言,而C#作为Unity3D中的游戏脚本主流语言,在我们的开发工作中能够驾驭好它的这个特点便十分重要.事实上,怎么强 ...

  3. C++异常处理 - 栈解旋,异常接口声明,异常类型和异常变量的生命周期

    栈解旋(unwinding) 异常被抛出后,从进入try块起,到异常被抛掷前,这期间在栈上的构造的所有对象,都会被自动析构.析构的顺序与构造的顺序相反.这一过程称为栈的解旋(unwinding). d ...

  4. python 全栈开发,Day84(django请求生命周期,FBV和CBV,ORM拾遗,Git)

    一.django 请求生命周期 流程图: 1. 当用户在浏览器中输入url时,浏览器会生成请求头和请求体发给服务端请求头和请求体中会包含浏览器的动作(action),这个动作通常为get或者post, ...

  5. java线程基础巩固---Thread中断Interrupt方法学习&采用优雅的方式结束线程生命周期

    Interrupt学习: 在jdk中关于interrupt相关方法有三个,如下: 关于上面的疑问会在稍后进行阐述滴,下面看代码: 编译运行: 应该说是t线程为啥在被打断之后没有退出,还是在运行状态,这 ...

  6. 匹夫细说C#:委托的简化语法,聊聊匿名方法和闭包

    0x00 前言 通过上一篇博客<匹夫细说C#:庖丁解牛聊委托,那些编译器藏的和U3D给的>的内容,我们实现了使用委托来构建我们自己的消息系统的过程.但是在日常的开发中,仍然有很多开发者因为 ...

  7. 匹夫细说C#:庖丁解牛迭代器,那些藏在幕后的秘密

    0x00 前言 在匹夫的上一篇文章<匹夫细说C#:不是“栈类型”的值类型,从生命周期聊存储位置>的最后,匹夫以总结和后记的方式涉及到一部分迭代器的知识.但是觉得还是不够过瘾,很多需要说清楚 ...

  8. C# 堆VS栈 值类型VS引用类型

    最近博客园上连续出现了几篇关于堆VS栈 值类型VS引用类型的文章. 一个是关于C# 堆VS栈的,深入浅出,动图清晰明了,链接如下 C#堆栈对比(Part One) C#堆栈对比(Part Two) C ...

  9. C#基础概念

    1.     面向对象的思想主要包括:继承 多态 封装 ●       封装:用抽象的数据类型将数据和基于数据的操作封装在一起,数据被保护在抽象数据类型内部. ●       继承:子类拥有父类的所有 ...

随机推荐

  1. 论文解读《The Emerging Field of Signal Processing on Graphs》

    感悟 看完图卷积一代.二代,深感图卷积的强大,刚开始接触图卷积的时候完全不懂为什么要使用拉普拉斯矩阵( $L=D-W$),主要是其背后的物理意义.通过借鉴前辈们的论文.博客.评论逐渐对图卷积有了一定的 ...

  2. Ajax的IE缓存问题

    Ajax之IE缓存问题 <!-- IE浏览器会对ajax的结果进行一个缓存,这样就会导致一个缓存问题 浏览器会读取缓存 而不会去使用一个新的数据 这样对一个时效性比较强的场景 ajax的缓存会影 ...

  3. centos7 service iptables save 报错

    解决办法: 1.systemctl stop firewalld 2.yum install iptables-services 3.systemctl  restart iptables 4.ser ...

  4. asp.net core监控—引入Prometheus(三)

    上一篇博文中说到Prometheus有四种指标类型:Counter(计数器).Gauge(仪表盘).Histogram(直方图).Summary(摘要),并且我们做了一个Counter的Demo,接下 ...

  5. python22day

    内容回顾 递归练习 sys os logging shutil 函数结束啦 今日内容 面向对象 楔子:做一个人狗大战的游戏 技能要有归属感,人是人,狗是狗,技能的函数要写在对应函数内部,闭包. 复杂的 ...

  6. 微服务架构 | 5.1 使用 Netflix Hystrix 断路器

    目录 前言 1. Hystrix 基础知识 1.1 Hystrix 断路器强调调用 1.2 两大类别的 Hystrix 实现 1.3 舱壁策略 1.4 Hystrix 在远程资源调用失败时的决策过程 ...

  7. python 模块Module

    一.模块 1.定义: 模块是一个python文件,以.py结尾,包含了python对象定义和python语句. 2.作用: 模块内可以定义函数.类和变量: 模块可以提高代码的可维护性和重复使用: 让代 ...

  8. 洛谷 P4708 画画(无标号欧拉子图计数)

    首先还是类似于无标号无向图计数那样,考虑点的置换带动边的置换,一定构成单射,根据 Burnside 引理: \[|X / G| = \frac{1}{|G|}\sum\limits_{g \in G} ...

  9. Docker版本Jenkins的使用

    一. 什么是Jenkins Jenkins是当前非常流行的一款持续集成工具,可以帮助大家把更新后的代码自动部署到服务器上运行. 二. 为什么用docker版的Jenkins Jenkins主要有三种安 ...

  10. windows doc命令复制粘贴文件

    # 删除前端文件 rmdir /s/q E:\yuanbo2019\public\static del E:\yuanbo2019\public\index.html @echo off ::被复制的 ...