C#中堆栈和堆是一个很基础的概念,很遗憾我现在才来写这个话题。
导语
Windows使用的是虚拟寻址系统,把程序可用的内存地址映射到硬件内存中的实际地址,在32位处理器上,每个进程都可以使用4GB的内存(无论计算机内部有多少硬盘空间),4GB包含了程序的所有部分,包括了可执行的代码、加载的DLL动态链接库、以及代码中的所有的变量。这4GB的内存称为虚拟内存。
在这4GB的每个存储单元都是从0开始往上排的。我们如果要访问某个空间存储的值,就要提供该存储单元的数字。
高级语言中,编译器会把我们可以理解的名称转换为处理器可以理解的内存地址。
这篇文章所讲的堆栈和堆就存放在虚拟内存中。为了表达方便,我们把堆栈叫做栈,堆还是叫做堆。
通常的,在C/C++中:
- Stack叫做栈区,由编译器自动分配释放。存放函数的参数值,局部变量的值。
- Heap称为堆区,由程序员分配释放。
但是在C#中:
- Stack指栈,他是内存自行维护的。和C++差不多。
- Heap指堆,在C#中,我们叫它托管堆,因为C#的垃圾收集器GC(garbage collection)的控制下工作。相当于我们把这部分内存托管给了编译器。
栈
我们很容易将内存中的栈联想到数据结构里的栈。
与数据结构里的栈不同的是,内存中的栈通过堆栈指针来从高地址向低地址填充,是自上而下的填充。
与数据结构里的栈相同的是,内存中的栈也符合先进后出的原则。先分配内容然后释放。结合上文讲的栈自上而下填充,那么释放自然是自上而下的释放,创建的越早的变量会越晚释放,这样保证了栈中先进后出的规则不会与变量的声明周期起冲突。
堆栈指针,一个由操作系统维护的变量,指向堆栈中下一个自由空间的地址。程序第一次运行时,堆栈指针就指向为堆栈保留的内存块的末尾。
当数据入栈后,堆栈指针就会随之调整,指向下一个自由空间。
在C#中,栈有一个的前缀,即线程栈(Thread Stack)。每个运行的程序都对应一个进程(process),在一个进程的内部,可以有一个或多个的线程(Thread),在每个线程下才是我们的线程栈,大小为1m。
栈中保存在为运行而分配的局部变量、参数、返回数据等。
堆
堆与栈不同,堆是从下往上分配,自由空间都在已用空间的上面。
当我们希望用一个方法来分配内存、存储一些数据,而且在方法退出以后这些数据在很长一段时间内仍然可以调用。我们可以使用new运算符来请求一个空间,这个空间就在堆中。
例如我们创造一个类的实例:
Class ExampleClass
{
........
}
ExampleClass example = new example();
在创建一个类对象时,堆栈做了什么呢:
- 我们首先声明一个ExampleClass的引用,我们叫它example,在栈上给这个引用分配一个存储空间。这个空间里仅仅包含ExampleClass实例的引用地址。
- 在堆上分配内存来保存ExampleClass的实例,这个堆上的存储位置是.net运行库在堆中搜索的第一个从未使用的、32字节的连续块。
- 将ExampleClass实例的地址赋给example变量。
在new一个引用类型变量时较为复杂,不可避免的会造成性能降低。
而且由于.net运行库需要保证堆的信息状态正确,我们在堆中添加新数据的时候,栈中相对应的引用变量也会进行更新。
但是,如果我们把一个引用变量的值赋给同一类型的变量,让二者指向同一个堆中的实例,这不会受到栈的限制。
在C#中,如果一个引用类型退出作用域的时候,自然而然就会从栈中释放,但是实际上,引用对象的数据的生命周期仍然没有结束,它还是保留在堆中,直到程序运行结束或者该数据不被任何变量引用时,GC才会从堆中释放它。这就是引用类型的好处,我们可以对引用类型的生存周期进行自主的控制,只要有对数据的引用,这个数据就肯定在堆上。
值类型与引用类型
值类型大体上分为结构和枚举两类
- 结构值类型有常见的(System.Int32、System.Float、char等)、布尔值(System.Boolean)和自定义的结构体。
- 枚举值类型即Enum。
引用类型基本上都是称为“类”的类型,即Class、Interface、delegate、string等类型。引用类型通常在栈中放出一个指针,这个指针并不是显式的,由CLR(common language runtime)来进行管理。它指向一块内存空间,它的值即为一个内存地址。
值类型与引用类型的比较:
1.
值类型一般分配在线程栈上但不全分配在线程栈上,有很多情况它都分配在托管堆中:
- 值类型的数组元素
- 引用类型的值类型字段
- 迭代器返回的值类型变量
- 闭包的匿名函数或Lambda表达式的外部变量
但对于引用类型来说,它总是分配在托管堆中并且对线程栈放出指向它托管堆地址的引用。
2.
当我们使用值类型的时候,我们在操控值类型本身。
当我们操控引用类型的时候,实际上在操控该类型的引用。
这个例子非常浅显易懂
class Program
{
static void Main()
{
int a = 5;
int b = a + 1;
Example example1 = new Example(5);
Example example2 = example1;
example2.x += 1;
Console.WriteLine("值类型变量加一后原来的变量值为:" + a);
Console.WriteLine("引用类型变量加一后原来的变量值为:" + example1.x);
}
}
class Example
{
public int x;
public Example(int t)
{
x += t;
}
}
输出为:
当我们复制一个引用类型的时候,由于引用类型本身存储的为内存地址,所以复制的也是这个内存地址,而不是内存地址指向的堆内存中的对象本身。所以我们对这个地址的值做出改动,则其他指向该地址的引用保存的值也会进行改动。而值类型复制的时候即复制本身,所以我们改动它的复制体,原来的本体也不会变动。
我们需要注意的是,不能认为对象在C#中是通过引用传递的,引用变量的值是引用,而不是引用变量本身。我们传递的只是指向对象的地址。对象永远是不能传递的。(如果我们需要完全的复制一个对象,则需要使用深拷贝,这在序列化那篇博客有讲。)
3.
值类型都是隐式密封的,不能用来派生出其他类型,所以没有引用类型所拥有的额外信息。
引用类型除了内部的方法和字段,还需要类指针、同步索引、静态成员等一系列其他的信息:
- 类指针:用于关联对象。
- 静态成员:属于类的字段或者方法。
- 同步索引:完成同步(例如线程同步)。
4.
值类型和引用类型都派生自System.Object类,但是结构都派生自System.ValueType类。同时,System.ValueType本身也是派生自System.Object类的。
根据官方语言的规定,所有的值类型都必须派生自System.ValueType,包括枚举Enum。
图示
在堆栈中,我们可以这样表示引用类型与值类型:
假设我们有这样一段代码。
class Vihicle
{
public int VihicleCode;
public virtual void ShowCode()
{
Console.WriteLine("父类" + VihicleCode);
}
public void GetCode(int code)
{
VihicleCode = code;
}
}
class Car:Vihicle
{
public override void ShowCode()
{
Console.WriteLine("子类" + VihicleCode);
}
}
那么在堆中,我们可以这样表示:
如果此时,我们在主函数中这样写
int code = 10;
即在栈中,我们有:
此时我们接着在主函数中写:
Vihicle vihicle;
vihicle = new Car();
第一句相当于,在栈中生成了一个引用,但我们没有声明这个引用指向哪个地方。
第二句即new(创建)一个引用类型(Car)的实例,并让我们刚刚声明的引用指向它。我们画个图有:
如果我们此时创建一个新的Vihicle类型的变量vihicle2等于我们原来的Vihicle,在代码中为这样:
Vihicle vihicle2 = vihicle;
那么根据引用类型的原理,在内存中的表现即为:
这样就是引用类型和值类型的一些常规操作在内存中的表现。
装箱拆箱
我们刚才说过,值类型不一定是在栈中,可能它会因为是引用类型的字段而存储在堆中,例如我们上文中Vihicle中的VihicleCode字段,它就是值类型,但是我们看到图中,它却存放在堆中。
我们代码中常常需要将值类型向引用类型转化。例如ArrayList数组,它里面都是Object类型的,是一个引用类型的变量,我们可以清晰的看到ArrayList方法中的定义:
形参是一个object类型的变量,但是我们仍然可以传入值类型进去并且不会出任何问题。这个时候就发生了装箱。
装箱的定义即:值类型实例转换成在托管堆上分配的对象的过程,即值类型转换成引用类型的过程。
装箱的步骤:
- 在托管堆中分配内存,由于是将值类型引用类型化,所以分配的内存空间除了值类型本身字段的内存以外,还要加上托管堆对象必须携带的类型对象指针、同步索引块的内存。
- 将值类型的字段复制到刚刚分配的堆内存中。
- 返回对象的引用。
既然有了装箱,我们就可以拆箱,与之相对的就是拆箱:获得引用类型字段的地址。
它的步骤相较于装箱要简单的多:
- 获取一个装箱的引用类型中字段的地址。
- 将已经装箱的值类型字段中的值从托管堆中复制到线程栈的新的值类型实例上面去。
注意两点:
一:拆箱并不是装箱的逆过程,拆箱的代价要比装箱小得多。拆箱本质上来说是获得引用的过程,而不是复制的过程,复制是拆箱之后紧跟的一个过程,而不是拆箱本身。
二:拆箱之后的类型只能是最初未装箱的类型,而不能是其他。拆箱需要显式地指定要拆箱要装换的目标类型。
我们写一个装拆箱的小例子:
int i = 5;
object t = i;
int j = (int)t;
装拆箱的场景:
除了上文中所讲的装拆箱的场景以外,当我们值类型调用Object基类的方法ToString、Equals、GetHashCode方法时,也会造成装箱。还有,如果使用值类型用做接口变量时,也会发生装箱:
IComparable x = 5;
我们在开发过程中常常抵制频繁的装拆箱,这样反复的装拆箱和复制会对程序的速度和内存空间产生不利的影响。由于频繁的调用托管堆而增加了GC的负担。这也是为什么C#之后引入了泛型来避免装拆箱的过程发生。
结构体与类
在C#中,由于结构体与类都完成相似的功能,但是由于结构体在栈中,栈中的值类型又给人轻便简单的印象,所以结构体常常被认为是轻量级的类,但事实上不是这样。
结构体与类的使用选择, 取决于我们字段中值类型和引用类型的语义占比,例如,DateTime类型就是结构体,因为它非常适合作为和数字或字符相似的基本单位来使用。
- 从GC的角度来看,结构体不受GC的影响,而且没有类型标示的内存开销。结构体确实相交类比较轻便。
- 从变量传递的角度来看,类在参数传递、赋值、返回值等操作要比结构体简便得多,由于引用类型里只包装一个4或8字节的内存地址(无论指向的对象占多大的内存),结构体由于其值类型的特性,结构体本身有多大,在函数中传递就有多大。所以类对象在函数之间传递的时候的开销相较于结构体要小得多。类则相较结构体比较轻便。