C#查漏补缺----值类型与引用类型,值类型一定分配在栈上吗?
前言
环境:.NET 8.0
系统:Windows11
参考资料:《CLR via C#》, 《.Net Core底层入门》,《.NET 内存管理宝典》
栈空间与堆空间
程序运行过程中,需要保存各种各样的数据。数据根据它们的生命周期从不同位置分配,每个线程都有独立的栈空间(Stack Space)。栈空间主要用于保存被调用方法的数据,如果某个数据只在某个方法中使用,那么可以把该数据定义为本地变量,随着方法分配,随着方法返回而释放。
然而不是所有数据都是随着方法返回而释放,部分数据要在方法返回后继续使用,或者被多个线程同时使用。这种场景就比较合适堆空间(Heap Space).堆空间是程序中一块独立的空间,从堆空间分配的数据可以被所有方法,所有线程访问
特性 | 栈 | 堆 |
---|---|---|
生存期 | 进入时推入,退出时弹出 | 通过分配自由存储 |
作用域 | 局部 | 全局 |
访问 | 局部变量,方法参数 | 指针 |
访问时间 | 快速(可能在CPU缓存) | 较慢(可能临时存在硬盘) |
分配 | 移动栈指针 | 开辟内存空间 |
释放 | 移动栈指针 | GC自动释放 |
使用 | 主要用于存储参数、返回地址和局部变量,编译时确定大小的数据 | 可存一切,主要用于存储动态分配的对象和数据 |
容量 | 有限(一个线程只有1MB),栈空间的大小相对较小,且在程序启动时就已经确定。因此,大量数据的存储不适合放在栈上,以避免栈溢出 | 无限制(硬盘多大有多大), 堆空间的大小通常远大于栈,能够动态扩展,适合存储生命周期长或大小未知的数据 |
大小可变 | 否 | 是 |
碎片 | 不会有 | 有 |
主要缺点 | 栈溢出(StackOverflow) | 内存泄露,内存碎片 |
类型系统
type是CLI中的一个基本概念,在ECMA335中定义:描述值,并指定该类型的所有值必须支持的合约
.NET中的每种类型都由一个称之为MethodTable的数据结构描述,包含了大量信息,其中比较重要的信息如下:
- GCInfo
用于垃圾回收器用途的数据结构 - 标志
描述各种类型的属性 - 基本实例大小
每个对象的大小 - EEClass
一般存储的是“冷”数据,比如类型加载,JIT编译,反射等。包括了方法,字段和接口的描述信息 - 调用方法所必要的描述信息
- 静态字段有关的数据
包括基元静态字段
类型的分类
在面试八股文中,有一个经常出现的问题:值类型与引用类型的区别?
而这个问题,一个高频次的答案就是:Class是引用类型,struct是值类型。值类型分配在栈用,引用类型分配在堆中。
这个说法说并不准确,为什么呢?因为它是从实现的角度对两个概念进行描述,相当于先射箭再画靶。而不是基于两种类型内在的真正差别
类型的定义
ECMA335对两种类型真正的定义
值类型:这种类型的实例直接包含其所有数据。值类型的值是自包含,自解释的
引用类型:这种类型的实例包含对其数据的引用。引用类型所描述的值是指示其他值的位置
值类型 | 引用类型 | |
---|---|---|
生存期 | 包含其所有数据,自包含,自解释。值类型包含的数据生存期与实例本身一样长 | 描述了其他值的位置,其他值的生存期并不取决于引用类型值本身 |
共享性 | 不可共享,如果我们想在其他地方使用它。默认使用“传值”语义,按字节复制一份,原始值不受影响。 | 可被共享,如果我们想在如果我们想在其他地方使用它。默认使用”传引用“语义。因此在传递之后,会多出一个指向同一个位置的引用类型实例。 |
相等性 | 仅当它们的值的二进制序列一样时才认为相同 | 当它们所指示的位置一样就认为相同 |
类型的存储(分配)
从定义中可以看出,没有地方说明,谁存储在栈中,谁存储在堆中。
实际上,值类型分配在栈上,引用类型分配在堆上。只是微软在设计CLI标准时根据实际情况所作出的一个设计决策。
由于它确实是一个非常好的决策,因此微软在实现不同的CLI时,沿用了这个决策。但请记住,这并不是银弹,不同的硬件平台有不同的设计
事实上类型的存储实现,主要通过JIT编译的设计来体现。JIT编译器在x86/x64的硬件平台上,由于有栈,堆,寄存器存在。JIT可以随意使用,只要它愿意,它可以把值类型分配在堆中,分配在寄存器中都是可以的。只要不违反类型的定义,又有何不可呢?
值类型
CLS(Common language Specification)定义了两种值类型
- 结构(struct)
包括内置整型(char,byte,int),浮点,布尔类型。用户也可以定义自己的结构 - 枚举(enum)
从内存管理角度来看,它就是整型类型,内部本质上是就是结构
值类型的存储
如果仅从定义出发,将所有值类型保存在堆上是完全可行的,只是使用栈或者CPU寄存器实在太香了而已
---------------------------------------------------------------我本想拒绝,可对方实在是给得太多了
现在,我们穷举一下值类型每一个出现的场景。并考虑如何存储它们
- 方法中的局部变量
如果值类型存在堆中,方法执行过程中,另外一个线程并发使用这个值。怎么办?使用栈空间的activation frame(线程是不共享栈的),是不是就完美解决了此问题 - 方法中的参数
同上 - 引用类型的值类型字段
其生存期取决于父值的生存期,可以肯定的是,引用类型的生存期肯定比当前的activation frame要长的多。因此不适合将它们存储在栈上。 - 静态字段
同上,其生存期远大于activation frame - 值类型的引用类型字段
其生存期取决于父值的生存期,如果父值位于栈,则该值也位栈。如果父值位于堆,则该值也位于堆。ps:父值位于栈,说明生存期是确定的,会随着方法结束而释放,所以就算有引用类型字段,因为生存期确定,所以也可以位于栈 - 局部内存池
生存期与方法的生存期严格等长,所以可以毫无顾忌的使用栈 - 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#查漏补缺----值类型与引用类型,值类型一定分配在栈上吗?的更多相关文章
- Java查漏补缺(3)(面向对象相关)
Java查漏补缺(3) 继承·抽象类·接口·静态·权限 相关 this与super关键字 this的作用: 调用成员变量(可以用来区分局部变量和成员变量) 调用本类其他成员方法 调用构造方法(需要在方 ...
- Java基础查漏补缺(2)
Java基础查漏补缺(2) apache和spring都提供了BeanUtils的深度拷贝工具包 +=具有隐形的强制转换 object类的equals()方法容易抛出空指针异常 String a=nu ...
- JAVA查漏补缺 1
JAVA查漏补缺 1 目录 JAVA查漏补缺 1 基本数据类型 数组 方法参数传递机制 基本数据类型 数据类型 关键字 取值范围 内存占用(字节数) 整型 byte -128~127 1 整型 sho ...
- js基础查漏补缺(更新)
js基础查漏补缺: 1. NaN != NaN: 复制数组可以用slice: 数组的sort.reverse等方法都会改变自身: Map是一组键值对的结构,Set是key的集合: Array.Map. ...
- 2019Java查漏补缺(一)
看到一个总结的知识: 感觉很全面的知识梳理,自己在github上总结了计算机网络笔记就很累了,猜想思维导图的方式一定花费了作者很大的精力,特共享出来.原文:java基础思维导图 自己学习的查漏补缺如下 ...
- Mysql查漏补缺
Mysql查漏补缺 存储引擎 数据库使用存储引擎来进行CRUD的操作,不同的存储引擎提供了不同的功能.Mysql支持的存储引擎有InnoDB.MyISAM.Memory.Merge.Archive.F ...
- Java基础查漏补缺(1)
Java基础查漏补缺 String str2 = "hello"; String str3 = "hello"; System.out.println(str3 ...
- CSS基础面试题,快来查漏补缺
本文大部分问题来源:50道CSS基础面试题(附答案),外加一些面经. 我对问题进行了分类整理,并给了自己的回答.大部分知识点都有专题链接(来源于本博客相关文章),用于自己前端CSS部分的查漏补缺.虽作 ...
- Flutter查漏补缺1
Flutter 基础知识查漏补缺 Hot reload原理 热重载分为这几个步骤 扫描项目改动:检查是否有新增,删除或者改动,直到找到上次编译后发生改变的dart代码 增量编译:找到改变的dart代码 ...
- Go语言知识查漏补缺|基本数据类型
前言 学习Go半年之后,我决定重新开始阅读<The Go Programing Language>,对书中涉及重点进行全面讲解,这是Go语言知识查漏补缺系列的文章第二篇,前一篇文章则对应书 ...
随机推荐
- 4、SpringBoot2之整合SpringMVC
创建名为springboot_springmvc的新module,过程参考3.1节 4.1.重要的配置参数 在 spring boot 中,提供了许多和 web 相关的配置参数(详见官方文档),其中有 ...
- 【Spring】05 注解开发
环境搭建 配置ApplicationContext.xml容器文件[半注解实现] <?xml version="1.0" encoding="UTF-8" ...
- 【Mybatis】02 快速入门Part2 补完CRUD
这是我们的UserMapper.xml文件 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE ...
- 【H5】07 网页调试
摘自: https://developer.mozilla.org/zh-CN/docs/Learn/HTML/Introduction_to_HTML/Debugging_HTML HTML 优雅明 ...
- 【Layui】08 时间线 Timeline
文档地址: https://www.layui.com/demo/timeline.html 常规时间线: <ul class="layui-timeline"> &l ...
- 灵巧手 —— 智能仿生手 —— 人形机器人(humanoid)
产品主页: https://www.brainco.cn/#/product/brain-robotics 国内销售的一款产品,美国华人生产的,灵巧度非常高的一款仿生手产品.
- (续)signal-slot:python版本的多进程通信的信号与槽机制(编程模式)的库(library) —— 强化学习ppo算法库sample-factory的多进程包装器,实现类似Qt的多进程编程模式(信号与槽机制) —— python3.12版本下成功通过测试
前文: signal-slot:python版本的多进程通信的信号与槽机制(编程模式)的库(library) -- 强化学习ppo算法库sample-factory的多进程包装器,实现类似Qt的多进程 ...
- CPU端多进程/多线程调用CUDA是否可以加速???
相关: NVIDIA显卡cuda的多进程服务--MPS(Multi-Process Service) tensorflow1.x--如何在C++多线程中调用同一个session会话 tensorflo ...
- 关于centos7下所有指令失效
起因: 疑似宝塔更新修复后,本地所有环境变量集体不生效 问题环境 xshell环境下ssh连接 问题描述: - bash: xxx not fund - 环境变量无法保存 - 所有的保存方式都是临时生 ...
- 微信小程序 BLE 基础业务接口封装
写在前面:本文所述未必符合当前最新情形(包括蓝牙技术发展.微信小程序接口迭代等). 微信小程序为蓝牙操作提供了很多接口,但在实际开发过程中,会发现隐藏了不少坑.目前主流蓝牙应用都是基于低功耗蓝牙(BL ...