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

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#高级编程)。所谓不可变,包括了在内存中的大小不可变。大小一旦固定,修改其内容的方法和运算符实际上都是创建一个新对象,并分配新的内存空间,因为之前的大小可能不合适。究其根本,这是一个‘=’运算符的重载。

时间: 2024-08-24 10:21:34

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

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

6个重要的.NET概念:栈,堆,值类型,引用类型,装箱,拆箱 引言 本篇文章主要介绍.NET中6个重要的概念:栈,堆,值类型,引用类型,装箱,拆箱.文章开始介绍当你声明一个变量时,编译器内部发生了什么,然后介绍两个重要的概念:栈和堆:最后介绍值类型和引用类型,并说明一些有关它们的重要原理. 最后通过一个简单的示例代码说明装箱拆箱带来的性能损耗. 声明变量的内部机制 在.NET程序中,当你声明一个变量,将在内存中分配一块内存.这块内存分为三部分:1,变量名:2,变量类型:3,变量值. 下图揭示了声

包装类型、装箱拆箱、基本类型速度比较

首先是包装类型 Long sum = Long.valueOf(0); long t1 = System.currentTimeMillis(); for (Long i = Long.valueOf(0); i < Integer.MAX_VALUE/2; i++) { sum += i; } t1 = System.currentTimeMillis() - t1; System.out.println("packaging took "+ t1 +" sum =

NET中的类型和装箱/拆箱原理

谈到装箱拆箱,DebugLZQ相信给位园子里的博友一定可以娓娓道来,大概的意思就是值类型和引用类型的相互转换呗---值类型到引用类型叫装箱,反之则叫拆箱.这当然没有问题,可是你只知道这么多,那么DebugLZQ建议你花点时间看看楼主这篇文章,继续前几篇博文的风格--浅谈杂侃. 1. .NET中的类型 为了说明装箱和拆箱,那首先必须先说类型.在.NET中,我们知道System.Object类型是所有内建类型的基类.注意这里说的是内建类型,程序员可以编写不继承子自System.Object的类型,这

值类型的装箱与拆箱浅析

值类型的装箱与拆箱浅析 2012-02-23 15:47 by 秋梧, ... 阅读, ... 评论, 收藏, 编辑 阅读目录 前言 值类型的装箱 值类型的拆箱 装箱和拆箱实例 结束语 前言 在.Net 中值类型向引用类型的转换以及从引用类型到值类型的转换是需要装箱(boxing)和拆箱(unboxing)的,这是因为值类型是比引用类型更轻型的一种类型,因为他们不想对象那样在托管队中分配,不会被GC收集,而且不需要通过指针来引用.但是在许多情况下都需要获取对值类型的一个实例的引用.对于在值类型与

值类型的装箱和拆箱

在CLR中为了将一个值类型转换成一个引用类型,要使用一个名为装箱的机制. 下面总结了对值类型的一个实例进行装箱操作时内部发生的事: 1)在托管堆中分配好内存.分配的内存量是值类型的各个字段需要的内存量加上托管堆上的所有对象都有的两个额外成员(类型对象指针和同步块索引)需要的内存量. 2)值类型的字段复制到新的分配的堆内存. 3)返回对象的地址.现在,这个地址是对一个对象的引用,值类型现在是一个引用类型. 拆箱不是直接将装箱过程倒过来.拆箱的代价比装箱低得多.拆箱其实就是一个获取一个指针的过程,该

java内存管理的分析

java 中的内存分为四个部分: stack(栈):存放基本类型的数据和对象的引用,即存放局部变量. Note: 如果存放的是基本类型数据(非静态变量),则直接将变量名和值存入stack中. 如果存放的是引用类型,则将变量名存入栈,然后指向它new出的对象(存放在堆中). heap(堆)存放 new 出来的东西. data segment(数据区):分为静态区和常量区(常量池) 静态区(static segment): 存放在对象中用 static 定义的静态成员(即静态变量,如果该静态变量是基

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

C# 基础接口篇 一.多态复习 使用个new来实现,使用virtual与override    -->new隐藏父类方法 根据当前类型,电泳对应的方法(成员)    -->override重写 无论什么情况,都是执行新的方法(成员) 继承是实现多态的一个前提,没有继承多态是不能实现的 父类与子类实现多态 抽象类与子类实现 抽象类不能实例化 抽象类中的抽象方法没有方法体 抽象类的成员有哪些   ->包含非抽象成员   ->不能实例化   ->子类必须实现父类的 抽象方法,除非子

js:值类型/引用类型/内存回收/函数传值

把这4个概念放在一起写,因为它们是互通的 值类型:一个变量对应一块内存 var a=1; var b=a; a=2; 此时b还是等于1 就像你的克隆人,你心情不好去跳崖,他才不会傻乎乎地跟着你去跳 数值.boolean.null.undefined都是值类型 引用类型:有的博主这样比喻,一家店,它的引用就是它的钥匙 鉴于“作的精神”,我换一种比喻 一台电视机(内存)和它的遥控器关系(引用变量) 可以用遥控器换频道,但不可以用遥控器把电视变成冰箱 如果这电视不只一个遥控器,那么它们可以共同控制电视

值类型&amp;引用类型,装箱&amp;拆箱

值类型:声明一个值类型变量,会在栈上分配一个空间,空间里存储的就是变量的值引用类型:声明一个引用类型变量,会在栈中分配一个空间,存储一个引用,这个引用指向了一个托管堆. 值类型:struct,枚举,数值类型,bool类型引用类型:数组,类,接口,委托(delegate),Object,string 可以看下下面的例子 public class Person { public string Name { get; set; } public int Age { get; set; } } publ