C# 堆和栈 引用和值类型 Ref和Out

一、内存中的堆和栈

参考
内存划分为什么要分为堆和栈,当初设计这两个的时候分别是要解决什么问题? - Aetherus的回答 - 知乎
https://www.zhihu.com/question/447017261/answer/1759797973

1.栈内存

说到栈内存,不能不说的是“栈”。只要学过一点数据结构的都知道,栈(stack)是一种后进先出(last in first out,缩写为LIFO)的数据结构。那“栈内存”里的“栈”和后进先出有什么关系呢?

大家在写代码的时候,都会调用函数,而函数又可以调用其他函数。假定一个函数func1调用了函数func2,func2又调用了func3,那么这三个函数返回(return)的顺序是什么?没错,func3先返回,然后是func2,最后是func1。看见了?先调用的函数后返回,后调用的函数先返回,后进先出!这就是大家常说的“函数调用栈”(call stack)。

大家也都知道,函数可以定义一些参数(形参),函数体里也可以定义局部变量。对于每一个函数来说,当前函数一旦返回,这些形参和局部变量就出了作用域,也就没用了,于是它们占用的内存也就可以安全地释放了[1]。从这里可以看见,形参和局部变量的生命周期和函数调用的生命周期一致,而函数的嵌套调用是后进先出的,那么这部分内存也可以用后进先出的方式去管理,这就是栈内存。

每个函数被调用时,操作系统或语言运行时会创建一个对应于本次函数调用的“栈帧”(stack frame)并push到栈内存上,当这个函数返回的时候pop掉。对于每一个栈帧,它所需要的内存大小在编译时就能知道了(如果不考虑编译时优化,每个栈帧的大小等于当前函数的所有形参和局部变量的大小的总和,外加一些元数据的大小),并且每个形参和局部变量在当前栈帧上的内存地址偏移量也都是固定的[1],所以只要有个变量名就可以直接获取到这个变量对应的内存地址(变量名在编译时直接变成偏移量。这就是为什么Java程序反编译之后局部变量和型参的名称全部丢失的原因),从而直接访问它。对于多线程的程序,每个线程都有自己的栈内存。

由于栈内存的生命周期非常明了,栈内存的管理也相当地直截了当,通常操作系统直接就替你做了。

栈内存的好处我个人觉得有这么几点:

  • 无需通过指针或引用动态寻址,访问速度快
  • 生命周期非常明确,释放及时,内存空间使用效率高
  • 大小和偏移量固定,开内存的算法也简单,内存分配的延迟短
  • 回收内存不需要复杂的垃圾回收机制,算法的时间开销稳定

但是栈内存也有它的“缺陷”(严格地说是它设计的时候就不想让你那么用它):

  • 一次函数调用一旦返回,它对应的栈帧上的数据就不能用了(我在小白的年代经常犯这种错误,一个函数返回一个指向栈内存里的结构体的指针)。换句话说就是栈内存上的数据活不过一次函数调用的生命周期。
  • 栈内存无法在多个线程间共享
  • 栈内存的大小通常比较小(几个MB),如果往里塞的栈帧太多(=函数调用层次太深)或栈帧里要开的结构体太大,就会导致栈溢出(stack overflow)。这就是为什么递归函数的退出条件没写好就经常会导致stack overflow的道理,因为总是在push却没有pop。

为了克服上述任意一种“缺陷”,我们需要另一种内存管理机制:

2.堆内存

很抱歉,翻阅了很多资料也没找到“堆内存”和数据结构里的“堆”之间的关系。有人说是当年的Lisp用堆实现了这种内存管理方式(但没有证据)。总之记住数据结构的堆和堆内存完全不沾边就对了。堆内存就像是一堆杂乱无章的东西堆在那儿,也许这才是“堆内存”这个名称的由来吧。

堆内存是一种按需分配的动态内存管理机制(这里的动态是指什么时候申请就什么时候给你,申请多少给你多少,而不是像栈内存那样只要调了函数就直接把所有本次调用可能用到的内存都给你,不管你具体什么时候用),这种机制能让数据在内存里存活时间超过定义它的函数的生命周期(不去销毁就一直在那儿),也能让多个线程共享同一个内存里的结构体/对象(任何线程只要拿到这片内存的地址和大小就能访问)。但是由于它的动态,你不能仅靠一个变量名就知道每一份数据在内存里的确切位置,导致要访问它里面的数据必须通过指针或引用。

堆内存给我的感觉很像某些商场里寄放包裹的储物柜。你要用储物柜,就得先找前台申请,她会给你一把钥匙。有了这把钥匙,你就能往柜子里放东西了。等你购物完准备走人的时候,把柜子里的东西取出来,再把钥匙还掉。只不过商场里的柜子只有一种大小,而堆内存空间你可以任意指定需要的大小。另外,申请堆内存时拿到的钥匙(指针或引用)是可以复制的。

堆内存里的数据的生命周期只有程序员才知道(如果他真的知道),所以任何操作系统和语言运行时都不能准确地知道什么时候才应该释放一片堆内存空间。堆内存的管理现在比较常见的有三种手段:

  • 程序员手动管理(C/C++)。
  • 引入垃圾回收机制,在程序闲下来的时候或内存不够的时候清一下(这类语言最多,我都不想去列举了)。
  • 要求程序员明确指出每个变量的生命周期,并引入结构体的转让/借用机制,编译器据此推算出堆内存上的结构体什么时候释放(貌似只有Rust是这么做的)。
3.一点点补充

大家可以发现我这里只字未提内存地址增长方向,因为我觉得这是实现细节,每个语言都可以有自己的特色(比如Erlang的栈内存压根儿就不是正常的栈,而是类似于注册表的形式,所以也就不存在内存地址增长方向这一说了,当然它也不会有栈溢出),所以不能一概而论啦。

二、C#中的值类型和引用类型
1.值类型和引用类型在栈和堆中的分配

这儿有两个原则:

  • 创建引用类型时,runtime会为其分配两个空间,一块空间分配在堆上,存储引用类型本身的数据,另一个块空间分配在栈上,存储对堆上数据的引用(实际上存储的堆上的内存地址,也就是指针)。
  • 创建值类型时, runtime会为其分配一个空间,这个空间分配在变量创建的地方,如果值类型是在方法内部创建,则跟随方法入栈,分配到栈上存储;如果值类型是引用类型的成员变量,则跟随引用类型,存储在堆上。
2.举例说明

定义一个Point类:

 public class Point
   {
        public double PointX { get; set; }
        public double PointY { get; set; }   
   }

StartProgram类,有方法Start()和InitialPoint():

class StartProgram
    {
        void Start()
        {
            double pointX = 100.1;
            InitialPoint(pointX);
        }
        void InitialPoint(double pointX)
        {
            var point = new Point();
            point.PointX = pointX;
        }
    }

示例分析:假设主线程从Start()进入执行,我们从分析一下方法中的变量在内存中的大致分配情况,不深究细节。

首先将Start()方法指令压入栈底,然后压入局部变量pointX;紧接着将InitialPoint()方法压入栈底,形参pointX压入栈底,在堆上实例化Point对象(包括其成员变量PointX和PointY),并在栈上创建point变量指向堆上的Point对象,最后给成员变量PointX赋值,参考图如下:


注:注意不要混淆code中的pointx,虽然变量名相同,但是它们是不同的变量。
3.按值传递原则

在C#中数据传递默认按值传递,先看一个示例。现在有一个结构体PointSturct, 一个类PointClass:

  public struct PointStruct
    {
        public double PointX { get; set; }
        public double PointY { get; set; }   
    }
  public class PointClass
    {
        public double PointX { get; set; }
        public double PointY { get; set; }   
    }

并在一个方法中执行执行以下代码:

void Excute()
 {
      var pointStruct1 = new PointStruct();
      var pointClass1 = new PointClass();
      var pointStruct2 = pointStruct1;
      var pointClass2 = pointClass1;
  }

示例分析:第3,4行代码分别创建了一个结构体pointStruct1和一个类实例pointClass1, 结合上面的内存分配规则,对于pointSturct1,会在栈上分配内存存储其数据本身,对于pointClass1,会在堆上分配内存存储实例,且在栈上存储指向实例的引用,参考图如下:

image.png

经过执行5,6行代码后,内存分配应该是怎样的呢? 对于值类型(pointStruct1),会在栈上开辟一块新的空间,将数据复制一份新的过去,因此pointStruct2和pointStruct1是互相独立的,对其中一个的修改不会影响到另一个;对于引用类型(pointClass1),也会在栈上开辟一个新的空间,将栈上的引用复制到新的空间, 但是注意,此处复制的是栈上存储的引用,也就是说栈上的两个变量pointClass1和pointClass2虽然是不同的空间,但是它们存储的引用, 都是指向堆上的同一实例,所以当通过pointClass2对实例的数据进行修改以后,通过pointClass1再访问实例的数据,将会是修改过的数据,反之亦然。对于复制引用,我们打个比方,假如把堆上的实例比作学校,A同学记录了学校的地址(引用),现在又来了B同学,复制引用就好比A同学把学校的地址抄了一份给B同学。参考图如下:
image.png

4.参数传递

当程序中进行参数传递的时候,也是默认按值传递,值类型复制数据本身,形成独立的数据块,引用类型复制引用,指向同一实例。

我们将之前的StartProgram类中的方法改成如下 :

class StartProgram
{
   void Start()
   {
      double pointX1 = 100.1;
      var point1 = new Point();
      point1.PointX = 200.1;
      InitialPoint(pointX1, point1);
      Console.WriteLine(string.Format("pointX1:{0}", pointX1));
      Console.WriteLine(string.Format("point1.PointX:{0}", point1.PointX));
      Console.ReadKey();
    }
    void InitialPoint(double pointX2, Point point2)
    {
       pointX2 = 300.1;
       point2.PointX = pointX2;
    }
 }
/*Output:pointX1:100.1
         point1.PointX:300.1  */

示例分析:从输出结果可以看到,pointX1还是原来的值,没有受到pointX2影响,而point1.PointX的值是point2对PointX更改后的值。在内存中,将值类型pointX1传递给pointX2后,在栈上形成两个独立的内存块,因此对pointX2更改后,并不会影响到pointX1;而对于引用类型point1,传递给point2后,它们两块内存存储的引用指向同一实例,因此再InitialPoint()方法内对point2.PointX赋值为300.1后,再Start()方法里面取point1取PointX的值,也是300.1。

既然point1和point2指向同一实例,那么如果我们在InitialPoint()方法的最后将point2设置为null,会不会影响到Start()方法里的point1呢?用point.PointX取值的时候,会不会得到实例为null的异常呢?

void InitialPoint(double pointX2, Point point2)
 {
    pointX2 = 300.1;
    point2.PointX = pointX2;
    point2 = null;
 }
 /*Output:pointX1:100.1
          point1.PointX:300.1 
 */

示例分析:还是会得到之前的结果,没有检测到null异常。这是因为point2设置为null的含义是,并不是将堆上的实例变为null,而是设置栈上的引用为null,注意,这和上一句代码point2.PointX = pointX2是有区别的,上一句代码的含义是,通过point2引用找到堆上的实例,对其属性PointX进行更新。将point2设置为null后,point1仍然指向堆上的实例,因此可以访问到更新后的实例属性值。

我们也用上面的学校作类比,学校表示堆上的实例,A同学和B同学都有学校的地址(引用),将point2设置为null,就相当于销毁B同学的地址,让B同学找不到学校了,但是A同学仍然可以去学校,以及可以看到B同学之前在学校完成的作业(point2设置为null之前对实例数据的更新)。

image.png
5.按引用传递(Ref和Out关键字)

注:Ref和Out的区别在于Ref在传递前需要初始化。

我们知道C#中的Ref和Out关键字可以在值类型的传参上实现跟引用类型一样的效果,那么在引用类型参数上加入ref和out关键字跟默认的引用类型传参有什么区别呢?很多人觉得应该没有什么用,其实不然,我们继续将StartProgram类的方法改为按ref传递,看看会有什么不同。

class StartProgram
{void Start()
    {
        double pointX1 = 100.1;
        var point1 = new Point();
        point1.PointX = 200.1;
        InitialPoint(ref pointX1, ref point1);
        Console.WriteLine(string.Format("pointX1:{0}", pointX1));
        if (point1 != null) Console.WriteLine(
        string.Format("point1.PointX:{0}", point1.PointX));
        else Console.WriteLine(string.Format("point1 is null"));
        Console.ReadKey();
    }
    void InitialPoint(ref double pointX2, ref Point point2)
    {
        pointX2 = 300.1;
        point2.PointX = pointX2;
        point2 = null;
    }
    /*Output:             pointX1:300.1
             point1 is null
    */ }

示例分析:从运行结果可以看到,对于值类型, pointX2对值的更改影响到了pointX1;对于引用类型,将point2设置为null后,point1也变成了null,之前我们没有加ref参数的时候,point2设置为null,并不会影响到point1本身。我们可以看到,通过加入ref和out参数后,在内存中并不是像值传递一样将栈上的数据拷贝一份到新的空间。在这里,我并没有去研究C#对ref和out参数在内存上的实现原理,有兴趣的可以深入研究。

三、C#中ref和out的区别浅析

在C#中通过使用方法来获取返回值时,通常只能得到一个返回值。因此,当一个方法需要返回多个值的时候,就需要用到ref和out,那么这两个方法区别在哪儿呢?

案例:定义一个方法,求一个整数数组中的最大值,最小值,和,平均数。如果是一个方法只能有一个返回值,那只能每一个都得定义一个方法来实现,不过有了ref和out这实现起来就方便多了。

1.ref
static int GetIntResult(int[] arry, ref float avg, ref int max, ref int min)
{
    int sum = 0;
    max = arry[0];
    min = arry[0];
    for (int i = 0; i < arry.Length; i++)
    {
        sum += arry[i];
       
        if (max < arry[i])
        {
            max = arry[i];
        }
        if (min > arry[i])
        {
            min = arry[i];
        }
    }
    avg = sum / arry.Length;
    return sum;
}

然后在控制台中试着调用该方法:

static void Main(string[] args)
{
    int[] arr = { 1,2,3,4,5,6,7,8,9};
    float avg;
    int max;
    int min;
    int sum = GetIntResult(arr, ref avg, ref max, ref min);
}

此时编译器就会提示画红线,错误:使用了未赋值的avg,max,min

static void Main(string[] args)
{
    int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    float avg = 0;
    int max = 0;
    int min = 0;
    int sum = GetIntResult(arr, ref avg, ref max, ref min);
    Console.WriteLine("和:{0}\t平均值:{1}\t
    最大值:{2}\t最小值:{3}", sum, avg, max, min);
    Console.Read();
}

ref这个关键字告诉c#编译器被传递的参数值指向与调用代码中变量相同的内存。这样,如果被调用的方法修改了这些值然后返回的话,调用代码的变量也就被修改了。

ref 关键字使参数按引用传递。其效果是,当控制权传递回调用方法时,在方法中对参数所做的任何更改都将反映在该变量中(avg,max,min的初始值为0,调用方法后值改变)。若要使用 ref 参数,则方法定义和调用方法都必须显式使用 ref 关键字。

2.out

换成out之后,上面的方法不再适用,报错,错误 : 控制离开当前方法之前必须对 out 参数“min”和"max"赋值。你会发现这里max和min在循环外并未初始化。所以才会出错。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 

namespace Wolfy.RefAndOut
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
            //在使用out关键字时,不需要在此处初始化,
            //初始化也不会影响到方法内部的值,所以你初始化没用
            float avg;
            int max;
            int min;
            int sum = GetIntResult(arr, out avg, out max, out min);
            Console.WriteLine("和:{0}\t平均值:{1}\t最大值:{2}\t最小值:{3}", sum, avg, max, min);
            Console.Read();
        }
        static int GetIntResult(int[] arry, out float avg, out int max, out int min)
        {
            //使用out关键字时,必须在离开方法前对out关键字修饰的参数初始化
            int sum = 0;
            max = arry[0];
            min = arry[0];
            for (int i = 0; i < arry.Length; i++)
            {
                sum += arry[i];
               
                if (max < arry[i])
                {
                    max = arry[i];
                }
                if (min > arry[i])
                {
                    min = arry[i];
                }
            }
            avg = sum / arry.Length;
            return sum;
        }
    }
}

out 关键字会导致参数通过引用来传递。这与 ref 关键字类似,不同之处在于 ref 要求变量必须在传递之前进行初始化。若要使用 out 参数,方法定义和调用方法都必须显式使用 out 关键字。

四、C#详解值类型和引用类型区别

在C#中值类型的变量直接存储数据,而引用类型的变量持有的是数据的引用,数据存储在数据堆中。

值类型(value type):byte,short,int,long,float,double,decimal,char,bool 和 struct 统称为值类型。值类型变量声明后,不管是否已经赋值,编译器为其分配内存。

引用类型(reference type):string 和 class统称为引用类型。当声明一个类时,只在栈中分配一小片内存用于容纳一个地址,而此时并没有为其分配堆上的内存空间。当使用 new 创建一个类的实例时,分配堆上的空间,并把堆上空间的地址保存到栈上分配的小片空间中。

值类型包括:数值类型,结构体,bool型,用户定义的结构体,枚举,可空类型。
引用类型包括:数组,用户定义的类、接口、委托,object,字符串,null类型,类

所有的值类型都是密封(seal)的,所以无法派生出新的值类型。

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

  • 引用类型与值类型相同的是,结构体也可以实现接口;
  • 引用类型可以派生出新的类型,而值类型不能;
  • 引用类型可以包含null值,值类型不能(可空类型功能允许将 null 赋给值类型);
  • 引用类型变量的赋值只复制对对象的引用,而不复制对象本身。而将一个值类型变量赋给另一个值类型变量时,将复制包含的值。

对于最后一条,经常混淆的是string。我曾经在一本书的一个早期版本上看到String变量比string变量效率高;我还经常听说String是引用类型,string是值类型,等等。例如:

string s1 = "Hello, " ;
string s2 = "world!" ;
string s3 = s1 + s2;//s3 is "Hello, world!"

这确实看起来像一个值类型的赋值。再如:

string s1 = "a" ;
string s2 = s1 ;
s1 = "b";//s2 is still "a"

改变s1的值对s2没有影响。这更使string看起来像值类型。实际上,这是运算符重载的结果,当s1被改变时,.NET在托管堆上为s1重新分配了内存。这样的目的,是为了将做为引用类型的string实现为通常语义下的字符串。

五、一文读懂C#的 堆、栈、值类型、引用类型
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容