从C# 堆栈与堆(托管堆)说起(收集+整理+我的思考)

开头语 :值类型直接存储其值,引用类型存储对值的引用,值类型存在堆栈上,引用类型存储在托管堆上,值类型转为引用类型叫做装箱,引用类型转为值类型叫拆箱。

 

 (前面是细节,事实上可以从总结开始看起)

1 C#堆栈的工作方式

 

 Windwos使用虚拟寻址系统,把程序可用的内存地址映射到硬件内存中的实际地址,其作用是32位处理器上的每个进程都可以使用4GB的内存-无论计算机上有多少硬盘空间(在64位处理器上,这个数字更大些)。这4GB内存包含了程序的所有部份-可执行代码,加载的DLL,所有的变量。这4GB内存称为虚拟内存。

  4GB的每个存储单元都是从0开始往上排的。要访问内存某个空间存储的值。就需要提供该存储单元的数字。在高级语言中,编译器会把我们可以理解的名称转换为处理器可以理解的内存地址。

  在进程的虚拟内存中,有一个区域称为堆栈,用来存储值类型。另外在调用一个方法时,将使用堆栈复制传递给方法的所有参数。

  我们注意一下C#中变量的作用域,如果变量a在变量b之前进入作用域,b就会先出作用域。看下面的例子:

{

int a;

//do something

    {

int b;

//do something

    }

}

  声明了a之后,在内部代码块中声明了b,然后内部代码块终止,b就出了作用域,然后a才出作用域。在释放变量的时候,其顺序总是与给它们分配内存的顺序相反,后进先出,是不是让你想到了数据结构中的栈(LIFO--Last IN First Out)。这就是堆栈的工作方式。

  我们不知道堆栈在地址空间的什么地方,其实C#开发是不需要知道这些的。

举例说明

在C#中,虚拟内存中有个两个存储变量的区域,一个称为堆栈,一个称为托管堆,托管堆的出现是.net不同于其他语言的地方,堆栈存储值类型数据,而托管堆存储引用类型如类、对象,并受垃圾收集器的控制和管理。在堆栈中,一旦变量超出使用范围,其使用的内存 间会被其他变量重新使用,这时其空间中存储的值将被其他变量覆盖而不复存在,但有时候我们希望这些值仍然存在,这就需要托管堆来实现。我们用几段代码来说明其工作原理,假设已经定义了一个类class1:

class1 object1;

object1=new class1();

第一句定义了一个class1的引用,实质上只是在堆栈中分配了一个4个字节的空间,它将用来存府后 来实例化对象在托管堆中的地址,在windows中这需要4个字节来表示内存地址。第二句实例化object1对象,实际上是在托管堆中开僻了一个内存空间来存储类class1的一个具体对象,假设这个对象需要36个字节,那么object1指向的实际上是在托管堆一个大小为36个字节的连续内存空间开始的地址。由此也可以看出在C#编译器中为什么不允许使用未实例化的对象,因为这个对象在托管堆中还不存在。当对象不再使用时,这个被存储在堆栈中的引用变量将被删除,但是从上述机制可以看出,在托管堆中这个引用指向的对象仍然存在,其空间何时被释放取决垃圾收集器而不是引用变量失去作用域时。

在使用电脑的过程中大家可能都有过这种经验:电脑用久了以后程序运行会变得越来越慢,其中一个重要原因就是系统中存在大量内存碎片,就是因为程序反 复在堆栈中创建和释入变量,久而久之可用变量在内存中将不再是连续的内存空间,为了寻址这些变量也会增加系统开销。在.net中这种情形将得到很大改善, 这是因为有了垃圾收集器的工作,垃圾收集器将会压缩托管堆的内存空间,保证可用变量在一个连续的内存空间内,同时将堆栈中引用变量中的地址改为新的地址,这将会带来额外的系统开销,但是,其带来的好处将会抵消这种影响,而另外一个好处是,程序员将不再花上大量的心思在内在泄露问题上。

当然,以C#程序中不仅仅只有引用类型的变量,仍然也存在值类型和其他托管堆不能管理的对象,如果文件名柄、网络连接和数据库连接,这些变量的释放仍需要程序员通过析构函数或IDispose接口来做。

性能问题:

数据传入方法作为值类型参数,即在堆栈上创建了每个参数的副本。很明显,如果相关参数是大型数据类型(例如,包含很多元素的用户定义结构),或多次执行方法,都可能影响性能。

2托管堆的垃圾收集

 对象不再被引用时,会删除堆中已经不再被引用的对象。如果仅仅是这样,久而久之,堆上的自由空间就会分散开来,给新对象分配内存就会很难处理,.NET运行库必须搜索整个堆才能找到一块足够大的内存块来存储整个新对象。

  但托管堆的垃圾收集器运行时,只要它释放了能释放的对象,就会压缩其他对象,把他们都推向堆的顶部,形成一个连续的块。在移动对象的时候,需要更新所有对象引用的地址,会有性能损失。但使用托管堆,就只需要读取堆指针的值,而不用搜索整个链接地址列表,来查找一个地方放置新数据。

因此在.NET下实例化对象要快得多,因为对象都被压缩到堆的相同内存区域,访问对象时交换的页面较少。Microsoft相信,尽管垃圾收集器需要做一些工作,修改它移动的所有对象引用,导致性能降低,但这样性能会得到弥补。

 

装箱与拆箱

  有了上面的知识做铺垫,看下面一段代码

 int i = 1;
object o = i;//装箱
int j = (int)o;//拆箱

  int i=1;在堆栈中分配了一个4个字节的空间来存储变量 i 。

  object o=i;

  装箱的过程: 首先在堆栈中分配一个4个字节的空间来存储引用变量 o,

  然后在托管堆中分配了一定的空间来存储 i 的拷贝,这个空间会比 i所占的空间稍大些,多了一个方法表指针和一个SyncBlockIndex,并返回该内存地址。

  最后把这个地址赋值给变量o,o就是指向对象的引用了。o的值不论怎么变化,i 的值也不会变,相反你 i 的值变化,o也不会变,因为它们存储在不同的地方。

  int j=int(o);

  拆箱的过程:在堆栈分配4字节的空间保存变量J,拷贝o实例的值到j的内存,即赋值给j。

  注意,只有装箱的对象才能拆箱,当o不是装箱后的int型时,如果执行上述代码,会抛出一个异常。

  这里有一个警告,拆箱必须非常小心,确保该值变量有足够的空间存储拆箱后得到的值。

 long a = 999999999;
object b = a;
int c = (int)b;

  C#int只有32位,如果把64位的long值拆箱为int时,会产生一个InvalidCastExecption异常。

 总结  

值类型的内容存储在堆栈上分配的内存中。例如,在本例中,值 42 存储在称为“栈”的内存区域中:

C#
int x = 42;


由于定义变量的方法结束执行而使变量 x 超出范围时,其值则从栈中丢弃。

使用栈效率较高,但值类型的生命周期有限,不适合在不同类之间共享数据。

相反,引用类型(例如,类或数组的实例)在另一个称为“堆”的内存区域中分配。在下面的示例中,构成数组的 10 个整数所需的空间是在堆上分配的。

C#
int[] numbers = new int[10];


在方法完成时,并不将此内存归还给堆;仅当 C# 的垃圾回收系统确定不再需要该内存时,才进行回收。声明引用类型需要更多系统开销,但它们的优点是可以从其他类进行访问。


在C#中每种类型的存储方式有两种:1)分配在托管栈中;2)分配在托管堆中;

内存的分配有CLR管理(即公共语言运行时),这两种方法的区别是:

1)分配在托管栈中的变量会在创建它们的方法返回时自动释放,例如在一个方法中声明Char型的变量UserInput=C,当实例化它的方法结束时,UserInput变量在栈上占用的内存就会自动释放;

2)分配在托管堆中的变量并不会在创建它们的方法结束时释放内存,它们所占用的内存会被CLR中的垃圾回收机制释放。


装箱和拆箱

当值类型的数据转换成引用类型时,CLR会先在托管堆配置一块内存,将值类型的数据复制到这块内存,然后再让托管栈上的引用类型的变量指向这块内存,这样的过程称为装箱。相反的话,有引用类型转换成值类型的话就称为拆箱。

一般情况下,.NET会主动的帮我们完成装箱操作,但是拆箱并非主动,我们必须知道拆箱对象的实力类型,然后明确的去执行拆箱操作。

1 int BirthdayNum = 1989;
2             object BoxBirthdayNum = BirthdayNum;//系统自动装箱
3             int nBirthdayNum = (int)BoxBirthdayNum;//明确数据类型的拆箱

因为花费了更多的时间,所以装箱和拆箱对程序的性能有一定的影响。


类型推断

在C#中有两种类型的数据,一种是值类型,另一种是引用类型。

值类型包括:内置值类型、用户自定义值类型、和枚举,如 int,float bool 等,以及struct等。

引用类型包括接口类型、用户自定义的类、委托等。如 string 、DateTime、数组等。

值类型是存储在堆栈中,而引用类型是存储在托管堆上,C#程序首先被编译成IL程序,然后在托管执行。值类型直接从堆栈中里面取值,而引用类型必须要先从堆栈里面取出它的地址,再根据这个地址在堆里找到对应的值。

值类型与饮用类型的本质区别有以下几点:

1.内存分配: 值类型是分配在栈中的;而引用类型是分配在堆中。

2.效率: 值类型效率高,不需要地址转换;引用类型效率较低,需要进行地址转换。

3.内存回收: 值类型使用完后立即回收;引用类型使用完后不立即回收,而是交给GC处理回收。

4.赋值操作: 值类型是创建一个新对象;引用类型创建一个引用。

5.类型扩展: 值类型不易扩展,所有值类型都是密封(seal)的,所以无法派生出新的值类型;引用类型具有多态的特性方便扩展。


C#之所以要分这两种数据类型的原因是达到更好的性能,把一些基本类型如int、bool规定为值类型,而把包含许多字段的较大类型规定为引用类型,如用户自定义的类。值类型主要是负责存储数据,引用类更多是用在代码的重用性上。


几个相关的关键字:

C#中关键字ref与out的区别

在C#中,ref与out是很特殊的两个关键字。使用它们,可以使参数按照引用来传递。

总的来说,通常我们向方法中传递的是值.方法获得的是这些值的一个拷贝,然后使用这些拷贝,当方法运行完毕后,这些拷贝将被丢弃,而原来的值不将受到影响.此外我们还有其他向方法传递参数的形式,引用(ref)和输出(out).

有时,我们需要改变原来变量中的值,这时,我们可以向方法传递变量的引用,而不是变量的值.引用是一个变量,他可以访问原来变量的值,修改引用将修改原来变量的值.变量的值存储在内存中,可以创建一个引用,他指向变量在内存中的位置.当引用被修改时,修改的是内存中的值,因此变量的值可以将被修改.当我们调用一个含有引用参数的方法时,方法中的参数将指向被传递给方法的相应变量,因此,我们会明白,为什么当修改参数变量的修改也将导致原来变量的值.


ref

ref 关键字使参数按引用传递。其效果是,当控制权传递回调用方法时,在方法中对参数所做的任何更改都将反映在该变量中。

  1. 若要使用 ref 参数,则方法定义和调用方法都必须显式使用 ref 关键字。
  2. 传递到 ref 参数的参数必须最先初始化。这与 out 不同,out 的参数在传递之前不需要显式初始化。
  3. 属性不是变量,因此不能作为 ref 参数传递。
  4. 尽管 ref 和 out 在运行时的处理方式不同,但它们在编译时的处理方式是相同的。因此,如果一个方法采用 ref 参数,而另一个方法采用 out 参数,则无法重载这两个方法。例如,从编译的角度来看,以下代码中的两个方法是完全相同的。如果尝试这么做,将导致不能编译该代码。
  5. 如果一个方法采用 ref 或 out 参数,而另一个方法不采用这两类参数,则可以进行重载
  static   void  TestRefAndOut()
        
{
            
string s1 = "Good Luck!";
            TestRef(
ref s1);
            Console.WriteLine(s1);
//output: Hello World!
        }


        
static   void  TestRef( ref   string  str)
        
{
            str 
= "Hello World!";
        }
复制代码

TestRefAndOut()中将字符串s1以ref关键字的方式传到方法TestRef(ref string str)中,在这个方法中,我们改变了s1的引用变量str的值,最后,回到TestRefAndOut()方法后输出s1的值,发现其值已被改变。


将上例中的ref换成out,代码如下:

 

复制代码
         static void TestRefAndOut()
        {
            
string s1 = "Good Luck!";
            
//TestRef(ref s1);
            TestOut(out s1);
            Console.WriteLine(s1);
//output: Hello World!
        }

        
static void TestOut(out string str)
        {
            str 
= "Hello World!";
        }
复制代码

 

同样,在将ref换成out后,会发现最后的输出仍然是相同的,那这两个关键字的区别是什么呢?

进一步测试:

ref:

复制代码
         static   void  TestRefAndOut()
        {
            
string  s1  =   " Good Luck! " ;
            TestRef(
ref  s1);
        }

        
static   void  TestRef( ref   string  str)
        {
            Console.WriteLine(str);
// output: Good Lick!            
        }
复制代码

 out

 

 

复制代码
         static   void  TestRefAndOut()
        {
            
string  s1  =   " Good Luck! " ;
            TestOut(
out  s1);
        }

        
static   void  TestOut( out   string  str)
        {
            Console.WriteLine(str);
// compile does not pass
        }
复制代码

 

ref的那段代码顺利编译,输出"Good Luck!",而out那段代码却无法通过编译,提示“Use of unassigned out parameter 'str' ”,即使用了未分配地址的out参数str。怎么回事呢?

原来out参数在进入方法的时候,C#会自动清空它的一切引用和指向,所以在上面的out例子中,必需先要为str参数赋值。如以下程序。

复制代码
         static   void  TestRefAndOut()
        {
            
string  s1  =   " Good Luck! " ;
            TestOut(
out  s1);
        }

        
static   void  TestOut( out   string  str)
        {
            str 
=   " Hello World! " ;
            Console.WriteLine(str);
// output: Hello World!
        }
复制代码

 

Ok,得到第一个区别: out 参数在进入方法(函数)时后清空自己,使自己变成一个干净的参数,也因为这个原因必须在方法返回之前或再使用out参数前为 out 参数赋值(只有地址没有值的参数是不能被.net接受的);而ref参数是不需要在被调用方法使用前先赋值的,甚至也可以被调用方法中不改变ref参数的值,这都不会引起编译错误。

在继续看一段代码:

ref:

复制代码
         static   void  TestRefAndOut()
        {
            
string  s1;
            TestRef(
ref  s1);
            Console.WriteLine(s1);
// compile does not pass!
        }

        
static   void  TestRef( ref   string  str)
        {
            str 
=  Hello World ! " ;
        }    

out
        
static   void  TestRefAndOut()
        {
            
string  s1;
            TestOut(
out  s1);
            Console.WriteLine(s1);
// output: Hello World!
        }

        
static   void  TestOut( out   string  str)
        {
            str 
=   " Hello World! " ;
        }   
复制代码

 

这回发现,ref这段代码无法编译了,s1是一个空引用,所以无法使用。而out参数则因为上述的那个原因,它不在乎s1是不是空引用,因为就算s1不是空引用,它也会把s1变成空引用的。

Ok,第二个区别:ref参数在使用前必需初始化,而out不需要。

嗯,由上边两个区别可以引申一下,out参数只进不出,ref参数有进有出。在用法上概括一下就是:out适合用在需要retrun多个返回值的地方,而ref则用在需要被调用的方法修改调用者的引用的时候。


注:在C#中,方法的参数传递有四种类型:传值(by value),传址(by reference),输出参数(by output),数组参数(by array)。传值参数无需额外的修饰符,传址参数需要修饰符ref,输出参数需要修饰符out,数组参数需要修饰符params。传值参数在方法调用过 程中如果改变了参数的值,那么传入方法的参数在方法调用完成以后并不因此而改变,而是保留原来传入时的值。传址参数恰恰相反,如果方法调用过程改变了参数 的值,那么传入方法的参数在调用完成以后也随之改变。实际上从名称上我们可以清楚地看出两者的含义--传值参数传递的是调用参数的一份拷贝,而传址参数传 递的是调用参数的内存地址,该参数在方法内外指向的是同一个存储位置


var关键字(根据赋值来自动判断类型)



1. 必须在定义时初始化。也就是必须是var s = “abcd”形式,而不能是如下形式:
var s;
s = “abcd”;

2. 一但初始化完成,就不能再给变量赋与初始化值类型不同的值了。

3. var要求是局部变量。

4. 使用var定义变量和object不同,它在效率上和使用强类型方式定义变量完全一样。

(最后一句我的理解大概是object类型和其他类型之间的装换都要进行装箱和拆箱,而var类型就如果一种“一般”类型一样,

只是这个类型只是在你赋值的那一瞬间才定下来,虽然名字没变,但是类型已经是固定成了一个“一般”类型了)









 

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值