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调优。