C# 的类型系统可分为两种类型,一是值类型,一是引用类型,这个每个C#程序员都了解。还有托管堆,栈,ref,out等等概念也是每个C#程序员都会接触到的概念,也是C#程序员面试经常考到的知识,随便搜搜也有无数的文章讲解相关的概念,貌似没写一篇值类型,引用类型相关博客的不是好的C#程序员。我也凑个热闹,试图彻底讲明白相关的概念。

程序执行的原理

要彻底搞明白那一堆概念及其它们之间的关系似乎并不是一件容易的事,这是因为大部分C#程序员并不了解托管堆(简称“堆”)和线程栈(简称“栈”),或者知道它们,但了解得并不深入,只知道:引用类型保存在托管堆里,而值类型“通常”保存在栈里。要搞明白那一堆概念的关系,我认为先要明白程序执行的基本原理,从而理解栈和托管堆的作用,才能理清它们的关系。考虑下面代码,Main调用Method1,Method1调用Method2:

class Program
{
static void Main(string[] args)
{
var num = ;
Method1(num);
} static void Method1(int num)
{
var num2 = num + ;
Method2(num2);
Console.WriteLine(num);
} static void Method2(int i)
{
Console.WriteLine(i);
}
}

大家都知道Windows程序通常是多个线程的,这里不考虑多线程的问题。程序由Main方法进入开始执行,这时这个(主)线程会分配得到一个1M大小的只属于它自己的线程栈。这1M的的栈空间用于向方法传递参数,定义局部变量。所以在Main方法进入Method1前,大家心理面要有一个”内存图“:把num压入线程栈,如下图:

接着把num作为参数传入Method1方法,同样在Method1内定义一个局部变量num2,调用加方法得到最后的值,所以在进入Method2前,“内存图”如下,num是参数,num2是局部变量

接着调用Method2的过程雷同,然后退出Method2方法,回到上图的样子,再退出Method1方法,再回到第一副图的样子,然后退出程序,整个过程如下图:

所以去除那些if,for,多线程等等概念,只保留对象内存分配相关概念的话,程序的执行可以简单总结为如下:

程序由Main方法进入执行,并不断重复着“定义局部变量,调用方法(可能会传参),从方法返回”,最后从Main方法退出。在程序执行过程中,不断压入参数和局部变量到线程栈里,也不断的出栈。

注意,其实压入栈的还有方法的返回地址等,这里忽略了。

引用类型和堆

上面的例子我只用了一种简单的int值类型,目的是为了只关注线程栈的压栈(生长)和出栈(消亡)。很明显C#还有种引用类型,引入引用类型,再考虑上面的问题,看下面代码:

static void Main(string[] args)
{
var user = new User { Age = };
var num = ;
Console.WriteLine(user.Age);
Console.WriteLine(num);
} class User
{
public int Age;
}

我想很多人都应该知道,这时应该引入托管堆的概念了,但这里我想跟上面一样,先从栈的角度去考虑问题,所以在调用WriteLine前,“内存图”应该是这样的(地址是乱写的):

这也就是人们常说的:对于引用类型,栈里保存的是指向在堆里的实例对象的地址(指针,引用)。既然只是个地址,那么要获取一个对象的实例应该有一个根据地址或寻找对象的步骤,而事实正是这样,如果Console.WriteLine(num),这样获取栈里的num的值给WriteLine方法算一步的话,要获取上面user的实例对象,在运行时是要分两步的,也就是多了根据地址去寻找托管堆里实例对象的字段或方法的步骤。IL反编译上面的Main方法,删去一些无关代码后:

//load local 0=>获取局部变量0(是一个地址)
IL_0012: ldloc.
// load field => 将指定对象中字段的值推送到堆栈上。
IL_0013: ldfld int32 CILDemo.Program/User::Age
IL_0018: call void [mscorlib]System.Console::WriteLine(int32)
//load local 1=>获取局部变量1(是一个值)
IL_001e: ldloc.
IL_001f: call void [mscorlib]System.Console::WriteLine(int32)

第二个WriteLine方法前,只需要一个ldloc.1(load local 1)读取局部变量1指令即可获取值给WriteLine,而第一个WriteLine前需要两条指令完成这个任务,就是上面说的分两步。

当然,大家都知道对我们来说,这是透明的,所以很多人喜欢画这样的图去帮助理解,毕竟,我们是感觉不到那个0x0612ecb4地址存在的。

也有一种说法就是,引用类型分两段存储,一是在托管堆里的值(实例对象),二是持有它的引用的变量。对于局部变量(参数)来说,这个引用就在栈里,而作为类型的字段变量的话,引用会跟随这个对象。

字段和局部变量(参数)

上面图的托管堆,大家应该看到,作为值类型的Age的值是保存在托管堆里的,并不是保存在栈里,这也是很多C#新手所犯的错误:值类型的值都是保存在栈里。

很明显他们不知道这个结论是在我们上面讨论程序运行原理时,局部变量(参数)压栈和出栈时这个特定的场景下的结论。我们要搞清楚,就像上面代码一样,除了可以定义int类型的num这个局部变量存储23这个值外,我们还可以在一个类型里定义一个int类型Age字段成员来存储一个整形数字,这时这个Age很明显不是储存在栈,所以结论应该是:值类型的值是在它声明的位置存储的。即局部变量(参数)的值会在栈里,作为类型成员的话,会跟随对象。

当然,引用类型的值(实例对象)总是在托管堆里,这个结论是正确的。

ref和out

C#有值类型和引用类型的区别,再有传参时有ref和out这两个关键字使得人们对相关概念的理解更加模糊。要理解这个问题,还是要从栈的角度去理解。我们分四种情况讨论:正常传递值类型,正常传递引用类型,ref(out)传递值类型,ref(out)传递引用类型。

注意,对于运行时来说,ref和out是一样,它们的区别是C#编译器对它们的区别,ref要求初始化好,out没有要求。因为out没有要求初始化,所以被调用的方法不能读取out参数,且方法返回前必须赋值。

正常传递值类型

static void Main(string[] args)
{
var num = ;
Method1(num);
Console.WriteLine(num);//输出=>120
} static void Method1(int num)
{
Console.WriteLine(num);
num = ;
}

这种场景大家都熟悉,Method1的那句赋值是不起作用的,如果要画图的话,也跟上面第二幅图类似:

也就是说传参是把栈里的值复制到Method1的num参数,Method1操作的是自己的参数,对Main的局部变量完全没有影响,即影响不到属于Main方法的栈里的数据。

正常传递引用类型

static void Main(string[] args)
{
var user = new User();
user.Age = ;
Method2(user);
Debug.Assert(user != null);
Console.WriteLine(user.Age);//输出=> 18
} static void Method2(User user)
{
user.Age = ;
user = null;
}

留意这里的Method2的代码,把Age设为18,影响到了Main方法的user,而把user设为null却没有影响。要分析这个问题,还是要先从栈的角度去看,栈图如下(地址乱写):

看到第二幅图,大家应该大概明白了这个事实:无论值类型也好,引用类型也好,正常传参都是把栈里的值复制给参数,从栈的角度看的话,C#默认是按值传参的。

既然都是“按值传参”,那么引用类型为什么表现出可以影响到调用方法的局部变量这个跟值类型不同的表现呢?仔细想想也不难发现,这个不同的表现不是由传参方式不同引起的,而是值类型和引用类型的局部变量(参数)在内存的存储不同引起的。对于Main方法的局部变量user和Method2的参数user在栈里是各自储存的,栈里的数据(地址,指针,引用)互不影响,但它们都指向同一个在托管堆里的实例对象,而user.Age = 18这一句操作的正是对托管堆里的实例对象的操作,而不是栈里的数据(地址,指针,引用)。num = 180操作的是栈里的数据,而user.Age = 18却是托管堆,就是这样造成了不同的表现。

对于user = null这一句不会响应Main的局部变量,看了第三幅图应该也很容易明白,user = null跟user.Age = 18不一样,user = null是把栈里的数据(地址,指针,引用)设空,所以并不会影响Main的user。

这里再补充一下,对引用类型来说,var user = null,var user = new User(),user1 = user2都会影响栈里的数据(地址,指针,引用),第一个会设null,第二个会得到一个新的数据(地址,指针,引用),第三个跟上面传参一样,都是栈数据复制。

ref(out)传递值类型

static void Main(string[] args)
{
var num = ;
Method1(num);
Console.WriteLine(num);//输出=> 10
Method3(ref num);
Console.WriteLine(num);//输出=> 28
} static void Method1(int num)
{
Console.WriteLine(num);
num = ;
} static void Method3(ref int num)
{
Console.WriteLine(num);
num = ;
}

代码很简单,而且输出应该都很清楚,没有难度。ref的使用看似简单平常,背后其实是C#为我们做了大部分工作。画图的话,“栈图”如下(地址乱写):

看到这图,不少人应该迷惑了,Method3的参数明明写的是int类型的num,怎么在栈里却是一个指针(地址,引用)呢?这其实C#“欺骗”了我们,IL反编译看看:

可以看到,加了ref(out)的Method3编译出来的方法参数是不一样,再来看看方法里对参数取值的IL代码:

//这是Method1的代码
//load arg 0=>读取索引0的参数,直接就是一个值
IL_0001: ldarg. //这是Method3的代码
//load arg 0=>读取索引0的参数,这是一个地址
IL_0001: ldarg.
//将位于上面地址处的 int32 值作为 int32 加载到堆栈上。
IL_0002: ldind.i4

可以看到,同样是获取参数值给WriteLine,Method1只需一个指令,而Method3则需要2个,即多了一个根据地址去寻值的步骤。不难想到,赋值也有同样的区别:

//Method1
//把18放入栈中
IL_0008: ldc.i4.s
//store arg=> 把值赋给参数变量num
IL_000a: starg.s num //Method3
//load arg 0=>读取索引0的参数,这是一个地址
IL_0009: ldarg.
//把28放入栈中
IL_000a: ldc.i4.s
//在给定的地址存储 int32 值。
IL_000c: stind.i4

没错,虽然同样是num = 5这样一个对参数的赋值语句,有没有ref(out)关键字,实际上运行时发生的事情是不一样的。有ref(out)的方法跟上面取值一样有给定地址然后去操作(这里是赋值)的指令。

看到这里大家应该明白,给参数加了ref(out)后,参数才是引用传递,这时传递的是栈地址(指针,引用),否则就是正常的值传递--栈数据复制。

ref(out)传递引用类型

加了ref(out)的引用类型的参数有什么奥秘,这个留给大家去思考。可以肯定的是,还是从栈的角度去考虑的话,跟值类型是没有区别的,都是传递栈地址。

我个人认为,貌似给引用类型加ref(out)没什么用处。

总结

在考虑这一大堆概念问题时,我们首先要搞明白程序执行的基本原理,只不过是栈的生长和消亡的过程。明白这个过程后,要学会“从栈的角度”去思考问题,那么很多事情将会迎刃而解。为什么叫“值”类型和“引用”类型呢?其实这个“值”和“引用”是从栈的角度去考虑的,在栈里,值类型的数据就是值,引用类型在栈里只是一个地址(指针,引用)。还要注意到,变量除了可以是一个局部变量(参数)外,还可以作为一个类型的字段成员存在。知道这些后,“值类型的对象是存储在那里?”这些问题应该就一清二楚了。最后就是明白C#默认是按值传参的,也就是把栈里的数据赋值给参数,这跟在同一个方法内把一个变量赋值给同一类型的另一个变量是一样的,而加了ref(out)为什么这个神奇,其实是C#背后做了更多的事情,编译成不同的IL代码了。

参考:《CLR via C#》

来自:http://www.cnblogs.com/lemontea/p/3159282.html

转:图解C#的值类型,引用类型,栈,堆,ref,out的更多相关文章

  1. 图解C#的值类型,引用类型,栈,堆,ref,out

    C# 的类型系统可分为两种类型,一是值类型,一是引用类型,这个每个C#程序员都了解.还有托管堆,栈,ref,out等等概念也是每个C#程序员都会接触到的概念,也是C#程序员面试经常考到的知识,随便搜搜 ...

  2. C#【堆与栈 值类型 引用类型】

    先说C#中值类型和引用类型 概念: 1.值类型:数据存储在内存的堆栈中,从堆栈中可以快速地访问这些数据,因此,值类型表示实际的数据. 2.引用类型:表示指向存储在内存堆中的数据的指针或引用(包括类.接 ...

  3. 数往知来C#之接口 值类型与引用类型 静态非静态 异常处理 GC垃圾回收 值类型引用类型内存分配<四>

    C# 基础接口篇 一.多态复习 使用个new来实现,使用virtual与override    -->new隐藏父类方法 根据当前类型,电泳对应的方法(成员)    -->override ...

  4. 第四篇 .NET高级技术之结构体及值类型引用类型

    结构体 在平时的开发中很少自己去写结构体,他是一种值类型的数据.对于结构,不像类那样存在继承,一个结构体不能从另一个结构或类继承.但是结构体从基类Object继承. 语法: //struct是定义结构 ...

  5. 30天C#基础巩固-----值类型/引用类型,泛型,空合并操作符(??),匿名方法

    一:值类型/引用类型的区别      值类型主要包括简单类型,枚举类型,和结构体类型等,值类型的实例通常被分配在线程堆栈上面变量保存的内容是实例数据本身.引用类型被分配在托管堆上,变量保存的是地址.引 ...

  6. 6个重要的.NET概念:栈,堆,值类型,引用类型,装箱,拆箱

    引言 本篇文章主要介绍.NET中6个重要的概念:栈,堆,值类型,引用类型,装箱,拆箱.文章开始介绍当你声明一个变量时,编译器内部发生了什么,然后介绍两个重要的概念:栈和堆:最后介绍值类型和引用类型,并 ...

  7. [No0000136]6个重要的.NET概念:栈,堆,值类型,引用类型,装箱,拆箱

    引言 本篇文章主要介绍.NET中6个重要的概念:栈,堆,值类型,引用类型,装箱,拆箱.文章开始介绍当你声明一个变量时,编译器内部发生了什么,然后介绍两个重要的概念:栈和堆:最后介绍值类型和引用类型,并 ...

  8. js的基础(平民理解的执行上下文/调用堆栈/内存栈/值类型/引用类型)

    与以前的切图比较,现在的前端开发对js的要求似乎越来越高,在开发中,我们不仅仅是要知道如何运用现有的框架(react/vue/ng), 而且我们对一些基础的知识的依赖越来越大. 现在我们就用平民的方法 ...

  9. 深入C#内存管理来分析值类型&引用类型,装箱&拆箱,堆栈几个概念组合之间的区别

    C#初学者经常被问的几道辨析题,值类型与引用类型,装箱与拆箱,堆栈,这几个概念组合之间区别,看完此篇应该可以解惑. 俗话说,用思想编程的是文艺程序猿,用经验编程的是普通程序猿,用复制粘贴编程的是2B程 ...

随机推荐

  1. 蜕变成蝶~Linux设备驱动之按键设备驱动

    在上述的驱动系列博客中,我们已经了解了关于阻塞和非阻塞.异步通知.轮询.内存和I/O口访问.并发控制等知识,按键设备驱动相对来说是比较简单的,本章内容可以加深我们对字符设备驱动架构.阻塞与非阻塞.中断 ...

  2. 使用Docker的macvlan为容器提供桥接网络及跨主机通讯

    对于了解Docker容器网络的朋友,我想对虚拟机的网络也不会陌生,毕竟我们是跟随这个时代一起学习和进步的人.相比VM,Docker的网络也在逐步走向成熟,本文主要针对其中的macvlan做下简单的介绍 ...

  3. 六、Sql Server 基础培训《进度6-更新删除(实际操作)》

    知识点: 假设,创建表test1,test2. drop table test1 create table test1 ( FID int identity(1,1), FBillNo varchar ...

  4. 【Jenkins】Jenkins安装

    下载rpm包 wget http://pkg.jenkins-ci.org/redhat-stable/jenkins-2.7.3-1.1.noarch.rpm 安装 rpm -ivh jenkins ...

  5. LeetCode - Subarray sum equals k

    Given an array of integers and an integer k, you need to find the total number of continuous subarra ...

  6. 盘点 React 16.0 ~ 16.5 主要更新及其应用

    目录 0. 生命周期函数的更新 1. 全新的 Content API 2. React Strict Mode 3. Portal 4. Refs 5. Fragment 6. 其他 7. 总结 生命 ...

  7. 机器学习 —— 深度学习 —— 基于DAGNN的MNIST NET

    DAGNN 是Directed acyclic graph neural network 缩写,也就有向图非循环神经网络.我使用的是由MatConvNet 提供的DAGNN API.选择这套API作为 ...

  8. [原]openstack-kilo--issue(二十三)虚拟机状态错误power_status为shutdonw或者vm_status为error

    问题点:虚拟机由于存储不足出现了错误标识 主要显示为状态错误: 1)  vm_status 显示为 error 2) power_status 显示为 shutdown 解决方案: 更改表 nova. ...

  9. 用vue制作饿了么首页(1)

    无论是静态网页还是动态交互网页,实现原则是将他们分块,然后各个击破. 很明显的饿了么首页分为三个部分(组件), 上面的头部(商家信息), 中间路由 购物车 每部分先占住自己位置,然后挨个将这三部分分别 ...

  10. python os module

    os 模块提供了非常丰富的方法用来处理文件和目录.常用的方法如下表所示: 序号                                                              ...