• C#基础知识之值类型和引用类型、堆和栈、装箱和拆箱


    一、概述 

    值类型直接存储其值,引用类型存储对值的引用,值类型存在堆栈上,引用类型存储在托管堆上,值类型转为引用类型叫做装箱,引用类型转为值类型叫拆箱。

    二、值类型和引用类型

     C#值类型数据直接在他自身分配到的内存中存储数据,而C#引用类型只是包含指向存储数据位置的指针。 C#值类型,我们可以把他归纳成三类:

    值类型 基础数据类型(除string) 浮点型  float 和 double
    十进制型 decimal
    布尔型 bool
    整型 sbyte、byte、char、short、ushort、int、uint、long、ulong 
    结构类型 struct
    枚举类型 enum
    引用类型 string、class、interface、delegate、object、string、Array


    所有值类型的数据都无法为null的,引用类型才允许为null。值类型是可以表示成可空类型的,可空类型可以为null。

    三、堆栈(Stack)和托管堆(Heap)

    1、堆栈stack:堆栈中存储值类型。

    (1)特点

    • 堆栈实际上是自上向下填充的,即由高内存地址指向低内存地址填充。
    • 堆栈的工作方式是先分配的内存变量后释放(先进后出原则)。堆栈中的变量是从下向上释放,这样就保证了堆栈中先进后出的规则不与变量的生命周期起冲突!
    • 堆栈的性能非常高,但是对于所有的变量来说还不太灵活,而且变量的生命周期必须嵌套。
    • 通常我们希望使用一种方法分配内存来存储数据,并且方法退出后很长一段时间内数据仍然可以使用。此时就要用到堆(托管堆)

    (2)C#堆栈的工作方式

      Windwos使用虚拟寻址系统,把程序可用的内存地址映射到硬件内存中的实际地址,其作用是32位处理器上的每个进程都可以使用4GB的内存-无论计算机上有多少硬盘空间(在64位处理器上,这个数字更大些)。这4GB内存包含了程序的所有部份-可执行代码,加载的DLL,所有的变量。这4GB内存称为虚拟内存。4GB的每个存储单元都是从0开始往上排的。要访问内存某个空间存储的值。就需要提供该存储单元的数字。在高级语言中,编译器会把我们可以理解的名称转换为处理器可以理解的内存地址。在进程的虚拟内存中,有一个区域称为堆栈,用来存储值类型。另外在调用一个方法时,将使用堆栈复制传递给方法的所有参数。我们来看一下下面的小例子:

    public void Test()
    {
        int a;
        ///do something
        {
            int b;
            ///do something
        }
    }    

    声明了a之后,在内部代码块中声明了b,然后内部代码块终止,b就出了作用域,然后a才出作用域。在释放变量的时候,其顺序总是与给它们分配内存的顺序相反,后进先出,这就是堆栈的工作方式。堆栈是向下填充的,即从高地址向低地址填充。当数据入栈后,堆栈指针就会随之调整,指向下一个自由空间。我们来举个例子说明。

    如图,假如堆栈指针2000,下一个自由空间是1999。下面的代码会告诉编译器需要一些存储单元来存储一个整数和一个双精度浮点数。

                int c = 2;
                double d=3.5;
                ///do something

    这两个都是值类型,自然是存储在堆栈中。声明c赋值2后,c进入作用域。int类型需要4个字节,c就存储在1996~1999上。此时,堆栈指针就减4,指向新的已用空间的末尾1996,下一个自由空间为1995。下一行声明d赋值3.5后,double需要占用8个字节,所以存储在1988~1995上,堆栈指针减去8。当d出作用域时,计算机就知道这个变量已经不需要了。变量的生存期总是嵌套的,当d在作用域的时候,无论发生什么事情,都可以保证堆栈指针一直指向存储d的空间。删除这个d变量的时候堆栈指针递增8,现在指向d曾经使用过的空间,此处就是放置闭合花括号的地方。然后c也出作用域,堆栈指针再递增4。此时如果放入新的变量,从1999开始的存储单元就会被覆盖了。

    2、托管堆heap):存储引用类型  

    与堆栈不同,托管堆是从下往上分配,所以自由的空间都在已用空间的上面。

    (1)托管堆的工作方式

    堆栈有很高的性能,但要求变量的生命周期必须嵌套(后进先出)。通常我们希望使用一个方法来分配内存,来存储一些数据,并在方法退出后很长的一段时间内数据仍是可用的。用new运算符来请求空间,就存在这种可能性-例如所有引用类型。这时候就要用到托管堆了。托管堆是进程可用4GB的另一个区域,我们用一个例子了解托管堆的工作原理和为引用数据类型分配内存。假设我们有一个Cat类。

    public class Cat
    {
      public string Name { get; set; }
    }

    来看下面这个最简单的方法

    public void Test()
    {
      Cat cat;
      cat = new Cat();
    }

    第三行代码声明了一个Cat的引用cat,在堆栈上给这个引用分配存储空间,但这只是一个引用,而不是实际的Cat对象。cat引用包含了存储Cat对象的地址-需要4个字节把0~4GB之间的地址存储为一个整数-因此cat引用占4个字节。第四行代码首先分配托管堆上的内存,用来存储Cat实例,然后把变量cat的值设置为分配给Cat对象的内存地址。Cat是一个引用类型,因此是放在内存的托管堆中。为了方便讨论,假设Cat对象占用32字节,包括它的实例字段和.NET用于识别和管理其类实例的一些信息。为了在托管堆中找到一个存储新Cat对象的存储位置,.NET运行库会在堆中搜索一块连续的未使用的32字节的空间,假定其起始地址是1000。而在堆栈中的内存地址的四个字节为:1996到1999。在实例化cat之前应该是这样的。

     

    cat实例化,给Cat对象分配空间之后,内存变化为  cat在堆栈中使用1996到1999的内存地址,然后对Cat对象分配空间之后。

    这里与堆栈不同,托管堆上的内存是向上分配的,所有自由空间都在已用空间的上面。

    以上例子可以看出,建议引用变量的过程比建立值变量的过程复杂的多,且不能避免性能的降低-.NET运行库需要保持堆的信息状态,在堆添加新数据时,这些信息也需要更新(这个会在堆的垃圾收集机制中提到)。尽管有这么些性能损失,但还有一种机制,在给变量分配内存的时候,不会受到堆栈的限制;把一个引用变量e的值赋给另一个相同类型的变量f,这两个引用变量就都引用同一个对象了。当变量f出作用域的时候,它会被堆栈删除,但它所引用的对象依然保留在堆上,因为还有一个变量e在引用这个对象。只有该对象的数据不再被任何变量引用时,它才会被删除。

    (2)托管堆的垃圾收集

      对象不再被引用时,会删除堆中已经不再被引用的对象。如果仅仅是这样,久而久之,堆上的自由空间就会分散开来,给新对象分配内存就会很难处理,.NET运行库必须搜索整个堆才能找到一块足够大的内存块来存储整个新对象。但托管堆的垃圾收集器运行时,只要它释放了能释放的对象,就会压缩其他对象,把他们都推向堆的顶部,形成一个连续的块。在移动对象的时候,需要更新所有对象引用的地址,会有性能损失。但使用托管堆,就只需要读取堆指针的值,而不用搜索整个链接地址列表,来查找一个地方放置新数据。因此在.NET下实例化对象要快得多,因为对象都被压缩到堆的相同内存区域,访问对象时交换的页面较少。Microsoft相信,尽管垃圾收集器需要做一些工作,修改它移动的所有对象引用,导致性能降低,但这样性能会得到弥补。

    三、装箱和拆箱

    1、概念

    装箱是将值类型转换为引用类型 ;拆箱是将引用类型转换为值类型。 利用装箱和拆箱功能,可通过允许值类型的任何值与Object 类型的值相互转换,将值类型与引用类型链接起来。

    代码如下:

    static void Main(string[] args)
    {    
        int val = 100; 
        object obj = val;//装箱操作
        int num = (int)obj;//拆箱操作
        Console.WriteLine("num = {0}", num); 
        Console.ReadLine();
     }

    2、为什么需要装箱

    调用一个含类型为Object的参数的方法,该Object可支持任意为型,以便通用。当你需要将一个值类型(如Int32)传入时,需要装箱。

    3、装箱/拆箱的内部操作

    装箱:  
      第一步:新分配托管堆内存
      第二步:将值类型的实例字段拷贝到新分配的内存中。
      第三步:返回托管堆中新分配对象的地址。这个地址就是一个指向对象的引用了。

    拆箱:

       第一步:在堆栈上开辟内存

       第二步:将引用类型的值拷贝到堆栈上

      拆箱必须非常小心,确保该值变量有足够的空间存储拆箱后得到的值。C#int只有32位,如果把64位的long值拆箱为int时,会产生一个InvalidCastExecption异常。

    总结:从原理上可以看出,装箱时,生成的是全新的引用对象,这会有时间损耗,也就是造成效率降低。装箱操作和拆箱操作是要额外耗费cpu和内存资源的,所以在c# 2.0之后引入了泛型来减少装箱操作和拆箱操作消耗。

    4、非泛型的装箱和拆箱以及泛型

    (1)非泛型装箱拆箱

    var array = new ArrayList();
    array.Add(1);
    array.Add(2);
    foreach (int value in array)
    {
        Console.WriteLine("value is {0}",value);
    }

     在这个过程中会发生两次装箱操作和两次拆箱操作,在向ArrayList中添加int类型元素时会发生装箱,在使用foreach枚举ArrayList中的int类型元素时会发生拆箱操作,将object类型转换成int类型。如果ArrayList的元素个数很多,执行装箱拆箱的操作会更多。所以尽量明确类型。

    (2)泛型装箱拆箱

    var list = new List<int>();
    list.Add(1);
    list.Add(2);
     
    foreach (int value in list)
    {
    Console.WriteLine("value is {0}", value);
    }

    代码和1中的代码的差别在于集合的类型使用了泛型的List,而非ArrayList。上述代码因为指定了类型,所以没有进行装箱和拆箱操作。可以看出泛型可以避免装箱拆箱带来的不必要的性能消耗;当然泛型的好处不止于此,泛型还可以增加程序的可读性,使程序更容易被复用等等。

  • 相关阅读:
    Luogu P1020 导弹拦截
    洛谷 p1196 带权并查集
    gradle 语法基础
    Codeforces Round #542 div.2 C
    我了解的字符编码
    洛谷p3374 树状数组1
    树状数组2
    线段树模板 求区间和, 区间加法,乘法更新
    洛谷 p1886 滑动窗口
    HDOJ.1251
  • 原文地址:https://www.cnblogs.com/qtiger/p/13570896.html
Copyright © 2020-2023  润新知