C# 堆内存 vs 栈内存 (1)

概述

在.Net当中,有Clr帮我们进行内存管理和垃圾回收,但这并不意味着我们不需要关心这些机制。为了优化程序的性能,在编写程序时,就必须要考虑内存管理和垃圾回收。同时,理解内存管理机制,能够帮我们理解各个类型变量的特性。在这篇文章中,将介绍栈内存和堆内存的基础,以及各种类型变量和他们的特性。

当程序运行时,.Net会在两个地方存储程序信息,堆内存和栈内存,它们只是对内存的逻辑分段,在程序运行过程中扮演者不同的角色,下面将详细的介绍下它们。

栈 vs 堆:区别在哪?

栈内存更多的是负责追踪函数调用,内存堆更多的负责记录数据对象。可以把栈看成是一堆盒子一个压着一个的堆叠在一起,当调用一个函数的时候,会在栈顶堆一个新的盒子,我们只能使用最上面的盒子,当最上面的盒子处理完之后(函数调用完成),我们把它扔掉,然后继续处理当前处于栈顶的盒子。

  • 从结构来说,栈是一种后进先出(LIFO)型结构,只能使用栈顶的数据,将栈顶数据弹出之后,才能使用下面的数据;堆是一种列表类型,可以访问堆中的任何数据 。
  • 从管理方式来说,栈能够对实现对内存的自我管理;堆得内存管理需要通过GC来实现。
    栈、堆结构示意

栈、堆存放内容

在程序执行过程中,主要包含四类数据需要存放到栈、堆上:值类型、引用类型、指针、指令。

值类型

在C#中,所有从System.ValueType继承的类型,都是值类型,包括:

  • bool
  • byte
  • char
  • decimal
  • double
  • enum
  • float
  • int
  • long
  • sbyte
  • short
  • ushort
  • uint
  • ulong
  • struct

引用类型

所有声明为以下类型的都属于引用类型,其中包含:

  • class
  • interface
  • delegate
  • object
  • string

指针

当我们把对象放到堆内存时,访问该对象,就需要一个指向该对象的引用,引用通常就是一个指针,我们不需要显式的使用指针,Clr会对引用进行管理。
注意区别指针(引用)与引用类型的区别,当我们说类型时引用类型时,指的是它需要通过指针来访问;而指针存储着一个指向内存的地址。
指针示意

指令

在程序执行时,内存上除了存储各类数据,还存储着处理指令,比如变量声明、数学运算、跳转等,后续将进行详细介绍。

分配到栈还是堆?

对象内存分配遵循以下两条原则:
- 引用类型总是分配到堆内存。
- 值类型和指针的分配与它们声明位置有关系。

栈的一个主要功能是跟踪线程执行时代码指针的位置、以及被调用的数据,可以把它看做一个线程的状态,每个线程都有自己独立的栈。当调用函数时,会将函数的参数压入线程栈,在方法内的局部变量也会压入线程栈顶。下面是一个最简单的示例。

public int AddFive(int pValue)
{
    int result;
    result = pValue + 5;
    return result;
}

下面看下调用 AddFive方法时,线程栈的变化情况。

  1. 首先在执行函数之前,将函数的实参压入线程栈顶。需要注意的是,方法并不是放在线程栈中,这里只是示意方法调用开始
    将参数压入栈顶

  2. 然后开始执行函数体,线程将指令控制指针,指向 AddFive 指令(存在于类型方法表中),如果时第一次执行函数,会执行JIT编译,将IL指令编译成本机CPU指令。
    修改指令指针

  3. 然后第一行声明了一个局部变量result,函数的局部变量分配在线程栈上(注:如果是引用类型则在线程栈上保存对象指针,后边示例在讨论)。
    将局部变量result压入栈顶

  4. 方法执行结束后, result作为返回值被返回。 (过程中涉及的指令执行会在后续文章进行解释)
    方法执行结束

  5. 通过将指令执行指针移动到 AddFive 执行之前的一个地址,将执行 AddFive 过程中分配的栈内存清理干净,然后返回到之前的方法中(调用AddFive的方法,这里未体现)。
    清理栈内存

值类型有的时候会被分配到堆内存当中,记住值类型内存分配的一个原则:值类型内存的分配,跟该变量声明的位置有关系,例如上面声明为函数的局部变量就分配到线程栈中;如果声明在一个class类中,则分配在堆内存中。看下面一个例子:

//定义MyInt引用类型
public class MyInt
{
    public int MyValue;
}

//定义示例函数
public MyInt AddFive(int pValue)
{
    MyInt result = new MyInt();
    result.MyValue = pValue +5;
    return result;
}

然后分析一个 AddFive 调用过程中,内存的变化情况。
1. 和之前一样,当线程开始调用函数时,首先实参会被压入到线程栈顶。
实参入栈

  1. 下面进行到函数体内,这里开始和之前就不一样了,同样是声明一个 result 变量,此时由于 result是引用类型,所以被分配到堆内存中。首先向线程栈压入一个指针,然后将MyInt实例分配到堆内存并返回内存地址,最后将指针指向返回的内存地址。
    result分配内存

  2. 当 AddFive 执行完成之后,清理线程栈内存。注意这里只是清理栈内存,堆内存会由GC管理。
    清理栈内存

而我们在堆内存上分配的MyInt,此时已经没有任何变量指向它,已经变成了一块内存垃圾,GC进行垃圾回收时会将该内存回收。
变成垃圾的MyInt实例

这里就可以看到垃圾回收(GC)的作用了,它的作用就是对堆内存进行管理和回收,当GC开始调用时,会挂起所有正在运行的线程,然后回收线程会检查内存堆,标记出堆中的垃圾,并将垃圾删除以清理更多可用内存,可以想象,我们堆内存是一块连续的内存地址,清理其中垃圾之后,就会造成内存碎片,所以最后GC会对剩下的对象进行重定位,同时会更新所有指向这些对象的指针(引用)。这一系列操作在性能消耗方面非常昂贵,所以在编写高性能代码时,要注意栈和堆得内存分配。

那么以上这些(分配在堆内存还是栈内存),对我们会产生什么影响呢?
当我们使用引用类型,我们处理的是实例对象的指针,并非对象本身;当我们使用值类型时,我们处理的是实例对象本身。下面通过例子看下区别。

public int ReturnValue()
{
    int x = new int();
    x = 3;
    int y = new int();
    y = x;
    y = 4;
    return x;
}

此时的返回值为 3.

下面使用MyInt 实现上面代码:

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.

下面是以上两个实例的堆栈情况:
第一个示例堆栈情况:
使用int示例

第二个示例堆栈情况,x和y都指向内存堆得同一个物体:
使用MyInt示例

希望能够通过以上几个示例,能够更好理解C#值类型与引用类型之间的区别,以及指针(引用)的使用以及什么时候会使用指针,其中涉及的函数过程调用、指令执行、GC等内容,只做了简单介绍,没有深入解释,后续会详细的解释其中部分内容。在下一部分当中,将进一步讨论内存管理相关内容,重点研究方法参数。

  • 1
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值