概述
在.Net当中,有Clr帮我们进行内存管理和垃圾回收,但这并不意味着我们不需要关心这些机制。为了优化程序的性能,在编写程序时,就必须要考虑内存管理和垃圾回收。同时,理解内存管理机制,能够帮我们理解各个类型变量的特性。在这篇文章中,将介绍栈内存和堆内存的基础,以及各种类型变量和他们的特性。
当程序运行时,.Net会在两个地方存储程序信息,堆内存和栈内存,它们只是对内存的逻辑分段,在程序运行过程中扮演者不同的角色,下面将详细的介绍下它们。
栈 vs 堆:区别在哪?
栈内存更多的是负责追踪函数调用,内存堆更多的负责记录数据对象。可以把栈看成是一堆盒子一个压着一个的堆叠在一起,当调用一个函数的时候,会在栈顶堆一个新的盒子,我们只能使用最上面的盒子,当最上面的盒子处理完之后(函数调用完成),我们把它扔掉,然后继续处理当前处于栈顶的盒子。
- 从结构来说,栈是一种后进先出(LIFO)型结构,只能使用栈顶的数据,将栈顶数据弹出之后,才能使用下面的数据;堆是一种列表类型,可以访问堆中的任何数据 。
- 从管理方式来说,栈能够对实现对内存的自我管理;堆得内存管理需要通过GC来实现。
栈、堆存放内容
在程序执行过程中,主要包含四类数据需要存放到栈、堆上:值类型、引用类型、指针、指令。
值类型
在C#中,所有从System.ValueType继承的类型,都是值类型,包括:
- bool
- byte
- char
- decimal
- double
- enum
- float
- int
- long
- sbyte
- short
- ushort
- uint
- ulong
- struct
引用类型
所有声明为以下类型的都属于引用类型,其中包含:
- class
- interface
- delegate
- object
- string
指针
当我们把对象放到堆内存时,访问该对象,就需要一个指向该对象的引用,引用通常就是一个指针,我们不需要显式的使用指针,Clr会对引用进行管理。
注意区别指针(引用)与引用类型的区别,当我们说类型时引用类型时,指的是它需要通过指针来访问;而指针存储着一个指向内存的地址。
指令
在程序执行时,内存上除了存储各类数据,还存储着处理指令,比如变量声明、数学运算、跳转等,后续将进行详细介绍。
分配到栈还是堆?
对象内存分配遵循以下两条原则:
- 引用类型总是分配到堆内存。
- 值类型和指针的分配与它们声明位置有关系。
栈的一个主要功能是跟踪线程执行时代码指针的位置、以及被调用的数据,可以把它看做一个线程的状态,每个线程都有自己独立的栈。当调用函数时,会将函数的参数压入线程栈,在方法内的局部变量也会压入线程栈顶。下面是一个最简单的示例。
public int AddFive(int pValue)
{
int result;
result = pValue + 5;
return result;
}
下面看下调用 AddFive方法时,线程栈的变化情况。
首先在执行函数之前,将函数的实参压入线程栈顶。需要注意的是,方法并不是放在线程栈中,这里只是示意方法调用开始。
然后开始执行函数体,线程将指令控制指针,指向 AddFive 指令(存在于类型方法表中),如果时第一次执行函数,会执行JIT编译,将IL指令编译成本机CPU指令。
然后第一行声明了一个局部变量result,函数的局部变量分配在线程栈上(注:如果是引用类型则在线程栈上保存对象指针,后边示例在讨论)。
方法执行结束后, result作为返回值被返回。 (过程中涉及的指令执行会在后续文章进行解释)
通过将指令执行指针移动到 AddFive 执行之前的一个地址,将执行 AddFive 过程中分配的栈内存清理干净,然后返回到之前的方法中(调用AddFive的方法,这里未体现)。
值类型有的时候会被分配到堆内存当中,记住值类型内存分配的一个原则:值类型内存的分配,跟该变量声明的位置有关系,例如上面声明为函数的局部变量就分配到线程栈中;如果声明在一个class类中,则分配在堆内存中。看下面一个例子:
//定义MyInt引用类型
public class MyInt
{
public int MyValue;
}
//定义示例函数
public MyInt AddFive(int pValue)
{
MyInt result = new MyInt();
result.MyValue = pValue +5;
return result;
}
然后分析一个 AddFive 调用过程中,内存的变化情况。
1. 和之前一样,当线程开始调用函数时,首先实参会被压入到线程栈顶。
下面进行到函数体内,这里开始和之前就不一样了,同样是声明一个 result 变量,此时由于 result是引用类型,所以被分配到堆内存中。首先向线程栈压入一个指针,然后将MyInt实例分配到堆内存并返回内存地址,最后将指针指向返回的内存地址。
当 AddFive 执行完成之后,清理线程栈内存。注意这里只是清理栈内存,堆内存会由GC管理。
而我们在堆内存上分配的MyInt,此时已经没有任何变量指向它,已经变成了一块内存垃圾,GC进行垃圾回收时会将该内存回收。
这里就可以看到垃圾回收(GC)的作用了,它的作用就是对堆内存进行管理和回收,当GC开始调用时,会挂起所有正在运行的线程,然后回收线程会检查内存堆,标记出堆中的垃圾,并将垃圾删除以清理更多可用内存,可以想象,我们堆内存是一块连续的内存地址,清理其中垃圾之后,就会造成内存碎片,所以最后GC会对剩下的对象进行重定位,同时会更新所有指向这些对象的指针(引用)。这一系列操作在性能消耗方面非常昂贵,所以在编写高性能代码时,要注意栈和堆得内存分配。
那么以上这些(分配在堆内存还是栈内存),对我们会产生什么影响呢?
当我们使用引用类型,我们处理的是实例对象的指针,并非对象本身;当我们使用值类型时,我们处理的是实例对象本身。下面通过例子看下区别。
public int ReturnValue()
{
int x = new int();
x = 3;
int y = new int();
y = x;
y = 4;
return x;
}
此时的返回值为 3.
下面使用MyInt 实现上面代码:
public class MyInt
{
public int MyValue;
}
public int ReturnValue2()
{
MyInt x = new MyInt();
x.MyValue = 3;
MyInt y = new MyInt();
y = x;
y.MyValue = 4;
return x.MyValue;
}
此时的返回值为4.
下面是以上两个实例的堆栈情况:
第一个示例堆栈情况:
第二个示例堆栈情况,x和y都指向内存堆得同一个物体:
希望能够通过以上几个示例,能够更好理解C#值类型与引用类型之间的区别,以及指针(引用)的使用以及什么时候会使用指针,其中涉及的函数过程调用、指令执行、GC等内容,只做了简单介绍,没有深入解释,后续会详细的解释其中部分内容。在下一部分当中,将进一步讨论内存管理相关内容,重点研究方法参数。