.Net中的内存分配 – 值类型、引用类型、堆栈、堆、装箱、拆箱、Ref、Out和Volatile






4.83/5 (30投票s)
本文讨论了.Net中的内存分配以及JIT编译器如何优化非易失性代码。它还讨论了值类型、引用类型、堆栈、堆、装箱、拆箱、Ref、Out和Volatile。
引言
在本文中,我将详细讨论值类型和引用类型以及它们在堆栈和堆上的内存分配。然后,我将讨论Volatile关键字的用法。
目录
- 内置数据类型
- 值类型和引用类型
- 堆栈和堆
- 何时使用struct和class
- 装箱和拆箱
- 何时使用Ref和Out关键字
- 多线程应用程序中线程之间的内存共享
- Volatile关键字的使用
- 深入了解JIT编译器对易失性和非易失性字段的优化
在深入研究内存分配和其他细节之前,让我们先看看内置数据类型
内置数据类型
数据类型 |
Range |
数据类型 |
byte |
0 .. 255 |
值类型 |
sbyte |
-128 .. 127 |
值类型 |
short |
-32,768 .. 32,767 |
值类型 |
ushort |
0 .. 65,535 |
值类型 |
int |
-2,147,483,648 .. 2,147,483,647 |
值类型 |
uint |
0 .. 4,294,967,295 |
值类型 |
long |
-9,223,372,036,854,775,808 .. 9,223,372,036,854,775,807 |
值类型 |
ulong |
0 .. 18,446,744,073,709,551,615 |
值类型 |
float |
-3.402823e38 .. 3.402823e38 |
值类型 |
double |
-1.79769313486232e308 .. 1.79769313486232e308 |
值类型 |
decimal |
-79228162514264337593543950335 .. 79228162514264337593543950335 |
值类型 |
char |
Unicode字符。 |
值类型 |
字符串 |
Unicode字符字符串。 |
引用类型 |
bool |
真或假。 |
值类型 |
object |
一个对象。 |
引用类型 |
值类型和引用类型
在.Net中,我们有2种数据类型:值类型和引用类型。了解CLR如何管理数据和内存对于编写优化代码以提高性能至关重要。
上面表格中给出的所有内置数据类型,当用于在函数中声明变量或作为参数传递时(非ref传递),它将是值类型,除了string和object数据类型,它们将是引用类型。
堆栈和堆
值类型数据将在堆栈上分配,引用类型数据将在堆上分配。但是,当相同的值类型声明为数组或用作类的成员数据时,它们将被存储在堆上。此外,当值类型用于struct时,它们将被存储在堆栈上。
例如
class Program
{
public int intMember = 3; // Data members will be part of object instance
public bool flag = true; // so they will be stored on the heap
public void func1(int f, bool b, int[] intAry)//value type parameters will be allocated on the stack
{
int index = 5; // Integer local variable will be allocated on the Stack
string str = "string"; // string local variable will be allocated on the Heap
int[] ary = { 1, 2, 3 };// Integer array will be allocated on the Heap
for (int i = 0; i < intAry.Length; i++)
{
intAry[i] = intAry[i] + 100;
}
f = 123;
b = true;
}
static void Main(string[] args)
{
Program obj = new Program();
int[] ary1 = { 1, 2, 3, 4, 5 };
int intLocal = 5;
bool boolLocal = false;
obj.func1(intLocal, boolLocal, ary1);
// The changes done for the array in the called function will be reflecting
for (int i = 0; i < ary1.Length; i++)
{
Console.WriteLine("ary1 [" + i + "]=" + ary1[i]);
}
// The changes done for value types in the called function will not reflect
Console.WriteLine("intLocal=" + intLocal + ", boolLocal = " + boolLocal);
}
}
在上面的示例代码中,当创建对象实例时,值类型成员变量intMember和flag将在堆上分配,而不是在堆栈上。而在Main函数中,intLocal、boolLocal将在堆栈上分配,整数数组ary1将在堆上分配。当我们调用成员函数func1时,参数变量f、b和局部变量index将存储在堆栈上,而字符串变量str和整数数组ary将在堆上分配。这里需要注意的是,当任何值类型声明为数组时,它们将被视为引用类型。如果您使用并运行上面的代码,您可以看到在func1函数中对整数数组所做的更改在函数返回后仍然有效,而局部值类型变量intLocal和boolLocal在调用函数func1后将不会反映更改。
何时使用struct和class
堆栈比堆更快。由于struct的内存是在堆栈上分配的,因此它们比在堆上分配内存的class更快。但是,由于堆栈的内存大小有限(最大1MB),因此我们应该仅在数据量较小时使用struct。如果您需要将大量数据存储在内存中(例如,一个大的struct),并且需要长时间保留该变量,那么最好使用class而不是struct来将其分配到堆上,而struct仅适用于少量数据。如果您处理的是只需要在函数使用期间存在的较小变量,则应使用堆栈。
装箱和拆箱
装箱是将值类型转换为引用类型的过程,在此过程中,它通过在堆上分配内存来创建一个新的object类型引用变量。下面是装箱的示例代码
int i = 67; // i is a value type
object o = i; // i is boxed
拆箱是将引用类型转换为值类型的过程,前提是object(引用变量)中的值是值类型,否则将抛出运行时异常。
但这会将值类型转换为引用类型以及反向转换的负担,从而影响性能。因此,除非非常必要,否则我们应避免装箱和拆箱。通常,当处理仅接受object类型值的类时,我们会使用此过程。例如,当我们将在ArrayList中存储整数时,ArrayList只接受object类型,此时就会发生装箱。当我们检索并将object值类型转换为其相应的数据类型时,就会发生拆箱。下面是拆箱的示例代码
System.Collections.ArrayList list =
new System.Collections.ArrayList(); // list is a reference type
int n = 67; // n is a value type
list.Add(n); // n is boxed
n = (int)list[0]; // list[0] is unboxed
何时使用Ref和Out关键字
当值类型参数传递到方法时,每个参数的副本将在堆栈上创建。如果参数是大型数据类型,例如包含许多元素的自定义结构,或者方法执行次数很多,这可能会影响性能。在这些情况下,最好使用ref
关键字传递类型的引用。out
关键字类似于ref
关键字,区别在于使用out
时,它会告诉编译器在返回之前必须为该参数分配一个值,否则将导致编译错误。
多线程应用程序中线程之间的内存共享
在多线程应用程序中,每个线程都有自己的堆栈,大约1MB。但是,所有不同的线程将共享堆内存。由于堆内存将在所有线程之间共享,因此我们必须非常小心,并通过使用锁或其他.net同步技术来实现线程同步,以避免竞态条件、死锁等。如果我们有非易失性字段,那么在一个线程中更新的数据将不会反映在其他线程中。这就是volatile
关键字的用武之地。
Volatile关键字的使用
在单线程应用程序中,我们不会遇到堆内存中分配的数据问题。但在多线程应用程序中,尽管堆内存被所有线程共享,CLR内部会进行优化和重排代码,这可能导致数据同步问题。例如,一个线程修改了对象obj
的值,但读取其值的另一个线程不会获得obj
的更新值。通过将volatile
关键字应用于字段可以解决此问题。让我们看一个示例代码
class MultiThreadingHeapIssue
{
bool stopLoop = true; // by default value is set to true
public void Run()
{
new Thread(() => { Console.WriteLine("Loop started"); while (stopLoop);
Console.WriteLine("Loop ended");
}).Start();
// loop runs until the stopLoop field is set to false
Thread.Sleep(1000);
stopLoop = false;
Console.WriteLine("value set to false.");
}
static void Main(string[] args)
{
MultiThreadingHeapIssue obj = new MultiThreadingHeapIssue();
obj.Run();
}
}
在release模式下使用ctr+F5运行上述代码后的结果
使用bool stopLoop = true;
的结果
使用volatile
的结果:volatile bool stopLoop = true;
深入了解JIT编译器对易失性和非易失性字段的优化
在上面的代码中,由于使用了非易失性字段stopLoop
,.Net JIT编译器可能会将while循环重写成类似这样:
if (stopLoop) { while (true); }
如果JIT编译器在单线程应用程序中优化代码,那将没问题。但在多线程应用程序中,如果在另一个线程中将stopLoop
设置为false,优化可能会导致无限循环。当我们标记stopLoop
字段为易失性时,JIT编译器将不会为上述代码进行优化,因此条件将与原始代码保持一致。
源代码
我没有附加任何源代码,因为我编写的用于解释的程序非常简单,可以直接复制并执行以查看结果。
以下是一些值得阅读的好参考文献,以获得对主题的深入了解
以下是一些非常好的文章,其中包含太多细节,可能会让一些读者感到困惑。因此,我试图收集和解释有关这些核心.Net内存概念需要了解的必要要点。