C# 的类型系统可分为两种类型,一是值类型,一是引用类型,这个每一个C#程序员都了解。还有托管堆,栈,ref,out等等概念也是每一个C#程序员都会接触到的概念,也是C#程序员面试常常考到的知识,随便搜搜也有无数的文章讲解相关的概念,貌似没写一篇值类型,引用类型相关博客的不是好的C#程序员。我也凑个热闹,试图完全讲明白相关的概念。html
要完全搞明白那一堆概念及其它们之间的关系彷佛并非一件容易的事,这是由于大部分C#程序员并不了解托管堆(简称“堆”)和线程栈(简称“栈”),或者知道它们,但了解得并不深刻,只知道:引用类型保存在托管堆里,而值类型“一般”保存在栈里。要搞明白那一堆概念的关系,我认为先要明白程序执行的基本原理,从而理解栈和托管堆的做用,才能理清它们的关系。考虑下面代码,Main调用Method1,Method1调用Method2:程序员
class Program { static void Main(string[] args) { var num = 120; Method1(num); } static void Method1(int num) { var num2 = num + 250; 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,多线程等等概念,只保留对象内存分配相关概念的话,程序的执行能够简单总结为以下:3d
程序由Main方法进入执行,并不断重复着“定义局部变量,调用方法(可能会传参),从方法返回”,最后从Main方法退出。在程序执行过程当中,不断压入参数和局部变量到线程栈里,也不断的出栈。指针
注意,其实压入栈的还有方法的返回地址等,这里忽略了。htm
上面的例子我只用了一种简单的int值类型,目的是为了只关注线程栈的压栈(生长)和出栈(消亡)。很明显C#还有种引用类型,引入引用类型,再考虑上面的问题,看下面代码:对象
static void Main(string[] args) { var user = new User { Age = 15 }; var num = 23; Console.WriteLine(user.Age); Console.WriteLine(num); } class User { public int Age; }
我想不少人都应该知道,这时应该引入托管堆的概念了,但这里我想跟上面同样,先从栈的角度去考虑问题,因此在调用WriteLine前,“内存图”应该是这样的(地址是乱写的):blog
这也就是人们常说的:对于引用类型,栈里保存的是指向在堆里的实例对象的地址(指针,引用)。既然只是个地址,那么要获取一个对象的实例应该有一个根据地址或寻找对象的步骤,而事实正是这样,若是Console.WriteLine(num),这样获取栈里的num的值给WriteLine方法算一步的话,要获取上面user的实例对象,在运行时是要分两步的,也就是多了根据地址去寻找托管堆里实例对象的字段或方法的步骤。IL反编译上面的Main方法,删去一些无关代码后:
//load local 0=>获取局部变量0(是一个地址) IL_0012: ldloc.0 // 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.1 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很明显不是储存在栈,因此结论应该是:值类型的值是在它声明的位置存储的。即局部变量(参数)的值会在栈里,做为类型成员的话,会跟随对象。
固然,引用类型的值(实例对象)老是在托管堆里,这个结论是正确的。
C#有值类型和引用类型的区别,再有传参时有ref和out这两个关键字使得人们对相关概念的理解更加模糊。要理解这个问题,仍是要从栈的角度去理解。咱们分四种状况讨论:正常传递值类型,正常传递引用类型,ref(out)传递值类型,ref(out)传递引用类型。
注意,对于运行时来讲,ref和out是同样,它们的区别是C#编译器对它们的区别,ref要求初始化好,out没有要求。由于out没有要求初始化,因此被调用的方法不能读取out参数,且方法返回前必须赋值。
static void Main(string[] args) { var num = 120; Method1(num); Console.WriteLine(num);//输出=>120 } static void Method1(int num) { Console.WriteLine(num); num = 180; }
这种场景你们都熟悉,Method1的那句赋值是不起做用的,若是要画图的话,也跟上面第二幅图相似:
也就是说传参是把栈里的值复制到Method1的num参数,Method1操做的是本身的参数,对Main的局部变量彻底没有影响,即影响不到属于Main方法的栈里的数据。
static void Main(string[] args) { var user = new User(); user.Age = 15; Method2(user); Debug.Assert(user != null); Console.WriteLine(user.Age);//输出=> 18 } static void Method2(User user) { user.Age = 18; 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,第二个会获得一个新的数据(地址,指针,引用),第三个跟上面传参同样,都是栈数据复制。
static void Main(string[] args) { var num = 10; Method1(num); Console.WriteLine(num);//输出=> 10 Method3(ref num); Console.WriteLine(num);//输出=> 28 } static void Method1(int num) { Console.WriteLine(num); num = 18; } static void Method3(ref int num) { Console.WriteLine(num); num = 28; }
代码很简单,并且输出应该都很清楚,没有难度。ref的使用看似简单日常,背后实际上是C#为咱们作了大部分工做。画图的话,“栈图”以下(地址乱写):
看到这图,很多人应该迷惑了,Method3的参数明明写的是int类型的num,怎么在栈里倒是一个指针(地址,引用)呢?这其实C#“欺骗”了咱们,IL反编译看看:
能够看到,加了ref(out)的Method3编译出来的方法参数是不同,再来看看方法里对参数取值的IL代码:
//这是Method1的代码 //load arg 0=>读取索引0的参数,直接就是一个值 IL_0001: ldarg.0 //这是Method3的代码 //load arg 0=>读取索引0的参数,这是一个地址 IL_0001: ldarg.0 //将位于上面地址处的 int32 值做为 int32 加载到堆栈上。 IL_0002: ldind.i4
能够看到,一样是获取参数值给WriteLine,Method1只需一个指令,而Method3则须要2个,即多了一个根据地址去寻值的步骤。不难想到,赋值也有一样的区别:
//Method1 //把18放入栈中 IL_0008: ldc.i4.s 18 //store arg=> 把值赋给参数变量num IL_000a: starg.s num //Method3 //load arg 0=>读取索引0的参数,这是一个地址 IL_0009: ldarg.0 //把28放入栈中 IL_000a: ldc.i4.s 28 //在给定的地址存储 int32 值。 IL_000c: stind.i4
没错,虽然一样是num = 5这样一个对参数的赋值语句,有没有ref(out)关键字,实际上运行时发生的事情是不同的。有ref(out)的方法跟上面取值同样有给定地址而后去操做(这里是赋值)的指令。
看到这里你们应该明白,给参数加了ref(out)后,参数才是引用传递,这时传递的是栈地址(指针,引用),不然就是正常的值传递--栈数据复制。
加了ref(out)的引用类型的参数有什么奥秘,这个留给你们去思考。能够确定的是,仍是从栈的角度去考虑的话,跟值类型是没有区别的,都是传递栈地址。
我我的认为,貌似给引用类型加ref(out)没什么用处。
在考虑这一大堆概念问题时,咱们首先要搞明白程序执行的基本原理,只不过是栈的生长和消亡的过程。明白这个过程后,要学会“从栈的角度”去思考问题,那么不少事情将会迎刃而解。为何叫“值”类型和“引用”类型呢?其实这个“值”和“引用”是从栈的角度去考虑的,在栈里,值类型的数据就是值,引用类型在栈里只是一个地址(指针,引用)。还要注意到,变量除了能够是一个局部变量(参数)外,还能够做为一个类型的字段成员存在。知道这些后,“值类型的对象是存储在那里?”这些问题应该就一清二楚了。最后就是明白C#默认是按值传参的,也就是把栈里的数据赋值给参数,这跟在同一个方法内把一个变量赋值给同一类型的另外一个变量是同样的,而加了ref(out)为何这个神奇,实际上是C#背后作了更多的事情,编译成不一样的IL代码了。
参考:《CLR via C#》