本文翻译自 Six Important .NET Concepts: Stack, Heap, Value Types, Reference Types, Boxing, and Unboxing - Shivprasad koirala

笔者会添加一些说明来帮助理解

前言

本文将会讲解有关 .NET 的一些重要的内部机制。

  • 当你声明一个变量时,涉及到的的概念
  • 值类型与引用类型
  • 装箱和拆箱机制的性能影响

声明一个变量的背后发生了什么

当你在一个.NET应用中声明一个变量,它会分配一些内存块。这块空间会包含三个东西:变量名,数据类型和值。

以上的解释简化了很多,实际上,数据类型的不同将会导致其被分配到对应种类的内存空间。内存的分配类型有两种:。在接下来的章节中,我们将会尝试深入理解这两种内存。

栈和堆

为了理解栈和堆,我们先理解以下代码的内部机制:

1
2
3
4
5
6
public void Method1()
{
int i=4; // Line 1
int y=2; // Line 2
Class1 cls1 = new Class1(); //Line 3
}

让我们来分析这三行代码做了什么事情。

内存的分配与释放遵循LIFO(后进先出)的原则。换句话说,栈中内存最后分配的块会被最先释放。

编译器会在栈中分配一小块内存空间。而栈负责管理你的应用所需要的运行内存。

编译器会将这一块内存分配在上一次内存分配的顶部。栈可以理解为就是类似于将箱子垒起来的过程。

创建了一个对象后,它在栈上创建了一个指针,然后实际的对象被保存在另一种不同的内存地址中()。堆不会管理应用所需的运行内存,它只是一个存储对象(可以在各处访问)的地方并动态分配内存。


还有一个重要的点是,对象的引用会被分配到栈上。语句Class1 cls1;不会给Class1的实例分配内存,而只是在堆上分配一个变量叫cls1(并将其设置为null)。当你使用new关键字时,才会在堆上分配内存。

在程序运行的最后,栈上的内存会被释放。换句话说,所有类似int数据类型的变量都会以LIFO的顺序被释放。

然而对于堆来说,程序的退出不会立刻导致堆内存的释放,而是在之后被GC(Garbage Collector,即垃圾回收器)回收。

此时可能会有人问:为什么要区分出两种不同的内存分配方式呢?为什么不直接将所有的内存都分配到其中一种上呢?

你会发现,.NET的原始数据类型并不复杂且只被赋予了一个值,比如int i = 0。而对象的数据类型都很复杂,因为它们包含了对其他对象和原始数据类型的引用。

换句话说,他们有对多个值的引用,而每个值都必须要被存储到内存。引用类型需要动态内存,而原始数据类型只需要静态内存。如果需要动态内存,它就会被分配到堆上,否则就会被分配到栈上。

值类型和引用类型

现在我们理解了栈和堆的概念,我们来看看值类型和引用类型。

值类型在栈上存储了数据和内存地址,而引用类型只在栈上存储了内存地址。

一个简单的整型变量i,它被赋值为另一个整型变量y,那么iy都会被分配到栈上。

当我们将一个整型变量赋值到另一个整型变量上,它们完全不同,仅仅是拷贝了值。换句话说,如果你改了其中一个变量的值,另一个并不会改变。这就是值类型

1
2
3
4
5
public void Method1()
{
int i=4; // Line 1
int y=i; // Line 2
}

当我们创建了一个对象并让另一个对象引用该对象时,它们指向了同一块内存地址(如下图所示)。所以当我们将obj指派为obj1后,它们指向的内存地址相同。

也就是说,当我们改变其中一个对象的数据,其他引用了该对象的对象也会跟着改变。这就是引用类型

1
2
3
4
5
public void Method1()
{
cls1 obj = new cls1(); // Line 1
cls1 obj1 = obj; // Line 2
}
词汇解释
  • 类 (Class): 一种引用类型,是对数据和行为的抽象 (字段,属性和方法等),可以视作蓝图。
  • 对象/实例 (Object): 是类 (Class) 实例化后的产物,可以视作是按照蓝图配置的内存块。
  • 类的实例化是对象,对象的抽象是类。
  • Assign 一词在值类型的语境下可以理解为赋值,在引用类型的语境下理解为指派/引用,在内存相关操作时理解为分配

哪些类型是值/引用类型

.NET中,可以根据一个数据类型是分配在栈还是堆来分辨是值类型还是引用类型。比如StringObject是引用类型,其他的原始数据类型都是值类型。

常见类型
  • 值类型:byte,short,int,long,float,double,decimal,char,bool 和 struct
  • 引用类型:string, 由类声明的类型

Microsoft Learn - C# 类型

装箱和拆箱

了解这些在实际编程中有什么用呢 —— 在于了解数据在堆和栈之间的移动带来的性能损失。

请看下面的代码片段。

  • 当我们将值类型移动到引用类型时,数据会从栈移动到堆,这个过程成为装箱
  • 当我们将引用类型移动到值类型时,数据会从堆移动到栈,这个过程成为拆箱
1
2
3
4
5
6
public void Method1()
{
int i = 1; // Line 1
object O = i; // Line 2 装箱
int j = (int)O; // Line 3 拆箱
}

装箱和拆箱在 IL 代码中表示如下:

1
2
3
4
5
6
object O = i;

// IL
IL_0003: ldloc.0
IL_0004: box [System.Runtime]System.Int32
IL_0009: stloc.1
1
2
3
4
5
6
int j = (int)O;

// IL
IL_000a: ldloc.1
IL_000b: unbox.any [System.Runtime]System.Int32
IL_0010: stloc.2

装箱和拆箱的性能

为了了解装箱和拆箱带来的性能影响,我们将以下两个方法执行10000次。第一个方法是简单的装箱操作,而另一个是拆箱操作。通过使用Stopwatch类进行简单的测试。

过时的测试

原文在2010年进行的测试如今来看没有参考价值,因为现代的 .NET 运行时已针对装箱和拆箱操作做了很多优化。

  • 原文的装箱测试:10000次耗时3542毫秒
  • 原文的拆箱测试:10000次耗时2477毫秒
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Main()
{
var stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
BoxUnbox();
}
stopwatch.Stop();
stopwatch.Elapsed.Dump();
}

private void BoxUnbox()
{
int a = 123;
object b = a;
}

// Output: 00:00:00.0000813
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Main()
{
var stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
SimpleVariableAssignment();
}
stopwatch.Stop();
stopwatch.Elapsed.Dump();
}

private void SimpleVariableAssignment()
{
object a = 123;
int b = (int)a;
}


// Output: 00:00:00.0000548