c#知识总结-垃圾回收

目录

什么是垃圾回收

垃圾回收机制

垃圾回收算法

 垃圾回收触发条件

大对象

 常见的内存泄漏情况

使用弱引用(Weak References)

.NET Core和.NET 5的垃圾回收特性

 托管对象的最终化(Finalization)

高性能应用程序的垃圾回收策略 

最佳实践和建议

1.编写高效的C#代码

 2、优化内存使用

 3、处理大型数据集合的技巧


什么是垃圾回收

在编写程序时,会产生很多数据,比如int、string变量,这些数据都存储在内存中,如果不合理的管理它们,就会内存溢出导致程序崩溃。管理的过程即为垃圾回收。

垃圾回收是一种自动管理内存的机制,它主要目标是在程序运行时自动检测和释放不在被程序使用的内存,从而减少内存泄漏和提高程序的性能和稳定性。C#内置了自动垃圾回收GC,不需要担心内存泄漏的问题,在变量失去引用后,某个时刻GC会帮我们自动回收,但不包含数据流、数据库连接、静态的变量等,这些需要我们手工的释放资源。

垃圾回收机制

在c#中,垃圾回收是由CLR来执行的。CLR负责管理托管代码的执行,其中包含内存分配和回收。c#中垃圾回收是基于代的,即将托管堆中的对象分为不同的代,每个代具有不同生命周期。垃圾回收根据对象的代进行不同的回收策略,通常会优先回收那些生命周期较短的对象。

垃圾回收算法

1.标记-整理(Mark and Compact)算法

标记阶段: 垃圾回收器会从根对象开始遍历内存中的对象图,标记所有可达对象。这些根对象可以是全局变量、线程栈或静态变量等。一旦标记完成,垃圾回收器就会知道哪些对象是活动的,而哪些对象是垃圾。

清除阶段: 垃圾回收器会遍历整个堆,清除未被标记的对象。这些未被标记的对象被认为是垃圾,可以安全地释放它们所占用的内存并存活的对象紧凑地移动到堆的一端,从而消除内存碎片。

2.分代收集(Generational Collection)

分代收集是一种优化垃圾回收性能的策略。根据经验观察,大多数对象的生命周期都比较短暂,因此分代收集将堆分为几个代,通常是三代:年轻代(0代)、中年代(1代)和老年代(2代)。
在年轻代中,大部分对象都是短暂的,因此可以使用一种较轻量级的垃圾回收算法来频繁地回收内存。而在老年代中,存活的对象更多,因此可以使用更复杂、成本更高的算法来进行垃圾回收。
通过将堆分成几代,并根据对象的生命周期采用不同的回收策略,分代收集能够有效地提高垃圾回收的性能和效率。

代的工作原理:

托管堆在初始化时不把不包含对象。添加到堆的对象为第0代对象。简单来说,第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。图1展示了一个新启动的应用程序,他分配了5个对象(从A到E)。过了一会,对象C和E变得不可达。

图1:一个新初始化的堆,包含一些对象且对象都是0代,垃圾回收尚未发生

CLR初始化时为第0代对象选择-一个 预算容量(以KB为单位)。如果分配一个新对象造成CLR初始化时为第0代对象选择-一个 预算容量(以KB为单位)。如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。假设对象A到E刚好用完第0代的空间,那么分配对象F就必须启动垃圾回收。垃圾回收器判断对象C和E是垃圾,所以会压缩对象D,使之与对象B相邻。在垃圾回收中存活的对象(A,B和D)现在成为第1代对象。第1代对象已经经历了垃圾回收器的一次检查。 此时的堆如图2所示。

图2:经过一次垃圾回收,第0代的幸存者被提升至第1代;第0代暂时是空的

 一次垃圾回收后,第0代就不包含任何对象了。和前面一样,新对象会分配到第0代中。,
在图3中,应用程序继续运行,并新分配了对象F到对象K。另外,随着应用程序继续
运行,对象B,H和J变得不可达,它们的内存将在某一时刻回收。

图3:第0代分配了新对象:第1代有垃圾产生

现在,假定分配新对象L会造成第0代超出预算,造成必须启动垃圾回收。开始垃圾回收
时,垃圾回收器必须决定检查哪些代。前面说过,CLR初始化时会为第0代对象选择预算。
事实上,它还必须为第1代选择预算。

开始一.次垃圾回收时,垃圾回收器还会检查第1代占用了多少内存。在本例中,由于第I
代占用的内存远少于预算,所以垃圾回收器只检查第0代中的对象。回顾一下基于代的垃
圾回收器做出的假设。第一个假设是越新的对象活得越短。因此,第0代包含更多垃圾的
可能性很大,能回收更多的内存。由于忽略了第1代中的对象,所以加快了垃圾回收速度。
显然,忽略第1代中的对象能提升垃圾回收器的性能。但对性能有更大提振作用的是现在
不必遍历托管堆中的每个对象。如果根或对象引用了老-代的某个对象,垃圾回收器就可
以忽略老对象内部的所有引用,能在更短的时间内构造好可达对象图(graphofreachable
object)。当然,老对象的字段也有可能引用新对象。为了确保对老对象的已更新字段进行
检查,垃圾回收器利用了JIT 编译器内部的一个机制。这个机制在对象的引用字段发生变
化时,会设置一个对应的位标志。这样,垃圾回收器就知道自上一次垃圾回收以来,哪些
老对象(如果有的话)已被写入。只有字段发生变化的老对象才需检查是否引用了第0代中
的任何新对象。

基于代的垃圾回收器还假设越老的对象活得越长。也就是说,第1代对象在应用程序中很
有可能是继续可达的。如果垃圾回收器检查第1代中的对象,很有可能找不到多少垃圾,结果是回收不了多少内存。因此,对第1代进行垃圾回收很可能是浪费时间。如果真的有
垃圾在第1代中,它将留在那里。此时的堆如图4所示。

 图4:经过两次垃圾回收之后,第0代的幸存者被提升至第1代(第1代的大小增加);第0代又空出来了

如你所见,所有幸存下来的第0代对象都成了第1代的一-部分。由于垃圾回收器没有检查
第1代,所以对象B的内存并没有被回收,即使它在上一次垃圾回收时已经不可达。同样,
在一次垃圾回收后,第0代不包含任何对象,等着分配新对象。假定应用程序继续运行,
并分配对象L到对象O。另外,在运行过程中,应用程序停止使用对象G,L和M,使它
们变得不可达。此时的托管堆如下图5所示。
 

图5:新对象分配到第0代中;第1代产生了更多的垃圾

 假设分配对象P导致第0代超过预算,垃圾回收发生。由于第1代中的所有对象占据的内
存仍小于预算,所以垃圾回收器再次决定只回收第0代,忽略第1代中的不可达对象(对象
B和G)。回收后,堆的情况如下图6所示。

图6:经过三次垃圾回收,第0代的幸存者被提升至第1代(第1代的大小再次增加);第0代空出来了

从上图6可以看到,第1代正在缓慢增长。假定第1代的增长导致它的所有对象占用了全
部预算。这时,应用程序继续运行(因为垃圾回收刚刚完成),并分配对象P到对象S,使第
0代对象达到它的预算容量。这时的堆如下图7所示。

图7:新对象分配到第0代;第1代有了更多的垃圾

 应用程序试图分配对象T时,由于第0代已满,所以必须开始垃圾回收。但这一次垃圾回
收器发现第1代占用了太多内存,以至于用完了预算。由于前几次对第0代进行回收时,
第1代可能已经有许多对象变得不可达(就像本例这样)。所以这次垃圾回收器决定检查第
I代和第 0代中的所有 对象。两代都被垃圾回收后,堆的情况如图8所示。

图8:经过4次垃圾回收后,第1代的幸存者提升至第2代,第0代的幸存者提升至第1代,
第0代空出来了

和之前一样,垃圾回收后,第0代的幸存者被提升至第1代,第1代的幸存者被提升至第
2代,第0代再次空出来了,准备好迎接新对象的到来。第2代中的对象经过了2次或更
多次检查。虽然到目前为止已发生过多次垃圾回收,但只有在第1代超出预算时才会检查
第1代中的对象。而在此之前,-般都已经对第0代进行了好几次垃圾回收。

托管堆只支持三代:第0代、第1代和第2代。没有第3代。CLR初始化时,会为每一-代
选择预算。然而,CLR的垃圾回收器是自调节的。这意味着垃圾回收器会在执行垃圾回收
的过程中了解应用程序的行为。例如,假定应用程序构造了许多对象,但每个对象用的时
间都很短。在这种情况下,对第0代的垃圾回收会回收大量内存。事实上,第0代的所有
对象都可能被回收。

如果垃圾回收器发现在回收0代后存活下来的对象很少,就可能减少第0代的预算。已
分配空间的减少意味着垃圾回收将更频繁地发生,但垃圾回收器每次做的事情也减少了,
这减小了进程的工作集。事实上,如果第0代中的所有对象都是垃圾,垃圾回收时就不必
压缩(移动)任何内存:只需让NextObjPtr指针指回第0代的起始处即可。这样回收可真快!

另一方面,如果垃圾回收器回收了第0代,发现还有很多对象存活,没有多少内存被回收,,
就会增大第0代的预算。现在,垃圾回收的次数将减少,但每次进行垃圾回收时,回收的
内存要多得多。顺便说一句,如果没有回收到足够的内存,垃圾回收器会执行一次完整回
收。如果还是不够,就抛出OutOfMemoryException异常。 

 垃圾回收触发条件
  • 显示调用System.GC的静态Collect方法。(通常不建议使用,会降低程序性能)
  • windows 报告低内存情况
  • CLR正在卸载AppDomain
  • CLR正在关闭
大对象

CLR将对象分为大对象和小对象。目前认为85000字节或更大的对象为大对象。CLR以不同的方式对待大小的对象。

  • 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。
  • 目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。但这可能在进程中
    的大对象之间造成地址空间的碎片化,以至于抛出OutOfMemoryException。CLR将
    来的版本可能压缩大对象。
  • 大对象总是第2代,绝不可能是第0代或第I代。所以只能为需要长时间存活的资源
    创建大对象。分配短时间存活的大对象会导致第2代被更频繁地回收,会损害性能。
    大对象一般是大字符串(比如XML或JSON)或者用于I/O操作的字节数组(比如从文件
    或网络将字节读入缓冲区以便处理)。
     
 常见的内存泄漏情况
  • 事件订阅未取消: 如果对象订阅了事件,但未在不再需要时取消订阅,事件发布者会继续持有对该对象的引用,从而阻止对象被垃圾回收。
  • 静态集合的持久引用: 静态集合在应用程序生命周期内持有对象的引用,如果不注意释放这些引用,会导致对象无法被回收。
  • 未释放资源: 例如文件句柄、数据库连接等资源,如果未在适当的时候释放,会导致资源泄漏。
使用弱引用(Weak References)

弱引用是一种特殊类型的引用,它允许对象被垃圾回收器回收,即使有弱引用指向该对象。在C#中,可以使用System.WeakReference类来创建弱引用。
弱引用通常用于缓存等场景,其中对象的生命周期可能比较长,但又不希望因为缓存对象而阻止垃圾回收。通过使用弱引用,可以避免内存泄漏,并在需要时重新加载或重新计算缓存对象。

.NET Core和.NET 5的垃圾回收特性

.NET Core和.NET 5是跨平台的.NET实现,它们具有不同于传统.NET Framework的垃圾回收特性和行为。
Server GC的默认行为:与Windows上的.NET Framework不同,.NET Core和.NET 5在服务器上使用Server GC作为默认的垃圾回收器。Server GC在多核系统上能够更好地利用硬件资源,并提供更好的性能。
使用交互式模式(Interactive Mode):.NET Core和.NET 5引入了一种称为交互式模式的新特性,该模式可以减少垃圾回收的停顿时间,提高应用程序的响应性。交互式模式可以通过设置相应的环境变量来启用。

垃圾回收的性能调优: 与传统的.NET Framework相比,.NET Core和.NET 5提供了更多的垃圾回收参数和选项,使开发人员能够更精细地调整垃圾回收器的行为,以适应不同场景下的需求。

 托管对象的最终化(Finalization)

在C#中,可以通过重写类的Finalize()方法来实现托管对象的最终化。最终化是指在对象被垃圾回收之前,CLR会调用对象的Finalize()方法来执行一些清理工作,例如释放非托管资源或关闭文件句柄。
尽管最终化提供了一种释放资源的机制,但它并不是可靠的。由于最终化的执行时机不确定,可能会导致资源泄漏或性能问题。因此,通常建议使用IDisposable接口和using语句来手动释放资源,而不是依赖最终化。

高性能应用程序的垃圾回收策略 
  • 减少内存分配: 通过对象池、复用对象等方式尽量减少内存分配的次数,从而降低垃圾回收的压力。
  • 避免频繁的大型对象分配:大型对象的分配和回收会增加垃圾回收的成本,因此应尽量避免频繁地分配和释放大型对象。
  • 使用并发垃圾回收: 并发垃圾回收器可以在垃圾回收的同时继续执行应用程序的其他线程,从而减少垃圾回收对应用程序响应性的影响。

通过采取这些策略,可以有效地提高高性能应用程序的性能和稳定性。

最佳实践和建议
1.编写高效的C#代码
  • 避免频繁的对象创建和销毁: 尽量复用对象、使用对象池等方式减少对象的创建和销毁次数。
  • 优化集合操作: 使用适当的集合类型和数据结构,并注意避免在循环中频繁地对集合进行修改。
  • 注意字符串操作: 字符串是不可变的,频繁的字符串操作会导致大量的内存分配和拷贝。考虑使用StringBuilder类或其他方式来优化字符串操作。
 2、优化内存使用
  • 及时释放资源: 在使用完资源后及时释放,并确保在Dispose()方法中正确释放非托管资源。
  • 避免内存泄漏: 注意避免常见的内存泄漏情况,例如事件订阅未取消、静态集合的持久引用等。
  • 合理设置垃圾回收器参数: 根据应用程序的性能需求和内存使用情况,合理设置垃圾回收器的参数和选项。
 3、处理大型数据集合的技巧
  • 分页加载:对于大型数据集合,采用分页加载的方式,只加载当前需要显示的数据,可以减少内存占用和提高性能。
  • 异步处理: 使用异步操作来处理大型数据集合,可以避免阻塞主线程,提高应用程序的响应性。
  • 数据缓存: 对于频繁使用的数据集合,考虑使用缓存来减少对数据库或其他数据源的访问次数,提高性能。

一个垃圾回收小例子:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.Remoting.Messaging;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp2
{
    class Program
    {
        static void Main(string[] args)
        {
            //创建每2000毫秒就调用一次TimerCallback方法的Timer对象
            Timer t = new Timer(timerCallback, null, 0, 2000);
            Console.ReadLine();//等待用户按Enter键
        }

        static void timerCallback(object state)
        {
            Console.WriteLine("in timerCallback :" + DateTime.Now);
            //等待用户按Enter键
            GC.Collect();
        }
    }
}

 观察代码,可能以为TimerCallback方法每隔2000亳秒调用一次。 毕竟,代码创建了一一个
Timer对象,而且有一个变量t引用该对象。只要计时器对象存在,计时器就应该一直触发。但要注意,TimerCallback 方法调用GC.Collect()强制执行了一次垃圾回收。

回收开始时,垃圾回收器首先假定堆中的所有对象都是不可达的(垃圾);这自然也包括
Timer对象。然后,垃圾回收器检查应用程序的根,发现在初始化之后,Main方法再也没
有用过变量t。既然应用程序没有任何变量引用Timer 对象,垃圾回收自然会回收分配给
它的内存:这使计时器停止触发,并解释了为什么TimerCallback方法只被调用了一次。

注:

  • TimerCallback方法只被调用了一次,只存在为Release编译且框架为.net framework.
  • Debug编译下,每2秒会执行一次
  • 在.net core或.net5及以后版本,无论是debug还是Release 都会每2秒执行一次。
  • 12
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值