c#中的值类型存储位置在哪里?
在谈到值类型和引用类型的区别时,很多初学者常说值类型分配在方法的调用栈(或线程栈)上,引用类型分配在托管堆上,这种说法是错误的,至少前半部分是错误的。实际上这根本不应该成为值类型和引用类型区别的答案,这是所答非所问。值类型和引用类型的区别在语义层面,与存储位置无关,并不是值类型和引用类型不同的分配方式导致了它们行为上的差异,而是因为值和引用这两种类型在语义上的差异,才导致了他们不同的分配方式。本文只讨论存储位置,不会深入介绍它们的区别。
有些朋友可能会说,详细的分配方式应该是这样的:
- 引用类型的实例总是分配在托管堆上(在栈上至只保留实例的引用)
- 值类型的实例总是分配在它声明的地方(声明为局部变量时被分配在栈上,声明为引用类型成员时则被分配在托管堆上)
具体来讲也就是说,当值类型作为引用类型的私有字段时,它将作为引用类型实例的一部分,也分配在托管堆上。而当引用类型作为值类型的成员变量时,栈上将保留该成员的引用,其实际数据还是保存在堆中。
在C# 2出现之前,这样的说法没有问题。但C# 2引入了匿名方法和迭代器块后,以上说法就过于笼统了,它只看到了代码层面的东西,而没有看到编译器层面的东西。值类型实例作为局部变量不都是分配在栈上。这是因为C#代码中的局部变量,很可能在编译为IL后就不再是局部变量了。比如,如果匿名方法使用了外部变量(外部方法中声明的局部变量),或者迭代器块中声明了变量,那么这些变量将被提升为隐藏类的字段,因此也将分配在堆上。
由此可见,虽然MSDN的文档上也说,“值类型分配在栈上”,但这显然是不合适的。因为
- 这是不正确的。正确的说法应该是:值类型可以存储在栈上。
- 这是无关的。在C#中,存储的种类是隐藏在后台的,是实现细节。
- 这是不完整的。比如引用是什么?它既不是值类型,也不是引用类型的实例。引用也是值,也需要存储在某个地方。
值类型的存储位置
这样,关于值类型的存储位置,正确、完整的说法应该是:对于值类型来说,在微软桌面CLR的C#实现中,如果值类型的实例是局部变量、Lambda表达式或匿名方法中封闭的临时变量,且方法体不是迭代器块,并且JIT不对该值进行寄存,那么这时该值类型将存储在栈上。
够啰嗦吧,其实每一句都必不可少:
- 其他厂商实现C#时,完全可以对临时变量采用不同的分配策略。C#并没有要求必须将局部的值类型变量存储在栈上。
- 微软提供了多个CLI版本,有的用于嵌入式系统,有的用于Web浏览器。这些CLI运行在不同的硬件设备上,其分配策略是未知的。可能这些硬件根本就没有栈,也可能每个线程包含多个栈,也可能所有的东西都分配在堆上。
- Lambda表达式和匿名方法会将局部变量提升为分配在堆上的字段。
- 现在桌面CLR的C#实现中,迭代器块也将局部变量提升为分配在堆上的字段。但这不是必须的。微软也可以选择其他的实现,将其存储在栈上。
- 除 了栈和堆以外,还有其他的内存管理方式,但人们总是忽略这一点。比如寄存器,它既不在堆上,也不在栈上。如果寄存器的大小适当,值类型也完全可以位于一个 寄存器中。如果有东西存储在栈上是重要的,那么为什么存储在寄存器中就不重要呢?相反,如果JIT编译器的寄存器规划算法是不重要的,那么为什么栈的分配 策略就不能是不重要的呢?
存储位置与生存时间
之所以会有这样的误区,是因为人们总是错误地以为类型系统与存储分配策略有关。然而究竟是存储在栈上还是堆上,与要存储的类型没有任何关系。分配机制的选择只与存储所需的生存时间(lifetime)有关。
明确了这些之后,我们可以得出以下结论:
- 值共有三种:值类型实例、引用类型实例和引用。(C#代码不能直接操纵引用类型的实例,但可以通过引用来操纵。在不安全代码下,指针类型被视为值类型,以决定其值的存储需求)
- 值存储在存储位置(storage location)中。
- 程序所操作的所有值都存储在某个存储位置中。
- 所有引用(空引用除外)都指向一个存储位置。
- 所有存储位置都有一个生存时间(在这段时间内,存储位置中的内容是有效的)
- 从某个特定方法的开始,到方法返回或抛出异常为止,这段时间成为方法执行的活动期(activation period)。
- 方法中的代码可以请求一个存储位置(即声明一个局部变量)。如果该存储位置所需的生存时间大于当前方法执行的活动期,那么这个存储位置就称为是长期的(long lived)。否则为短期的(short lived)。(注意,当方法M调用方法N时,M会要求使用传入N的参数和N返回值的存储位置。)
现在我们来看一下实现细节。在微软CLR对C#的实现中:
- 共有三种存储位置:栈位置、堆位置和寄存器。
- 长期的存储位置通常是堆位置。
- 短期的存储位置通常是栈位置或寄存器。
- 在某些情况下,编译器或运行时很难决定某个特定的存储位置是短期的还是长期的。这时,会谨慎地认为它们是长期的。例如,引用类型实例的存储位置总是认为是长期的,尽管可能为短期的。因此,它们总是位于堆上。
这样就可以很自然地得出:
- 就存储来说,引用和值类型实例实质上是一回事,都存储在栈上、寄存器中或堆上,这取决于值的存储是短期的还是长期的。
- 数 组元素、引用类型的字段、迭代器块中的局部变量以及Lambda或匿名方法中的非封闭局部变量,它们的生存期都必须比第一次请求这些存储的方法的活动期要 长。即使少数情况下它们的生存时间要短于方法的活动期,也很难或根本没法通知编译器。因此不得不保守地对待:所有这些存储都将位于堆上。
- 局部变量和临时值通常可以通过编译时分析,认为在方法活动期之后就没用了,因此为短期的,可以存储在栈上或寄存器中。
一旦你摒弃值的类型与存储有关这个疯狂的想法,一切就会豁然开朗了。其实,你无需知道这些,除非要编写不安全代码或与非托管代码交互。你尽可以让编译器和运行时来管理存储位置的生存时间,这正是它们所擅长的。