前言

环境:.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. 4、SpringBoot2之整合SpringMVC

    创建名为springboot_springmvc的新module,过程参考3.1节 4.1.重要的配置参数 在 spring boot 中,提供了许多和 web 相关的配置参数(详见官方文档),其中有 ...

  2. 【Spring】05 注解开发

    环境搭建 配置ApplicationContext.xml容器文件[半注解实现] <?xml version="1.0" encoding="UTF-8" ...

  3. 【Mybatis】02 快速入门Part2 补完CRUD

    这是我们的UserMapper.xml文件 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE ...

  4. 【H5】07 网页调试

    摘自: https://developer.mozilla.org/zh-CN/docs/Learn/HTML/Introduction_to_HTML/Debugging_HTML HTML 优雅明 ...

  5. 【Layui】08 时间线 Timeline

    文档地址: https://www.layui.com/demo/timeline.html 常规时间线: <ul class="layui-timeline"> &l ...

  6. 灵巧手 —— 智能仿生手 —— 人形机器人(humanoid)

    产品主页: https://www.brainco.cn/#/product/brain-robotics 国内销售的一款产品,美国华人生产的,灵巧度非常高的一款仿生手产品.

  7. (续)signal-slot:python版本的多进程通信的信号与槽机制(编程模式)的库(library) —— 强化学习ppo算法库sample-factory的多进程包装器,实现类似Qt的多进程编程模式(信号与槽机制) —— python3.12版本下成功通过测试

    前文: signal-slot:python版本的多进程通信的信号与槽机制(编程模式)的库(library) -- 强化学习ppo算法库sample-factory的多进程包装器,实现类似Qt的多进程 ...

  8. CPU端多进程/多线程调用CUDA是否可以加速???

    相关: NVIDIA显卡cuda的多进程服务--MPS(Multi-Process Service) tensorflow1.x--如何在C++多线程中调用同一个session会话 tensorflo ...

  9. 关于centos7下所有指令失效

    起因: 疑似宝塔更新修复后,本地所有环境变量集体不生效 问题环境 xshell环境下ssh连接 问题描述: - bash: xxx not fund - 环境变量无法保存 - 所有的保存方式都是临时生 ...

  10. 微信小程序 BLE 基础业务接口封装

    写在前面:本文所述未必符合当前最新情形(包括蓝牙技术发展.微信小程序接口迭代等). 微信小程序为蓝牙操作提供了很多接口,但在实际开发过程中,会发现隐藏了不少坑.目前主流蓝牙应用都是基于低功耗蓝牙(BL ...