前言

环境:.NET 8.0

系统:Windows11

参考资料:《CLR via C#》, 《.Net Core底层入门》,《.NET 内存管理宝典》

栈空间与堆空间

程序运行过程中,需要保存各种各样的数据。数据根据它们的生命周期从不同位置分配,每个线程都有独立的栈空间(Stack Space)。栈空间主要用于保存被调用方法的数据,如果某个数据只在某个方法中使用,那么可以把该数据定义为本地变量,随着方法分配,随着方法返回而释放。

然而不是所有数据都是随着方法返回而释放,部分数据要在方法返回后继续使用,或者被多个线程同时使用。这种场景就比较合适堆空间(Heap Space).堆空间是程序中一块独立的空间,从堆空间分配的数据可以被所有方法,所有线程访问

特性
生存期 进入时推入,退出时弹出 通过分配自由存储
作用域 局部 全局
访问 局部变量,方法参数 指针
访问时间 快速(可能在CPU缓存) 较慢(可能临时存在硬盘)
分配 移动栈指针 开辟内存空间
释放 移动栈指针 GC自动释放
使用 主要用于存储参数、返回地址和局部变量,编译时确定大小的数据 可存一切,主要用于存储动态分配的对象和数据
容量 有限(一个线程只有1MB),栈空间的大小相对较小,且在程序启动时就已经确定。因此,大量数据的存储不适合放在栈上,以避免栈溢出 无限制(硬盘多大有多大), 堆空间的大小通常远大于栈,能够动态扩展,适合存储生命周期长或大小未知的数据
大小可变
碎片 不会有
主要缺点 栈溢出(StackOverflow) 内存泄露,内存碎片

类型系统

type是CLI中的一个基本概念,在ECMA335中定义:描述值,并指定该类型的所有值必须支持的合约

.NET中的每种类型都由一个称之为MethodTable的数据结构描述,包含了大量信息,其中比较重要的信息如下:

  1. GCInfo

    用于垃圾回收器用途的数据结构
  2. 标志

    描述各种类型的属性
  3. 基本实例大小

    每个对象的大小
  4. EEClass

    一般存储的是“冷”数据,比如类型加载,JIT编译,反射等。包括了方法,字段和接口的描述信息
  5. 调用方法所必要的描述信息
  6. 静态字段有关的数据

    包括基元静态字段

类型的分类

在面试八股文中,有一个经常出现的问题:值类型与引用类型的区别?

而这个问题,一个高频次的答案就是:Class是引用类型,struct是值类型。值类型分配在栈用,引用类型分配在堆中。

这个说法说并不准确,为什么呢?因为它是从实现的角度对两个概念进行描述,相当于先射箭再画靶。而不是基于两种类型内在的真正差别

类型的定义

ECMA335对两种类型真正的定义

值类型:这种类型的实例直接包含其所有数据。值类型的值是自包含,自解释的

引用类型:这种类型的实例包含对其数据的引用。引用类型所描述的值是指示其他值的位置

值类型 引用类型
生存期 包含其所有数据,自包含,自解释。值类型包含的数据生存期与实例本身一样长 描述了其他值的位置,其他值的生存期并不取决于引用类型值本身
共享性 不可共享,如果我们想在其他地方使用它。默认使用“传值”语义,按字节复制一份,原始值不受影响。 可被共享,如果我们想在如果我们想在其他地方使用它。默认使用”传引用“语义。因此在传递之后,会多出一个指向同一个位置的引用类型实例。
相等性 仅当它们的值的二进制序列一样时才认为相同 当它们所指示的位置一样就认为相同

类型的存储(分配)

从定义中可以看出,没有地方说明,谁存储在栈中,谁存储在堆中。

实际上,值类型分配在栈上,引用类型分配在堆上。只是微软在设计CLI标准时根据实际情况所作出的一个设计决策。

由于它确实是一个非常好的决策,因此微软在实现不同的CLI时,沿用了这个决策。但请记住,这并不是银弹,不同的硬件平台有不同的设计

事实上类型的存储实现,主要通过JIT编译的设计来体现。JIT编译器在x86/x64的硬件平台上,由于有栈,堆,寄存器存在。JIT可以随意使用,只要它愿意,它可以把值类型分配在堆中,分配在寄存器中都是可以的。只要不违反类型的定义,又有何不可呢?

值类型

CLS(Common language Specification)定义了两种值类型

  • 结构(struct)

    包括内置整型(char,byte,int),浮点,布尔类型。用户也可以定义自己的结构
  • 枚举(enum)

    从内存管理角度来看,它就是整型类型,内部本质上是就是结构

值类型的存储

如果仅从定义出发,将所有值类型保存在堆上是完全可行的,只是使用栈或者CPU寄存器实在太香了而已

---------------------------------------------------------------我本想拒绝,可对方实在是给得太多了

现在,我们穷举一下值类型每一个出现的场景。并考虑如何存储它们

  1. 方法中的局部变量

    如果值类型存在堆中,方法执行过程中,另外一个线程并发使用这个值。怎么办?使用栈空间的activation frame(线程是不共享栈的),是不是就完美解决了此问题
  2. 方法中的参数

    同上
  3. 引用类型的值类型字段

    其生存期取决于父值的生存期,可以肯定的是,引用类型的生存期肯定比当前的activation frame要长的多。因此不适合将它们存储在栈上。
  4. 静态字段

    同上,其生存期远大于activation frame
  5. 值类型的引用类型字段

    其生存期取决于父值的生存期,如果父值位于栈,则该值也位栈。如果父值位于堆,则该值也位于堆。ps:父值位于栈,说明生存期是确定的,会随着方法结束而释放,所以就算有引用类型字段,因为生存期确定,所以也可以位于栈
  6. 局部内存池

    生存期与方法的生存期严格等长,所以可以毫无顾忌的使用栈
  7. evaluation stack上的临时值

    生存期被JIT严格控制,JIT清楚何时释放。故使用栈,堆,寄存器都可以。不过出于性能考虑,会优先使用寄存器与栈

从上面可以看到,值类型是否分配在栈中,主要考虑生存期与共享这两个因素,决定了我们使用哪种机制来存储值类型数据,

因此"值类型分配在栈用"这句话并不准确

C#示例

		public struct SomeStruct
{
public int Value1;
public int Value2;
public int Value3;
public int Value4;
}
public int RunStruct()
{
SomeStruct ss = new SomeStruct();
ss.Value1 = 10086;
return HelperStruct(ss);
} private int HelperStruct(SomeStruct ss)
{
return ss.Value1;
}

IL示例

// Token: 0x06000036 RID: 54 RVA: 0x000027A0 File Offset: 0x000009A0
.method public hidebysig
instance int32 RunStruct () cil managed
{
// Header Size: 12 bytes
// Code Size: 33 (0x21) bytes
// LocalVarSig Token: 0x1100000F RID: 15
.maxstack 2
.locals init (
[0] valuetype ConsoleApp2.SomeStruct ss,
[1] int32
) /* 0x000009AC */ IL_0000: nop
/* 0x000009AD */ IL_0001: ldloca.s ss
/* 0x000009AF */ IL_0003: initobj ConsoleApp2.SomeStruct //关键点1:没有在堆上分配
/* 0x000009B5 */ IL_0009: ldloca.s ss
/* 0x000009B7 */ IL_000B: ldc.i4 10086
/* 0x000009BC */ IL_0010: stfld int32 ConsoleApp2.SomeStruct::Value1
/* 0x000009C1 */ IL_0015: ldarg.0
/* 0x000009C2 */ IL_0016: ldloc.0 //关键点2:将第一个局部变量推入evaluation stack.相当于struct数据被复制一次
/* 0x000009C3 */ IL_0017: call instance int32 ConsoleApp2.StructClassIL::HelperStruct(valuetype ConsoleApp2.SomeStruct)
/* 0x000009C8 */ IL_001C: stloc.1
/* 0x000009C9 */ IL_001D: br.s IL_001F /* 0x000009CB */ IL_001F: ldloc.1
/* 0x000009CC */ IL_0020: ret
} // end of method StructClassIL::RunStruct

可以看到,执行过程中并没有进行堆分配(堆分配会用到newobj指令),参数传递过程中也是传值语义

引用类型

CLS(Common language Specification)定义了两种引用类型

  • 对象类型

    包括类和委托,最有名的就是object
  • 指针类型

    它是一个指向某个内存位置的纯地址。分为托管指针与非托管指针

引用类型的存储

由于引用可以共享数据,因此它们的生存期并不确定。所以考虑引用类型存储到哪里要比值类型要简单得多。

通常来说,引用类型不可能存储在栈上,此时哪里能存储引用类型就很明显了。

根据流传已久的说法,"引用类存储在堆上",这句话也不算特别对

因为.NET不同的GC模式会导致堆的数量也不一样,所以到底存在哪个堆呢?

以及在.net 9后,跟Java一样实现了逃逸分析(Escape Analysis),JIT如果知道一个引用类型实例的使用场景与一个局部值类型相同。由于生存期的可确定,我们可以像对待值类型一样将它分配到栈上

https://github.com/dotnet/runtime/issues/4584

C#示例

		public class SomeClass
{
public int Value1;
public int Value2;
public int Value3;
public int Value4;
}
public int RunClass()
{
SomeClass sc = new SomeClass();
sc.Value1 = 10086;
return HelperClass(sc);
}
public int HelperClass(SomeClass sc)
{
return sc.Value1;
}

IL示例

// Token: 0x06000038 RID: 56 RVA: 0x000027E8 File Offset: 0x000009E8
.method public hidebysig
instance int32 RunClass () cil managed
{
// Header Size: 12 bytes
// Code Size: 30 (0x1E) bytes
// LocalVarSig Token: 0x11000010 RID: 16
.maxstack 2
.locals init (
[0] class ConsoleApp2.SomeClass sc,
[1] int32
) /* 0x000009F4 */ IL_0000: nop
/* 0x000009F5 */ IL_0001: newobj instance void ConsoleApp2.SomeClass::.ctor() //关键点1:底层调用Allocator,创建一个新SomeClass对象实例
/* 0x000009FA */ IL_0006: stloc.0
/* 0x000009FB */ IL_0007: ldloc.0
/* 0x000009FC */ IL_0008: ldc.i4 10086
/* 0x00000A01 */ IL_000D: stfld int32 ConsoleApp2.SomeClass::Value1
/* 0x00000A06 */ IL_0012: ldarg.0
/* 0x00000A07 */ IL_0013: ldloc.0 //关键点2:将第一个局部变量推入evaluation stack.传递的是SomeClass实例的引用,引用本身可以看作是值对象。
/* 0x00000A08 */ IL_0014: call instance int32 ConsoleApp2.StructClassIL::HelperClass(class ConsoleApp2.SomeClass)
/* 0x00000A0D */ IL_0019: stloc.1
/* 0x00000A0E */ IL_001A: br.s IL_001C /* 0x00000A10 */ IL_001C: ldloc.1
/* 0x00000A11 */ IL_001D: ret
} // end of method StructClassIL::RunClass

实际场景

看了这么多,来几个实际的例子。加深理解

场景1:引用类型中的值类型

    public class MyTestClass
{
public MyTestStruct myTestStruct;
} public struct MyTestStruct
{
public int value;
} public class DemoTest()
{
public static void Example()
{
MyTestClass c = new MyTestClass();
//跟随父对象c分配在堆空间中,如果启用了逃逸分析,由于对象c是本地变量且在方法结束后没有被共享。所以也有可能被分配在栈空间中
c.myTestStruct = new MyTestStruct();
c.myTestStruct.value = 10086;
}
}

场景2:值类型中的引用类型

    public struct MyTestStruct
{
public object value;
} public class DemoTest()
{
public static void Example()
{
//值类型本地变量,值存储在栈空间
int i = 10086;
MyTestStruct s = new MyTestStruct();
//值类型的引用类型字段,其生存期取决于父值的生存期
//变量s为本地变量,因此内部引用类型变量value也存储在栈空间中
s.value = "10086";
}
}

类型的布局

见此文对象内存结构与布局

总结

讲述了这么多,实际上核心思路就只有一个。生存期是否可控?是否被其他线程共享?无论什么类型,只要它生存期大于activation fram 或者被其他线程所共享访问的。那么它就会被分配在堆上。反之,则分配在堆上。

更简单来说, JIT如果不知道对象什么时候被释放,那么它一定会分配到堆空间中。如果知道什么时候被释放,那么它会尽量分配到栈空间中(逃逸分析)。

埋坑

耳听为虚,眼见为实。这里只是从理论层面以及IL代码层面解释了。值类型和引用类型的分配问题。

所以这里埋个坑,dump文件形式,查看真正的汇编跟内存分配。静待更新~

题外话,为什么经常看到JVM调优,而少见CLR调优?

叠甲,无引战,个人理解,纯属是为了解决早年的自己对这方面的疑惑。如果理解不对的地方,全是我错。您都对。

个人认为,这与虚拟机本身的特性有关,屁股决定脑袋,经济基础决定上层建筑。

JAVA认为万物皆可Class,并没有给开发者提供灵活的自定义值类型,指针以及非托管堆等设施,代价就是内存占用更高(尽管JAVA有逃逸分析,但写代码的终究是人)。所以当遇到GC问题时,其关注点是如何让程序尽量减少GC,甚至不GC。所以需要调整堆大小,老年代/新生代的预算等。来达到一个既不会占用过于离谱内存以及又不会频繁GC的平衡点。

C#因为有这样的设施,所以关注点是优化自己的代码。使用struct/ref struct/span/memory等,来减少堆分配或手动管理内存。从而实现降低GC频率,降低内存碎片等操作。

此外,JAVA开源比较早,积累多,高层次人才也多。所以研究资料也不少,自然而然JVM调优就成了大家经常看见的一个话题。反观C#,早期不开源。一步没赶上,步步没赶上。导致人才断层,研究CLR底层的人更是少之又少。.Net Core发布后才有改善。所以极少有人讨论CLR调优。

C#查漏补缺----值类型与引用类型,值类型一定分配在栈上吗?的更多相关文章

  1. Java查漏补缺(3)(面向对象相关)

    Java查漏补缺(3) 继承·抽象类·接口·静态·权限 相关 this与super关键字 this的作用: 调用成员变量(可以用来区分局部变量和成员变量) 调用本类其他成员方法 调用构造方法(需要在方 ...

  2. Java基础查漏补缺(2)

    Java基础查漏补缺(2) apache和spring都提供了BeanUtils的深度拷贝工具包 +=具有隐形的强制转换 object类的equals()方法容易抛出空指针异常 String a=nu ...

  3. JAVA查漏补缺 1

    JAVA查漏补缺 1 目录 JAVA查漏补缺 1 基本数据类型 数组 方法参数传递机制 基本数据类型 数据类型 关键字 取值范围 内存占用(字节数) 整型 byte -128~127 1 整型 sho ...

  4. js基础查漏补缺(更新)

    js基础查漏补缺: 1. NaN != NaN: 复制数组可以用slice: 数组的sort.reverse等方法都会改变自身: Map是一组键值对的结构,Set是key的集合: Array.Map. ...

  5. 2019Java查漏补缺(一)

    看到一个总结的知识: 感觉很全面的知识梳理,自己在github上总结了计算机网络笔记就很累了,猜想思维导图的方式一定花费了作者很大的精力,特共享出来.原文:java基础思维导图 自己学习的查漏补缺如下 ...

  6. Mysql查漏补缺

    Mysql查漏补缺 存储引擎 数据库使用存储引擎来进行CRUD的操作,不同的存储引擎提供了不同的功能.Mysql支持的存储引擎有InnoDB.MyISAM.Memory.Merge.Archive.F ...

  7. Java基础查漏补缺(1)

    Java基础查漏补缺 String str2 = "hello"; String str3 = "hello"; System.out.println(str3 ...

  8. CSS基础面试题,快来查漏补缺

    本文大部分问题来源:50道CSS基础面试题(附答案),外加一些面经. 我对问题进行了分类整理,并给了自己的回答.大部分知识点都有专题链接(来源于本博客相关文章),用于自己前端CSS部分的查漏补缺.虽作 ...

  9. Flutter查漏补缺1

    Flutter 基础知识查漏补缺 Hot reload原理 热重载分为这几个步骤 扫描项目改动:检查是否有新增,删除或者改动,直到找到上次编译后发生改变的dart代码 增量编译:找到改变的dart代码 ...

  10. Go语言知识查漏补缺|基本数据类型

    前言 学习Go半年之后,我决定重新开始阅读<The Go Programing Language>,对书中涉及重点进行全面讲解,这是Go语言知识查漏补缺系列的文章第二篇,前一篇文章则对应书 ...

随机推荐

  1. 【Spring】07 后续的学习补充 vol1

    控制反转Inverse Of Control的演变: 在之前的原生Javaweb项目的问题: 我们三层架构每一层之间的联系是这样的: 由GradeDao接口指向GradeDaoImpl 再由Grade ...

  2. 老代码报错:scipy.misc.imresize报错: AttributeError: module 'scipy.misc' has no attribute 'imresize'

    运行老代码报错: image = misc.imresize(image, [Config.IMAGE_HEIGHT, Config.IMAGE_WIDTH], 'bilinear')Attribut ...

  3. mini_imagenet 数据集生成工具 (续)

    续接前文:  mini_imagenet 数据集生成工具 ============================================ 前文接受了mini_imagenet数据集的生成,但 ...

  4. Apache DolphinScheduler 在奇富科技的首个调度异地部署实践

    奇富科技(原360数科)是人工智能驱动的信贷科技服务平台,致力于凭借智能服务.AI研究及应用.安全科技,赋能金融机构提质增效,助推普惠金融高质量发展,让更多人享受到安全便捷的金融科技服务.作为国内领先 ...

  5. BMC Genomics | 综合代谢组学和转录组学分析揭示了菊花黄酮和咖啡酰奎宁酸的生物合成机制

    杭白菊是一种流行的药用和食用植物,主要通过黄酮类和咖啡酰奎宁酸(CQAs)的存在发挥其生物活性.然而,菊花头状花序中黄酮和CQA生物合成的调控机制尚不清楚. 本研究采用高效液相色谱法测定了菊花头状花序 ...

  6. mybatis 中 实体类字段为 month SQL 会报错的问题

    因为 month 是 mysql 的关键字 ,所以 你的实体类字段改成 months months months months months months就行了

  7. 【CMake系列】03-cmake 注释、常用指令 message、set、file、for_each、流程控制if

    本文给出了 cmake 中的 一些常用的 指令,可以快速了解,为后面的内容深入 打点基础. 本专栏的详细实践代码全部放在 github 上,欢迎 star !!! 如有问题,欢迎留言.或加群[3927 ...

  8. AvaloniaChat—从源码构建指南

    AvaloniaChat介绍 一个使用大型语言模型进行翻译的简单应用. 我自己的主要使用场景 在看英文文献的过程中,比较喜欢对照着翻译看,因此希望一边是英文一边是中文,虽然某些软件已经自带了翻译功能, ...

  9. SQL中解决i+1 & values中插入变量

    基于JDBC环境下使用mysql插入数据的一些小问题 下方代码用于实现 批量向数据库中插入数据 一般为"垃圾"数据 代码例子实现i+1的效果 i=1 i+1=2 for (int ...

  10. Ubuntu 安装 Docker Engine

    Docker Engine (也称作 Docker CE) 是 Docker 官方的社区版包,它不包含在 Ubuntu 默认的存储库中.因此,你无法直接使用 apt install docker-ce ...