介绍
这篇文章将解释6个重要的概念:栈,堆,值类型,引用类型,装箱,拆箱。本文将会阐述当你声明一个变量时发生了什么并提前说明两个重要个概念:栈和堆。文章将围绕引用类型和值类型澄清一些重要基本信息。并通过一个简单的示例来演示装箱和拆箱引起的性能损失。
声明一个变量时发生了什么?
当你在.NET应用程序中声明了一个变量时,将会从RAM中分配一小块内存,在内存中存在三样东西:变量名称,数据类型和变量的值。
我们简单的说明下在内存中发生了什么,要知道变量在内存中的分配是取决于数据类型的。这里存在两种类型的内存分配:栈内存和堆内存。如下图所示,我们将更加清楚的理解这两种类型的内存。
堆和栈
为了理解堆和栈,让我们来看下如下代码内部是如何运行的。
public void Method1()
{
// Line 1
int i=4;
// Line 2
int y=2;
//Line 3
class1 cls1 = new class1();
}
这里有三行代码,让我们来一行一行的解释下它的内部机制。
第一行:当这一行代码执行后,编译器将在栈上分配一小块内存,栈负责跟踪应用程序在内存中的运行。
第二行:现在执行下一步操作,正如栈的名字所说,每次分配内存都是在栈的最顶端,你可以把栈当成一系列堆砌起来的盒子。
内存的分配和回收是通过LIFO(后进先出)逻辑来实现的,换句话说内存的分配和回收都是从顶端开始的。
第三行:在第三行,创建了一个对象,当这一行代码执行完后,在栈上创建了一个指针指向这个对象,而对象实际上存储在另一
种叫“堆”的不同的内存位置上。“堆”是无法跟踪内存的运行的,它只是一系列随时都能够访问的对象的集合,堆用于动态内存分配。
需要注意的是引用指针分配在了栈上。声明 Class1 cls1;并没有给类Class1分配内存,它只分配一个cls1栈变量(并将它设置为null)直到遇到new关键字,它才在堆上进行分配。
退出方法(函数):现在终于开始退出执行控制方法。当它结束最终控制时,清除所有被分配在栈的内存变量。换句话说所有与int数据类型相关的变量都各自从堆栈中以“后进先出”的方式进行回收。
需要注意的是——堆不会释放内存。而是由垃圾收集器后回收。
现在许多开发者朋友一定想知道为什么有两种类型的内存,我们不能只是分配一个内存类型就能实现我们做的一切吗?如果你仔细观察,基本数据类型并不复杂,他们持有单个值,像“int i = 0”。 Object数据类型是复杂的,他们引用其他对象或其他基本数据类型。换句话说,他们拥有其他多个值引用的指针,并且每一个指针都必须存储在内存中。Object类型需要动态内存而基本类型需要的静态类型的内存。如果要求是动态内存,则必须分配在堆上,否则分配在栈上。
值类型和引用类型
既然我们已经了解了栈和堆的概念了,是时候了解值类型和引用类型的概念了。值类型将数据和内存都保存在同一位置,而一个引用类型则会有一个指向实际内存区域的指针。
通过下图,我们可以看到一个名为i的整形数据类型,它的值被赋值到另一个名为j的整形数据类型。他们的值都被存储到了栈上。
当我们将一个int类型的值赋值到另一个int类型的值时,它实际上是创建了一个完全不同的副本。换句话说,如果你改变了其中某一个的值,另一个不会发生改变。于是,这些种类的数据类型被称为“值类型”。
当我们创建一个对象并且将此对象赋值给另外一个对象时,他们彼此都指向了如下图代码段所示的内存中同一块区域。因此,当我们将obj赋值给obj1时,他们都指向了堆中的同一块区域。换句话说,如果此时我们改变了其中任何一个,另一个都会受到影响,这也说明了他们为何被称为“引用类型”。
哪些是值类型,哪些是引用类型?
在.NET中,变量是存储到栈还是堆中完全取决于其所属的数据类型。比如:‘String’或‘Object’属于引用类型,而其他.NET基元数据类型则会被分配到栈上。下图则详细地展示了在.NET预置类型中,哪些是值类型,哪些又是引用类型。
装箱和拆箱
现在,你已经有了不少的理论基础了。现在,是时候了解上面的知识在实际编程中的使用了。在应用中最大的一个意义就在于:理解数据从栈移动到堆的过程中所发生的性能消耗问题,反之亦然。
考虑一下以下的代码片段,当我们将一个值类型转换为引用类型,数据将会从栈移动到堆中。相反,当我们将一个引用类型转换为值类型时,数据也会从堆移动到栈中。
不管是在从栈移动到堆还是从堆中移动到栈上都会不可避免地对系统性能产生一些影响。
于是,两个新名词横空出世:当数据从值类型转换为引用类型的过程被称为“装箱”,而从引用类型转换为值类型的过程则被成为“拆箱”。
如果你编译一下上面这段代码并且在ILDASM(一个IL的反编译工具)中对其进行查看,你会发现在IL代码中,装箱和拆箱是什么样子的。下图则展示了示例代码被编译后所产生的IL代码。
装箱和拆箱的性能问题
为了弄明白到底装箱和拆箱会带来怎样的性能影响,我们分别循环运行10000次下图所示的两个函数方法。其中第一个方法中有装箱操作,另一个则没有。我们使用一个Stopwatch对象来监视时间的消耗。
具有装箱操作的方法花费了3542毫秒来执行完成,而没有装箱操作的方法只花费了2477毫秒,整整相差了1秒多。而且,这个值也会因为循环次数的增加而增加。也就是说,我们要尽量避免装箱和拆箱操作。在一个项目中,如果你需要装箱和装箱,请仔细考虑它是否是绝对必不可少的操作,如果不是,那么尽量不用。
虽然以上代码段没有展示拆箱操作,但其效果同样适用于拆箱。你可以通过写代码来实现拆箱,并且通过Stopwatch来测试其时间消耗。