原文链接:https://www.c-sharpcorner.com/article/C-Sharp-heaping-vs-stacking-in-net-part-ii/
前言
虽然使用.NET Framework编程时我们不必主动的关注内存管理和垃圾回收,但是为了更优的程序性能,我们还是应该对内存管理和GC有一定的了解。而且,从根本上理解了内存管理的工作方式也可以帮助我们理解程序中的每一个变量的行为。在本篇中,将针对方法参数的行为具体展开。
在第一节中我们就堆栈的工作方式有了基本的了解,以及值类型和引用类型在程序运行时的分配方式。我们还介绍了指针的基本概念。
看重点:参数
在第一节中我们已经介绍了方法在运行时的基本情况,现在我们就来看更详细的内容。
当我们在执行一个方法时,发生了下面这些事:
- 在方法执行的时候,需要在栈中分配空间。其中包括一个相当于goto命令的指针,用来确保当我们的方法在线程中执行完毕以后,知道该回到哪里以继续执行。
- 方法的参数被复制了。这是我们需要注意的地方。
- 完成JIT编译以后线程开始执行代码。之后再去执行下一个方法。
看下面的代码:
public int AddFive(int pValue)
{
int result;
result = pValue + 5;
return result;
}
栈看起来大概是这样的:
注意:方法是不在栈中的,图中的方法名只是为了表示从这里开始的。
在第一节的讨论中,不论参数是值类型还是引用类型,都会被分配到栈中。值类型会生成一份拷贝,引用类型会生成一份引用的拷贝。
值类型的传递
先来看值类型...
首先,当我们在传递一个值类型的时候,会在栈中新分配一个空间用来存储该类型的值。看接下来的这个栗子:
class Class1
{
public void Go()
{
int x = 5;
AddFive(x);
Console.WriteLine(x.ToString());
}
public int AddFive(int pValue)
{
pValue += 5;
return pValue;
}
}
当方法Go()被执行的时候,会在栈中为x分配一个空间,存储的值是5。
然后,AddFive()被压入栈中,并且为它的参数分配空间,存储的是从x拷贝的值。
当AddFive()执行结束以后,线程继续执行Go()方法,因为AddFive()已经结束了,所以pValue实际上就已经被移除了。
你觉得输出的是5吗?看黑板,讲重点:当参数传递给方法时,会生成一个一毛一样的副本,原始的值会被保留着。
还有一件事需要注意:如果我们有一个很大的值类型,比如一个庞大的struct被传递到了栈中,无论分配内存还是其他处理,每一次都会带来巨大的开销。栈的可用空间是有限的,就像是往玻璃瓶里装水,装多了是会溢出的。struct是一个可以很庞大的值类型,所以我们必须要懂得该怎样处理它。
给你展示一个很大的结构体:
public struct MyStruct
{
long a, b, c, d, e, f, g, h, i, j, k, l, m;
}
看一看当在执行Go()方法的时候,都发生了什么:
public void Go()
{
MyStruct x = new MyStruct();
DoSomething(x);
}
public void DoSomething(MyStruct pValue)
{
// DO SOMETHING HERE....
}
这样的效率真的很低。想象一下我们把MyStruct传递了几千次,你就应该能明白,这是一件多么糟糕的事情了。
我们该怎样避开这个问题呢?那就要通过引用传递来实现,如下:
public void Go()
{
MyStruct x = new MyStruct();
DoSomething(ref x);
}
public struct MyStruct
{
long a, b, c, d, e, f, g, h, i, j, k, l, m;
}
public void DoSomething(ref MyStruct pValue)
{
// DO SOMETHING HERE....
}
这样在为对象分配内存时效率就高多了。
这时候我们唯一需要注意的是,当我们通过引用来传递值类型时,我们就可以直接修改这个值类型的值了。
对pValue的修改就是对x的修改。通过下面的代码,我们得到的结果是”12345“,因为pValue.a指向的内存地址,就是变量x的地址。
public void Go()
{
MyStruct x = new MyStruct();
x.a = 5;
DoSomething(ref x);
Console.WriteLine(x.a.ToString());
}
public void DoSomething(ref MyStruct pValue)
{
pValue.a = 12345;
}
引用类型的传递
引用类型的传递和之前栗子中的通过引用传递值类型是相似的。
继续看栗子
public class MyInt
{
public int MyValue;
}
在访问Go()方法时,MyInt()被分配在堆(heap)上,因为他是引用类型的:
public void Go()
{
MyInt x = new MyInt();
}
当我们在执行下面栗子中的Go方法时...
public void Go()
{
MyInt x = new MyInt();
x.MyValue = 2;
DoSomething(x);
Console.WriteLine(x.MyValue.ToString());
}
public void DoSomething(MyInt pValue)
{
pValue.MyValue = 12345;
}
看看发生了什么...
- 在执行Go()方法的时候,首先为变量x在栈上分配内存。
- 执行DoSomething()时,为参数pValue在栈上分配内存。
- x的值(MyInt的地址)被拷贝到pValue
所以,当我们通过pValue来改变MyInt对象的MyValue变量的值时,和通过x来做修改是一样的。我们将得到“12345”。
现在来看点更有趣的东东。当我们通过应用来传递一个引用类型时,会发生什么呢?
让我们尝试一下。假设我们有一个Thing类,Animal类和Vegetable类都是继承自Thing类:
public class Thing
{
}
public class Animal:Thing
{
public int Weight;
}
public class Vegetable:Thing
{
public int Length;
}
然后我们执行下面的Go()方法:
public void Go()
{
Thing x = new Animal();
Switcharoo(ref x);
Console.WriteLine(
"x is Animal : "
+ (x is Animal).ToString());
Console.WriteLine(
"x is Vegetable : "
+ (x is Vegetable).ToString());
}
public void Switcharoo(ref Thing pValue)
{
pValue = new Vegetable();
}
变量x编程了Vegetable。输出结果:
x is Animal : False
x is Vegetable : True
再来看看都发生了什么:
再来看看都发生了什么:
- Go()方法开始执行的时候,指针x被分配在栈中。
- Animal被分配在堆中。
- 在执行Switcharoo()方法的时候,pValue被分配在栈中,并且指向x。
- Vegetable被分配在堆中。
- 通过pValue修改指针x指向的地址为Vegetable。
如果我们不是通过引用来传递Thing,指针x将继续指向Animal,结果就不是这样了。
如果上面的代码你理解不了,去前面的章节看一下关于变量引用的讨论,这会对更好的理解引用类型的工作方式。
写在最后
我们已经知道了传递参数时如何在内存上分配空间并且也知道了该注意些什么。在接下来的一篇里,我们将会关注栈中引用变量发生了什么变化,以及如何克服在复制对象时遇到的问题。