图解C#值类型与引用类型的内存分配情况


前言

关于C#的值类型和引用类型,在面试中经常被问到的问题就是:值类型与引用类型的内存分配有哪些区别,大多数同学只能回答到值类型存储在栈上,引用类型存储在堆上。具体是如何分配的,栈和堆的结构特征是怎样的,了解的较少。今天我们通过这篇文章,来彻底搞懂C#值类型与引用类型的内存分配情况。


一、类型系统之值类型和引用类型

C#的类型一共分为两类,一种是值类型(Value Type),一类是引用类型(Reference Type)。

在这里插入图片描述

1.值类型与引用类型的派生关系

在这里插入图片描述
值类型和引用类型都继承自System.Object类。不同的是,几乎所有的引用类型都直接从System.Object继承,而值类型则继承System.ValueType。System.ValueType直接派生于System.Object。即System.ValueType本身是一个类类型,而不是值类型。其关键在于ValueType重写了Equals()方法,从而对值类型按照实例的值来比较,而不是引用地址来比较。

2.值类型与引用类型的主要区别

  • 引用类型变量的赋值只复制对对象的引用,而不复制对象本身。而将一个值类型变量赋给另一个值类型变量时,将复制包含的值;
  • 引用类型可以派生出新的类型,而值类型不能;
  • 引用类型可以包含null值,值类型不能(可空类型功能允许将 null 赋给值类型);
  • 值类型总是分配在它声明的地方,作为字段时,跟随其所属的变量(实例)存储在堆上,作为局部变量时,存储在栈上。引用类型存储在堆中。类型实例化的时候,会在堆中开辟一部分空间存储类的实例,类实例的引用(指针)还是存储在栈中。

3.值类型和引用类型的使用场合

  • 值类型:在内存管理方面具有更好的效率,但不支持多态,不能派生新的类型,适合用做存储数据的载体;

  • 引用类型:支持多态,可以派生新的类型,适合用于定义应用程序的行为。

二、内存的逻辑划分之栈和堆

C#程序在CLR上运行时,内存从逻辑上划分两大块:栈、堆,这两个基本元素组成了C#程序的运行环境。

  • 栈,在程序运行的时候,每个线程(Thread)都会维护一个自己的专属线程堆栈。把它想像成叠在一起的盒子(像搭积木一样)。每一次调用一个方法就会在最上面叠一个盒子,用来跟踪程序运行情况。我们只能使用栈中叠在最上面的盒子里的东西。当最上面的盒子里的代码执行完毕(如方法执行完成),就把它扔掉并继续去使用下一个盒子。

  • 堆,是程序在运行的时候请求操作系统分配给自己的内存空间,可以想象成一个仓库,储存着我们使用的各种对象等信息,跟栈不同的是他们被调用完毕不会立即被清理掉。

1.栈的特征

  • 栈空间比较小(每个线程只有一个栈,占用1MB,栈内存溢出抛出StackOverflowException),但是读取速度快;
  • 数据只能从栈的顶端插入或删除,是连续存储的,把数据放到栈顶称为入栈,从栈顶删除数据称为出栈;
  • 存放方法的参数、局部变量、返回地址等值,当一个方法执行完毕后立刻自动清除。

2.栈的结构

  • 栈帧,每个方法执行都会分配一块独立的内存空间来存储方法运行需要的数据;
  • 栈帧也是后入先出的方式进入和弹出线程栈。

3.堆的特征

  • 堆空间比较大(32位最多分配1.5GB,64位最多分配8TB,堆内存溢出抛出OutOfMemoryException),但是读取速度慢;
  • 数据存储不连续,与栈不同,堆里的内存能够以任意顺序存入和移除;
  • 存放引用类型的对象,通过GC清理。

4.堆的结构

  • 堆中包含(至少)三个程序域,以及它们自带的加载堆和其他零部件;
  • 加载堆(loader heap)存在于每一个程序域中,存放 CLR 自己的类型系统以及用户定义的类型对象;
  • GC 堆(GC heap),垃圾收集器的处理对象。它分为 0, 1, 2 代三块区域,越高代的堆大小越大;
  • JIT 代码堆,用来存放 JIT 之后的本地代码。

参考链接:CLR如何创建运行时对象

三、代码运行时的内存分配情况

下面我们以实际的代码运行过程为例,详细介绍C#代码在运行时的内存分配情况。

1.变量和对象在内存中的分配

示例代码:

class TestClass
{
    public int x;
    public static string y;
}

void Test1()
{
  var a=1; 
  var b=new TestClass();
  var c=a;
  var d=b;
  var e=d.x;
  var f=TestClass.y;
}

内存分配情况
在这里插入图片描述

  • Test1()方法被调用时,系统为该方法创建一个栈桢,用于存储该方法使用到的值类型的变量、指针、调用其他方法的返回地址等;

  • 方法执行到 var a=1 时,首先入栈,变量a的值1存储在栈中,栈的起始地址为0x000000671b77e5a4;

  • 方法执行到 var b=new TestClass() 时,会在堆中开辟一块儿内存用于存储TestClass实例对象,然后变量b入栈,变量b的值为TestClass实例对象的引用(实际上存储的是TestClass实例在堆上的内存地址,也就是指针);

  • 方法执行到 var c=a 时,将变量c压入栈,因为a是值类型,所以对变量a的值进行拷贝赋值给c;

  • 方法执行到 var d=b 时,将变量d压入栈,因为b是引用类型,所以将变量b引用的地址赋值给变量d,仍然指向堆内存中的TestClass实例对象;

  • 方法执行到 var e=d.x 时,将变量e压入栈,因为x字段是值类型,所以将x的实际值0(int类型初始化的默认值为0)赋值给e;

  • 方法执行到 var f=TestClass.y 时,将变量f压入栈,因为y字段是引用类型,所以f变量的值为y字段的引用。

2.方法参数在栈中的分配

示例代码:

class TestClass
{
    public int x;
    public int sum(int i,int j){
        return i+j;
    }
}

void Test1()
{
  var a=new TestClass();
  int b = 0;
  b=a.sum(1,2);
}

内存分配情况:
在这里插入图片描述

  • 方法执行到 var a=new TestClass() ,会在堆中开辟一块儿内存用于存储TestClass实例对象,然后变量a入栈,变量a的值为TestClass实例对象的引用(实际上存储的是TestClass实例在堆上的内存地址,也就是指针);

  • 方法执行到 int b = 0 ,将局部变量b压入栈,因为b是值类型,所以值0存储在栈中;

  • 方法执行到 b=a.sum(1,2) ,首先两个int类型实参1,2分别入栈,并将sum方法的返回地址压入栈,sum方法执行结束之后应返回至该位置。

3.特殊的引用类型“System.String”

特性一:字符串是不可变的,字符串一经创建便不能更改,不能变长、变短或修改其中的任何字符。

特性二:字符串驻留(字符串池化),CLR可通过一个String对象共享多个完全一致的String内容,这样能减少系统中字符串的数量,从而节省内存。String的驻留机制实际上是在SystemDomain中进行的。 当CLR被加载之后,会在SystemDomain对应的managed heap中创建一个Hashtable,Hashtable中记录了所有在代码中使用字面量声明的字符串实例的引用,Hashtable的Key为字符串本身,Value为字符串对象的地址。

示例代码:

static void Main(string[] args)
{
   //申请一块堆内存,把地址放在Hashtable的key为hello的元素中
   string str1 = "hello"; 
   //由于上一句已经创建了key为hello的元素,所以不需要申请新的堆内存
   string str2 = "hello";
   //编译成MSIL语言时 已经与string str3 = "hello"一样了
   string str3 = "" + "e" + "l" + "l" + "o"; 
   //自己显示进行new
   string str4 = new string(new char[] { 'h', 'e', 'l', 'l', 'o' }); 
   //申请一块堆内存,把地址放在Hashtable的key为hello2的元素中
   string str5 = "hello2";
   //True 引用同一块堆内存 
   Console.WriteLine(object.ReferenceEquals(str1, str2).ToString());
   //True 也是引用同一块堆内存
   Console.WriteLine(object.ReferenceEquals(str1, str3).ToString()); 
   //False 引用了不同的堆内存 
   Console.WriteLine(object.ReferenceEquals(str1, str4).ToString());
   // 先从Hashtable中检索是否有重复的key ,检索到了hello2,所以不需要申请新的堆内存
   str2 = "hello2"; 
   //False str2与str1已经不引用同一个堆
   Console.WriteLine(object.ReferenceEquals(str1, str2).ToString());
   //True 变成与str5引用同一个堆内存
   Console.WriteLine(object.ReferenceEquals(str2, str5).ToString()); 
   // 控制台输入两个相同的字符串
   str1 = Console.ReadLine();
   str2 = Console.ReadLine();
   //False 因为 str1 和 str2 两个变量并非字面量声明的字符串,所以不会触发字符串驻留机制
   Console.WriteLine(object.ReferenceEquals(str1, str2).ToString());
   Console.ReadLine();
}

总结

不理解值类型和引用类型区别的同学,可能会给代码引入诡异的bug或性能问题,通过这篇文章讲解了值类型与引用类型的区别,栈与堆的结构特征,代码运行时的内存分配情况,并配以示例代码及相关图形说明,尽可能形象的讲解了值类型与引用类型,希望能给大家带来帮助。

  • 20
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值