-C#初学者经常被问的几道辨析题,值类型与引用类型,装箱与拆箱,堆栈,这几个概念组合之间区别,看完此篇应该可以解惑。
俗话说,用思想编程的是文艺程序猿,用经验编程的是普通程序猿,用复制粘贴编程的是2B程序猿,开个玩笑^_^。
相信有过C#面试经历的人,对下面这句话一定不陌生:
值类型直接存储其值,引用类型存储对值的引用,值类型存在堆栈上,引用类型存储在托管堆上,值类型转为引用类型叫做装箱,引用类型转为值类型叫拆箱。
但仅仅背过这句话是不够的。
C#程序员不必手工管理内存,但要编写高效的代码,就仍需理解后台发生的事情。
在学校的时候老师们最常说的一句话是:概念不清。最简单的例子,我熟记了所有的微积分公式,遇到题就套公式,但一样会有套不上解不出的,因为我根本不清楚公式是怎么推导出来的,基本的原理没弄清楚。
(有人死了,是为了让我们好好的活着;有人死了,也不让人好好活:牛顿和莱布尼茨=。=)。
有点扯远了。下面大家来跟我一起探讨下C#堆栈与托管堆的工作方式,深入到内存中来了解C#的以上几个基本概念。
一,stack与heap在不同领域的概念
在C/C++中:
Stack叫做栈区,由编译器自动分配释放,存放函数的参数值,局部变量的值等。
Heap则称之为堆区,由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收。
而在C#中:
Stack是指堆栈,Heap是指托管堆,不同语言叫法不同,概念稍有差别。(此处若有错误,请指正)。
这里最需要搞清楚的是在语言中stack与heap指的是内存中的某一个区域,区别于数据结构中的栈(后进先出的线性表),堆(经过某种排序的二叉树)。
讲一个概念之前,首先要说明它所处的背景。
若无特别说明,这篇文章讲的堆栈指的就是Stack,托管堆指的就是Heap。
二,C#堆栈的工作方式
Windwos使用虚拟寻址系统,把程序可用的内存地址映射到硬件内存中的实际地址,其作用是32位处理器上的每个进程都可以使用4GB的内存-无论计算机上有多少硬盘空间(在64位处理器上,这个数字更大些)。这4GB内存包含了程序的所有部份-可执行代码,加载的DLL,所有的变量。这4GB内存称为虚拟内存。
4GB的每个存储单元都是从0开始往上排的。要访问内存某个空间存储的值。就需要提供该存储单元的数字。在高级语言中,编译器会把我们可以理解的名称转换为处理器可以理解的内存地址。
在进程的虚拟内存中,有一个区域称为堆栈,用来存储值类型。另外在调用一个方法时,将使用堆栈复制传递给方法的所有参数。
我们注意一下C#中变量的作用域,如果变量a在变量b之前进入作用域,b就会先出作用域。看下面的例子:
{ int a; //do something { int b; //do something } }
声明了a之后,在内部代码块中声明了b,然后内部代码块终止,b就出了作用域,然后a才出作用域。在释放变量的时候,其顺序总是与给它们分配内存的顺序相反,后进先出,是不是让你想到了数据结构中的栈(LIFO--Last IN First Out)。这就是堆栈的工作方式。
我们不知道堆栈在地址空间的什么地方,其实C#开发是不需要知道这些的。
堆栈指针,一个由操作系统维护的变量,指向堆栈中下一个自由空间的地址。程序第一次运行时,堆栈指针就指向为堆栈保留的内存块的末尾。
堆栈是向下填充的,即从高地址向低地址填充。当数据入栈后,堆栈指针就会随之调整,指向下一个自由空间。我们来举个例子说明。
如图,堆栈指针800000,下一个自由空间是799999。下面的代码会告诉编译器需要一些存储单元来存储一个整数和一个双精度浮点数。
{ int a=1; double b = 1.1; //do something }
这两个都是值类型,自然是存储在堆栈中。声明a赋值1后,a进入作用域。int类型需要4个字节,a就存储在799996~799999上。此时,堆栈指针就减4,指向新的已用空间的末尾799996,下一个自由空间为799995。下一行声明b赋值1.1后,double需要占用8个字节,所以存储在799988~799995上,堆栈指针减去8。
当b出作用域时,计算机就知道这个变量已经不需要了。变量的生存期总是嵌套的,当b在作用域的时候,无论发生什么事情,都可以保证堆栈指针一直指向存储b的空间。
删除这个b变量的时候堆栈指针递增8,现在指向b曾经使用过的空间,此处就是放置闭合花括号的地方。然后a也出作用域,堆栈指针再递增4。
此时如果放入新的变量,从799999开始的存储单元就会被覆盖了。
二,托管堆的工作方式
堆栈有灰常高的性能,但要求变量的生命周期必须嵌套(后进先出决定的),在很多情况下,这种要求很过分。。。通常我们希望使用一个方法来分配内存,来存储一些数据,并在方法退出后很长的一段时间内数据仍是可用的。用new运算符来请求空间,就存在这种可能性-例如所有引用类型。这时候就要用到托管堆了。
如果看官们编写过需要管理低级内存的C++代码,就会很熟悉堆(heap),托管堆与C++使用的堆不同,它在垃圾收集器的控制下工作,与传统的堆相比有很显著的性能优势。
托管堆是进程可用4GB的另一个区域,我们用一个例子了解托管堆的工作原理和为引用数据类型分配内存。假设我们有一个Customer类。
1 void DoSomething() 2 { 3 Customer john; 4 john = new Customer();
5 }
第三行代码声明了一个Customer的引用john,在堆栈上给这个引用分配存储空间,但这只是一个引用,而不是实际的Customer对象。john引用包含了存储Customer对象的地址-需要4个字节把0~4GB之间的地址存储为一个整数-因此john引用占4个字节。
第四行代码首先分配托管堆上的内存,用来存储Customer实例,然后把变量john的值设置为分配给Customer对象的内存地址。
Customer是一个引用类型,因此是放在内存的托管堆中。为了方便讨论,假设Customer对象占用32字节,包括它的实例字段和.NET用于识别和管理其类实例的一些信息。为了在托管堆中找到一个存储新Customer对象的存储位置,.NET运行库会在堆中搜索一块连续的未使用的32字节的空间,假定其起始地址是200000。
john引用占堆栈的799996~799999位置。实例化john对象前内存应该是这样,如图。
给Customer对象分配空间后,内存内容如图。这里与堆栈不同,堆上的内存是向上分配的,所有自由空间都在已用空间的上面。
以上例子可以看出,建议引用变量的过程比建立值变量的过程复杂的多,且不能避免性能的降低-.NET运行库需要保持堆的信息状态,在堆添加新数据时,这些信息也需要更新(这个会在堆的垃圾收集机制中提到)。尽管有这么些性能损失,但还有一种机制,在给变量分配内存的时候,不会受到堆栈的限制:
把一个引用变量a的值赋给另一个相同类型的变量b,这两个引用变量就都引用同一个对象了。当变量b出作用域的时候,它会被堆栈删除,但它所引用的对象依然保留在堆上,因为还有一个变量a在引用这个对象。只有该对象的数据不再被任何变量引用时,它才会被删除。
这就是引用数据类型的强大之处,我们可以对数据的生存周期进行自主的控制,只要有对数据的引用,该数据就肯定存于堆上。
三,托管堆的垃圾收集
对象不再被引用时,会删除堆中已经不再被引用的对象。如果仅仅是这样,久而久之,堆上的自由空间就会分散开来,给新对象分配内存就会很难处理,.NET运行库必须搜索整个堆才能找到一块足够大的内存块来存储整个新对象。
但托管堆的垃圾收集器运行时,只要它释放了能释放的对象,就会压缩其他对象,把他们都推向堆的顶部,形成一个连续的块。在移动对象的时候,需要更新所有对象引用的地址,会有性能损失。但使用托管堆,就只需要读取堆指针的值,而不用搜索整个链接地址列表,来查找一个地方放置新数据。
因此在.NET下实例化对象要快得多,因为对象都被压缩到堆的相同内存区域,访问对象时交换的页面较少。Microsoft相信,尽管垃圾收集器需要做一些工作,修改它移动的所有对象引用,导致性能降低,但这样性能会得到弥补。
四,装箱与拆箱
有了上面的知识做铺垫,看下面一段代码
int i = 1; object o = i;//装箱 int j = (int)o;//拆箱
int i=1;在堆栈中分配了一个4个字节的空间来存储变量 i 。
object o=i;
装箱的过程: 首先在堆栈中分配一个4个字节的空间来存储引用变量 o,
然后在托管堆中分配了一定的空间来存储 i 的拷贝,这个空间会比 i 所占的空间稍大些,多了一个方法表指针和一个SyncBlockIndex,并返回该内存地址。
最后把这个地址赋值给变量o,o就是指向对象的引用了。o的值不论怎么变化,i 的值也不会变,相反你 i 的值变化,o也不会变,因为它们存储在不同的地方。
int j=int(o);
拆箱的过程:在堆栈分配4字节的空间保存变量J,拷贝o实例的值到j的内存,即赋值给j。
注意,只有装箱的对象才能拆箱,当o不是装箱后的int型时,如果执行上述代码,会抛出一个异常。
这里有一个警告,拆箱必须非常小心,确保该值变量有足够的空间存储拆箱后得到的值。
long a = 999999999; object b = a; int c = (int)b;
C#int只有32位,如果把64位的long值拆箱为int时,会产生一个InvalidCastExecption异常。
---------------------------------------------------------------我是分割线--------------------------------------------------------------
上述为个人理解,如果有任何问题,欢迎指正。希望这对各位看官理解一些基础概念有帮助。
根据_龙猫同学的提示,发现一个有趣的现象。我看来看下面一段代码,假设我们有个Member 类,字段有Name和Num:
Member member1 = new Member { Name = "Marry", Num = "001" }; Member member2 = member1; member1.Name = "John"; Console.WriteLine("member1.Name={0} member2.Name={1}",member1.Name,member2.Name); int i = 1; object o = i; object o2 = o; o = 2; Console.WriteLine("o={0} o2={1}", o, o2); string str1 = "Hello"; string str2 = str1; str1 = "Hello,World!"; Console.WriteLine("str1={0} str2={1}", str1, str2); Console.ReadKey();
按照我们之前的理论,member1和member2 引用的是堆里面的同一个对象,修改了其中一个,另一个必然也会改变。
所以首先输出应该是member1.Name=John member2.Name=John 这是毋庸置疑的。
那object和string是C#预定义的仅有的两个引用类型,结果会如何呢?
按推理来说,预期的结果会是o=2 o2=2 以及str1=Hello,World! str2=Hello,World!。运行一下,OMG,错咯。
结果是o=2 o2=1 以及str1=Hello,World! str2=Hello 。
这种现象的解释是,(正如_龙猫给出的链接中的解释)string类型比较特殊,因为一个string变量被创建之初,它在堆中所占的空间大小就已经确定了。
修改一个string变量,如str1 = "Hello,World!",就必须重新分配合适空间来存储更大的数据(较小时也会如此),即创建了新的对象,并更新str1储存的地址,指向新的对象。
所以str2依然指向之前的对象。str1指向的是新创建的对象,两者已是不同对象的引用。
至于object为什么会如此,我弄懂再说。。。可能因为身为两大预设引用类型,都是一个德行^_^
感谢_龙猫同学。不然我也也不会注意到这一点。
!回来了,其实哈,object和string果然是一个德行。object身为基类,它可以绑定所有的类型。比如先给他来个
int i=1; object o=i;
那显然,o所引用的对象在堆上占了4个字节多一些的大小(还有.NET用于识别和管理其类实例的一些信息:一个方法表指针和一个SyncBlockIndex),假设是6个字节。
如果现在又给o绑定个long类型呢?
o=(long)100000000;
如果只是把数据填充到原来的内存空间,这6个字节小庙恐怕容不下比8个字节还大的佛把。
只能重新分配新的空间来保存新的对象了。
string和object是两个一旦初始化,就不可变的类型。(参见C#高级编程)。所谓不可变,包括了在内存中的大小不可变。大小一旦固定,修改其内容的方法和运算符实际上都是创建一个新对象,并分配新的内存空间,因为之前的大小可能不合适。究其根本,这是一个‘=’运算符的重载。