叫我安不理

C#查漏补缺----值类型与引用类型,值类型一定分配在栈上吗?

前言

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

栈空间与堆空间

程序运行过程中,需要保存各种各样的数据。数据根据它们的生命周期从不同位置分配,每个线程都有独立的栈空间(Stack Space)。栈空间主要用于保存被调用方法的数据,如果某个数据只在某个方法中使用,那么可以把该数据定义为本地变量,随着方法分配,随着方法返回而释放。
然而不是所有数据都是随着方法返回而释放,部分数据要在方法返回后继续使用,或者被多个线程同时使用。这种场景就比较合适堆空间(Heap Space).堆空间是程序中一块独立的空间,从堆空间分配的数据可以被所有方法,所有线程访问

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

image

类型系统

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

posted on 2024-09-09 09:05  叫我安不理  阅读(144)  评论(0编辑  收藏  举报

导航