代码改变世界

让你一次性搞定堆、栈、值类型、引用类型……

2010-07-31 16:44  elivsit  阅读(3624)  评论(21编辑  收藏  举报

在.NET中或许我们不用担心内存管理以及垃圾回收器(Garbage Collection GC)的问题,但是我们还是应该了解这些东东以便在必要的时候优化我们程序的性能。而且,如果对内存管理如何工作有所了解,那将有助于解释我们每个程序里的每个变量的运行规律。这篇文章主要内容是解释堆(Heap)和栈(Stack),各种变量以及这些变量到底是如何工作的。

.Net Framework 在执行代码时,有两个用来存储对象的地方,也就是堆和栈,用于帮助执行我们的代码。它们驻留在机器内存中,包含了所有我们需要实现的信息。

  Stack VS Heap

  栈多多少少用来负责跟踪你的代码里正在执行什么,或者说代码里的什么东东被called。而堆则或多或少用来跟踪我们的对象,或者说数据,大多数情况下都是数据啦——后头再详解。

  把栈想象成堆砌起来由上到下的盒子。每次我们调用一个方法,就新加一个盒子到栈顶,我们用这种方法跟踪我们的程序在执行些什么。我们能用的,永远只是最顶上的那个盒子。当我们把最顶上这个盒子用掉了的时候,也就是方法执行完毕并返回的时候,我们就恶狠狠的把它扔掉!然后接着处理下一个盒子里的东东。而堆其实也是类似的东东,除了它的目的是用来保存信息(绝大多数情况下不是用来跟踪程序执行),因此堆里面的任何东西都不受限制的随便访问。堆就像是床上我们没空收拾的洗好的衣服一般,我们能很快的随便拿任何一件起来。而栈则跟壁橱里一堆装鞋子的盒子似的,我们得一个一个的从顶上取下来才能拿到下一双鞋子。

 


  上图虽然并不是真正的内存中堆栈的样子,不过有助于我们理解它们的区别。
栈是“自我维护”的,意思是基本上是管理自个儿的(而不是别的地方的)内存。当顶部盒子不在使用,就扔之(就不是自家的雪了)!而堆呢,不太一样的是,必须得跟GC打交道——这东西用来保证堆是clean(没有过多垃圾内存)的。(木有人喜欢地上摆一堆脏衣服吧!臭死了!)。
  What goes on the Stack and Heap?
当代码执行时,堆栈里头主要放置四种类型的东东:值类型,引用类型,指针(Pointers),以及指令(Instructions)。
  值类型:
  c#中,值类型继承自System.ValueType:
  bool, byte, char, decimal, double, enum, float, int, long, sbyte, short, struct, uint, ulong, ushort
  引用类型:
  而引用类型则有:
  class, interface, delegate, object, string
  指针:
内存管理模型中的第三种东东是对一个类型的引用。这个引用通常就是指指针。我们不能直接使用指针,它们被CLR所管理。指针不同于引用类型。当我们说某某是引用类型时,实际上就意味着我们要通过指针去访问这个类型的值。一个指针占用内存中的一块空间,只想内存中另外一块空间。指针跟任何别的放在堆栈中的东西一样,是要占用物理空间的。它的值要么是null,要么就是内存地址。

 

 

指令:
  在后续文章中再解释,稍安勿躁……
  How is it decided what goes where? (Huh?)
  okok,再啰嗦两句我们就可以正式摆弄我们的堆栈了。
  这里有两条黄金规则:
  引用类型总是保存在堆里头——够清楚了吧
  值类型以及指针,总是保存在其被声明(Declared)的地方。稍微复杂一丁点儿,因为需要理解什么是“其被声明的地方”
  栈,就像我们刚才提到的,负责跟踪单个线程(thread)运行到哪儿了。你不妨把其想象成thread的状态机,每个线程都有自个儿的栈。当我们的代码调某个方法时,线程开始执行JIT编译过并且保存在方法表(methodtable)中的指令集,同时,它把方法参数压入线程栈中。然后开始执行代码并访问方法里需要的、同时已经存在于线程栈顶部的变量。举个例子吧:
public int AddFive(int pValue)
{
   int result;
   result = pValue + 5;
   return result;
}
  让我们看看栈里头都发生了什么,记住我们看到的只是栈顶的东西,下头早有无数别的东东在里面了哦!
  当我们开始执行这个方法时,方法的参数被压栈(稍后我们讨论参数传递)。
  Notice: 方法并不存在stack里头,图例只是为了演示概念

  下一步,控制(线程执行这个方法)被交给AddFive方法在方法表中的指令集,如果这是第一次使用这个方法,JIT编译将被执行。

 

在方法的执行过程中,我们需要内存来保存 "result”,因此栈顶为其分配空间。

  方法结束了,result被返回。

  这时栈顶指针将会移到最初AddFive方法开始的内存地址,这样所有刚才分配的内存空间都被清理掉了,然后接着执行AddFive更下面的函数(图中为显示)。

  在这个例子里,result变量被压栈。事实上,任何时候值变量在方法内部被声明,都会被压栈。
  不过,值变量有时候也会存储在堆里头。看看黄金规则二,值类型总是保存在被声明的地方。那么,如果值类型在方法(method)外部声明,同时本身又存在于引用类型内部时,那么就会被保存在堆里头。
  在来一个例子:
  如果我们有MyInt Class(引用类型):
public class MyInt
{
  public int MyValue;
}
  另外一个方法正在执行中:
public MyInt AddFive(int pValue)
{
  MyInt result = new MyInt();
  result.MyValue = pValue + 5;
  return result;
}
  跟前头一样,线程开始执行方法,参数被压栈:

 


  这个时候开始好玩了:
  因为MyInt是引用类型,因此保存在堆里头,通过栈里的指针去引用它。

  AddFive执行完毕以后,我们开始清理栈顶:

  这样我们就把MyInt对象当作孤儿留在了堆里头,因为栈中没有任何指针在引用它了!

这时候就是GC大显身手的时候啦!一旦我们程序达到某个特定的内存阀值(threshold)而我们需要更多堆空间时,GC就会开动。GC完全停止所有运行中的线程,找到所有堆里没有被引用的对象,然后像秋风扫落叶般的删除它们。GC重新组织所有滞留在内存中活动的对象,并调整堆栈中对其引用的地址。可以看到,从性能角度来说,这可真是很费时间。所以现在你是不是觉得注意一下堆栈的处理会有助于你写出高性能的代码呢?
  OK,好了好了,很棒很棒,不过,这东西到底会如何来折磨俺们呢?
  好问题。
  当我们使用引用类型时,我们实际上是使用指向该对象的指针,而不是对象本身。当我们使用值类型时,我们使用的就是值本身。清楚还是不清楚?
最好还是来个例子吧。  如果我们执行以下方法:
public int ReturnValue()
{
  int x = new int();
  x = 3;
  int y = new int();
  y = x;
  y = 4;
  return x;
}
  我们得到3,够简单吧。
  如果这样呢:
public class MyInt
{
  public int MyValue;
}
public int ReturnValue2()
{
   MyInt x = new MyInt();
   x.MyValue = 3;
   MyInt y = new MyInt();
   y = x;        
   y.MyValue = 4;       
   return x.MyValue;
}
  那么我们将得到4。
  为什么?
  在第一个例子里所有事都按照预定计划行事:
public int ReturnValue()
{
   int x = 3;
   int y = x;  
   y = 4;
   return x;
}

  在后面的例子里,我们得不到3是因为x和y都指向堆里的同一个对象。
public int ReturnValue2()
{
   MyInt x;
   x.MyValue = 3;
   MyInt y;
   y = x;        
   y.MyValue = 4;
   return x.MyValue;
}

  希望本文能帮助您更好的理解值类型和引用类型基本的区别,以及指针是什么,什么时候会用到指针。在后面的系列中,我们进一步的阐述内存管理,特别的会多聊聊方法参数的问题。

 

在Part 1但中,我们简单介绍了堆栈的功能以及值类型、引用类型在堆栈中的存储位置的问题,也简单介绍了指针是虾米。让我们沿着革命的步伐继续前进!
  Parameters, the Big Picture.
  我们的代码执行的时候,底层到底有哪些内幕交易在发生呢?当我们调用一个方法时:
  栈顶分配控件用来存储执行我们的method所包含的信息,这部分空间叫做栈框(stackframe,详情见地板附录)。这里头有一个指针,指向调用地址。通常这是一个GOTO指令,这样线程执行完毕我们的方法后就知道应该回到哪儿去继续执行下一个栈里头的东东。(其实就是把stack frame删掉)
  方法的参数被完全复制。这部分我们会做详细解释。
  控制权被交给JIT编译好的方法指令集,开始真正的执行代码。实际上,在调用栈中还有另外一个方法,存在于栈框里。(见附录)
  代码又来了:
public int AddFive(int pValue)
{
   int result;
   result = pValue + 5;
   return result;
}
  这时我们的栈看起来这酱紫滴:

  就像前面说过的那样,参数如果是值类型,内容会被完整的复制过来。如果是引用类型,被复制的则只是指向堆里头实例的一个指针。
  Passing Value Types.
  首先,当我们传递一个值类型的时候,栈顶分配相应大小的空间,并且把值完整复制过去。例如:
class Class1
{
   public void Go()
   {
     int x = 5;
     AddFive(x);
     Console.WriteLine(x.ToString());   
   }
   public int AddFive(int pValue)
   {
     pValue += 5;
     return pValue;
   }
}
  当方法被执行时,会给x分配空间,并且值5被赋值到分配好的空间里。


  接下来,AddFive被压栈,同时其参数也会压栈(分配空间),然后参数的值会从x,一个字节一个字节的复制过来。

  当AddFive结束执行,线程转回到Go方法中。因为AddFive已经没有利用价值了,因此它的参数pValue也跟着没用了,“删之”。

  所以我们代码的输出应该是5,对不?重点是任何值类型用这种方式传递进方法都是传递的其一个复制本,同时我们希望原先的变量里的值保持不变。
  但是要记住的是,如果我们的值类型是个相当大(占用空间)的东东,例如一个很大的struct,那么对其进行复制操作将是非常耗费时间的。同时栈空间并不是无限大的,就像瓶子从水龙头接水,总是会溢出的哦!struct经常可能会变得很大,因此要小心使用。多大才算大?这就是个大大大的struct咯(有那么大嘛……*(*&@#¥%):
public struct MyStruct
{
  long a, b, c, d, e, f, g, h, i, j, k, l, m;
}
  看看使用这个struct时发生了虾米:
public void Go()
{
  MyStruct x = new MyStruct();
  DoSomething(x); 
}
public void DoSomething(MyStruct pValue)
{
  // DO SOMETHING HERE....
}
这可真是效率低下啊。想像一下,如果有数千个对这个struct的调用,那将会是多恐怖的事情!
  那可咋办呢?我的程序就是要用到几千次的嘛?答案就是传递一个对这个值类型的引用而不是值类型本身:
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实际上指向的就是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;
}Passing Reference Types.
  传递引用类型跟使用引用传递值类型其实基本上差球不多。
  如果我们在其中使用值类型:
public class MyInt
{
  public int MyValue;
}
  并且调用Go方法,MyInt存在于堆上因为其是引用类型:
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
  这样就能解释为什么我们改变作为引用类型属性的值类型MyValue时,结果得到的是改变以后的12345了。
  有意思的是,当我们使用引用传递一个引用类型的时候,会发生什么呢?
  试试吧。假设有下列引用类型:
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在堆中分配(new Vegetable)
  pValue指向x,因此改变pValue的内容实际上是去改变x指向的内容,因此x收pValue的影响指向了Vegetable
  如果我们不通过引用传递,那么我们将得到相反的结果,x仍然是Animal。(为什么?可以自个儿去画个图哈)
  In Conclusion
  接下来的章节里,我们会介绍栈里头的引用变量是怎么回事,以及如何克服复制对象时会遇到的一些麻烦。
  Appendix