c#垃圾回收

在公共语言运行时 (CLR) 中,垃圾回收器用作自动内存管理器。 它提供如下优点:

  • 在开发应用程序时,不必为所创建的对象手动释放内存。

  • 有效分配托管堆上的对象。

  • 回收不再使用的对象,清除它们的内存,并保留内存以用于将来分配。 托管对象会自动获取干净的内容来开始,因此,它们的构造函数不必对每个数据字段进行初始化。

  • 通过确保对象不能使用另一个对象的内容来提供内存安全。

内存基础知识

下面的列表总结了重要的 CLR 内存概念。

  • 每个进程都有其自己单独的虚拟地址空间。 同一台计算机上的所有进程共享相同的物理内存,如果有页文件,则也共享页文件。

  • 默认情况下,32 位计算机上的每个进程都具有 2 GB 的用户模式虚拟地址空间。

  • 作为一名应用程序开发人员,你只能使用虚拟地址空间,请勿直接操控物理内存。 垃圾回收器为你分配和释放托管堆上的虚拟内存。

    如果你编写的是本机代码,请使用 Win32 函数处理虚拟地址空间。 这些函数为你分配和释放本机堆上的虚拟内存。

  • 虚拟内存有三种状态:

    • 可用。 该内存块没有引用关系,可用于分配。

    • 保留。 内存块可供你使用,并且不能用于任何其他分配请求。 但是,在该内存块提交之前,你无法将数据存储到其中。

    • 提交。 内存块已指派给物理存储。

  • 可能会存在虚拟地址空间碎片。 就是说地址空间中存在一些被称为孔的可用块。 当请求虚拟内存分配时,虚拟内存管理器必须找到满足该分配请求的足够大的单个可用块。 即使有 2GB 可用空间,2GB 分配请求也会失败,除非所有这些可用空间都位于一个地址块中。

  • 如果用完保留的虚拟地址空间或提交的物理空间,则可能会用尽内存。

即使在物理内存压力(即物理内存的需求)较低的情况下也会使用页文件。 首次出现物理内存压力较高的情况时,操作系统必须在物理内存中腾出空间来存储数据,并将物理内存中的部分数据备份到页文件中。 该数据只会在需要时进行分页,所以在物理内存压力非常低的情况下也可能会进行分页。

 

垃圾回收的条件

当满足以下条件之一时将发生垃圾回收:

  • 系统具有低的物理内存。 这是通过 OS 的内存不足通知或主机指示的内存不足检测出来。

  • 由托管堆上已分配的对象使用的内存超出了可接受的阈值。 随着进程的运行,此阈值会不断地进行调整。

  • 调用 GC.Collect 方法。 几乎在所有情况下,你都不必调用此方法,因为垃圾回收器会持续运行。 此方法主要用于特殊情况和测试。

 

托管堆

在垃圾回收器由 CLR 初始化之后,它会分配一段内存用于存储和管理对象。 此内存称为托管堆(与操作系统中的本机堆相对)。

每个托管进程都有一个托管堆。 进程中的所有线程都在同一堆上为对象分配内存。

若要保留内存,垃圾回收器将调用 Win32 VirtualAlloc 函数,并且每次会为托管应用程序保留一个内存段。 垃圾回收器还会根据需要保留段,并通过调用 Win32 VirtualFree 函数将段释放回操作系统(在清除所有对象的段之后)。

 重要

垃圾回收器分配的段大小特定于实现,并且随时可能更改(包括定期更新)。 应用程序不应假设特定段的大小或依赖于此大小,也不应尝试配置段分配可用的内存量。

堆上分配的对象越少,垃圾回收器必须执行的工作就越少。 分配对象时,请勿使用超出你需求的舍入值,例如在仅需要 15 个字节的情况下分配了 32 个字节的数组。

当触发垃圾回收时,垃圾回收器将回收由死对象占用的内存。 回收进程会对活动对象进行压缩,以便将它们一起移动,并移除死空间,从而使堆更小一些。 这将确保一起分配的对象全都位于托管堆上,从而保留它们的局部性。

垃圾回收的侵入性(频率和持续时间)是由分配的数量和托管堆上保留的内存数量决定的。

此堆可视为两个堆的累计:大对象堆和小对象堆。

大对象堆包含大小为 85,000 个字节和更多字节的大型对象。 大对象堆上的对象通常是数组。 非常大的实例对象是很少见的。

代数

堆按代进行组织,因此它可以处理长生存期的对象和短生存期的对象。 垃圾回收主要在回收通常只占用一小部分堆的短生存期对象时发生。 堆上的对象有三代:

  • 第 0 代。 这是最年轻的代,其中包含短生存期对象。 短生存期对象的一个示例是临时变量。 垃圾回收最常发生在此代中。

    新分配的对象构成新一代的对象并且为隐式的第 0 代回收,除非它们是大对象,在这种情况下,它们将进入第 2 代回收中的大对象堆。

    大多数对象通过第 0 代中的垃圾回收进行回收,不会保留到下一代。

  • 第 1 代。 这一代包含短生存期对象并用作短生存期对象和长生存期对象之间的缓冲区。

  • 第 2 代。 这一代包含长生存期对象。 长生存期对象的一个示例是服务器应用程序中的一个包含在进程期间处于活动状态的静态数据的对象。

当条件得到满足时,垃圾回收将在特定代上发生。 回收某个代意味着回收此代中的对象及其所有更年轻的代。 第 2 代垃圾回收也称为完整垃圾回收,因为它回收所有代上的所有对象(即,托管堆中的所有对象)。

 

垃圾回收过程中发生的情况

垃圾回收分为以下几个阶段:

  • 标记阶段,找到并创建所有活动对象的列表。

  • 重定位阶段,用于更新对将要压缩的对象的引用。

  • 压缩阶段,用于回收由死对象占用的空间,并压缩幸存的对象。 压缩阶段将垃圾回收中幸存下来的对象移至段中时间较早的一端。

    因为第 2 代回收可以占用多个段,所以可以将已提升到第 2 代中的对象移动到时间较早的段中。 可以将第 1 代幸存者和第 2 代幸存者都移动到不同的段,因为它们已被提升到第 2 代。

    通常,由于复制大型对象会造成性能代偿,因此不会压缩大型对象堆。 但是,从 .NET Framework 4.5.1开始,你可以使用 GCSettings.LargeObjectHeapCompactionMode 属性按需压缩大型对象堆。

垃圾回收器使用以下信息来确定对象是否为活动对象:

  • 堆栈根。 由实时 (JIT) 编译器和堆栈查看器提供的堆栈变量。 请注意,JIT 优化可以延长或缩短报告给垃圾回收器的堆栈变量内的代码的区域。

  • 垃圾回收句柄。 指向托管对象且可由用户代码或公共语言运行时分配的句柄。

  • 静态数据。 应用程序域中可能引用其他对象的静态对象。 每个应用程序域都会跟踪其静态对象。

在垃圾回收启动之前,除了触发垃圾回收的线程以外的所有托管线程均会挂起。

下图演示了触发垃圾回收并导致其他线程挂起的线程。

线程触发垃圾回收时 触发垃圾回收的线程

 

【算法工作原理】

垃圾收集器的本质,就是跟踪所有被引用到的对象,整理不再被引用的对象,回收相应的内存。
这听起来类似于一种叫做“引用计数(Reference Counting)”的算法,然而这种算法需要遍历所有对象,并维护它们的引
用情况,所以效率较低些,并且在出现“环引用”时很容易造成内存泄露。所以.Net中采用了一种叫做“标记与清除
(Mark Sweep)”算法来完成上述任务。

天ççæ è®°åæ«æå¨è¡å¨ï¼æ¥æºç»´åºç¾ç§ï¼

 

 

“标记与清除”算法,顾名思义,这种算法有两个本领:

“标记”本领——垃圾的识别:从应用程序的root出发,利用相互引用关系,遍历其在Heap上动态分配的所有对
象,没有被引用的对象不被标记,即成为垃圾;存活的对象被标记,即维护成了一张“根-对象可达图”。其实,
CLR会把对象关系看做“树图”,无疑,了解数据结构的都知道,有了“树图”的概念,会加快遍历对象的速度。

检测并标记对象引用,是一件很有意思的事情,有很多方法可以做到,但是只有一种是效率最优的,.Net中是利
用栈来完成的,在不断的入栈与出栈中完成检测:先在树图中选择一个需要检测的对象,将该对象的所有引用压栈,
如此反复直到栈变空为止。栈变空意味着已经遍历了这个局部根(或者说是树图中的节点)能够到达的所有对象。树图
节点范围包括局部变量(实际上局部变量会很快被回收,因为它的作用域很明显、很好控制)、寄存器、静态变量,这
些元素都要重复这个操作。一旦完成,便逐个对象地检查内存,没有标记的对象变成了垃圾。

“清除”本领——回收内存:启用Compact算法,对内存中存活的对象进行移动,修改它们的指针,使之在内存
中连续,这样空闲的内存也就连续了,这就解决了内存碎片问题,当再次为新对象分配内存时,CLR不必在充满碎片
的内存中寻找适合新对象的内存空间,所以分配速度会大大提高。但是大对象(large object heap)除外,GC不会移
动一个内存中巨无霸,因为它知道现在的CPU不便宜。通常,大对象具有很长的生存期,当一个大对象在.NET托管
堆中产生时,它被分配在堆的一个特殊部分中,移动大对象所带来的开销超过了整理这部分堆所能提高的性能。

Compact算法除了会提高再次分配内存的速度,如果新分配的对象在堆中位置很紧凑的话,高速缓存的性能将会
得到提高,因为一起分配的对象经常被一起使用(程序的局部性原理),所以为程序提供一段连续空白的内存空间是很
重要的。

【代龄】

代龄就是对Heap中的对象按照存在时间长短进行分代,最短的分在第0代,最长的分在第2代,第2代中的对象往
往是比较大的。Generation的层级与FrameWork版本有关,可以通过调用GC.MaxGeneration得知。

通常,GC会优先收集那些最近分配的对象(第0代),这与操作系统经典内存换页算法“最近最少使用”算法如出一
辙。但是,这并不代表GC只收集最近分配的对象,通常,.Net GC将堆空间按对象的生存期长短分成3代:新分配的
对象在第0代(0代空间最大长度通常为256K),按地址顺序分配,它们通常是一些局部变量;第1代(1代空间最大长度
通常为2 MB)是经过0代垃圾收集后仍然驻留在内存中的对象,它们通常是一些如表单,按钮等对象;第2代是经历过
几次垃圾收集后仍然驻留在内存中的对象,它们通常是一些应用程序对象。

当内存吃紧时(例如0代对象充满),GC便被调入执行引擎——也就是CLR——开始对第0代的空间进行标记与压
缩工作、回收工作,这通常小于1毫秒。如果回收后内存依然吃紧,那么GC会继续回收第1代(回收操作通常小于10毫
秒)、第2代,当然GC有时并不是按照第0、1、2代的顺序收集垃圾的,这取决于运行时的情况,或是手动调用
GC.Collect(i)指定回收的代。当对第2代回收后任然无法获得足够的内存,那么系统就会抛出OutOfMemoryException
异常

当经过几次GC过后,0代中的某个对象仍然存在,那么它将被移动到第1代。同理,第1、2代也按同样的逻辑运行。GC Heap中代的数量与容量,都是可变的。

【使用方式】

Dispose可用于释放所有资源,包括托管的和非托管的,需要自己实现。

大多数的非托管资源都要求手动释放,实现IDispose接口的Dispose方法是最好的;而且C#中用到的using语句
快,也是在离开语句块时自动调用Dispose方法。

这里需要注意的是,如果基类实现了IDispose接口,那么它的派生类也必须实现自己的IDispose,并在其
Dispose方法中调用基类中base.Dispose方法。只有这样的才能保证当你使用派生类实例后,释放资源时,连同基类
中的非托管资源一起释放掉。

SuppressFinalize用于那些即有析构函数释放资源,又实现了Dispose()方法释放资源的情况下,将GC.SuppressFin
alize(this)添加至Dispose()方法中,以确保程序员调用Dispose()后,GC就不必再次垃圾回收了。

public class UnManagedResRelease : IDisposable
        {
            private bool _alreadyDisposed = false; //保证资源只用释放一次
 
            ~UnManagedResRelease()
            {
                Dispose(false);
            }
 
            /// <summary>
            /// 判断释放资源的类别(托管和非托管)
            /// </summary>
            /// <param name="isDisposing">是否是托管资源</param>
            protected virtual void Dispose(bool isDisposing)
            {
                if (_alreadyDisposed)
                {
                    return;
                }
 
                if (isDisposing)
                {
                    //释放托管资源...
                }
 
                //释放非托管资源...
 
                _alreadyDisposed = true;
            }
 
            public void Dispose()
            {
                Dispose(true);
 
                //阻止GC把该对象放入终结器队列
                GC.SuppressFinalize(this);
            }

其他语言怎么样?

Ruby的第一个版本使用了一种简单的标记和扫描技术,这种技术并不是最好的,但C扩展的作者编写本机扩展时很简单。这种简单性是Ruby在一开始就成长的关键,并且把它带到了现在的位置。在Ruby 2.1中引入了分代集合(当前版本为2.4)以提高程序的吞吐量 - 在语言成熟度方面很晚。之后,Ruby 2.2引入了增量标记,通过以较短的增量运行GC来解决长暂停时间问题。

停止世界与增量标记(源Heroku)

C ++中的程序员没有任何垃圾收集,他们需要手动进行内存管理。有一些技术可以使这更容易 - 智能指针现在是C ++ 11标准的一部分,并且是一个自动从堆中删除内存的工具。智能指针可以通过引用计数来实现,该引用计数确保在不再需要对象时立即删除对象 - 而不是在未使用的对象等待下一个收集周期时跟踪垃圾收集。

Java与C#非常相似,并使用跟踪分代垃圾收集 - 堆构建为年轻一代(eden,S0和S1),老一代和永久代。GC使用次要和主要循环来清洁世代并将对象从一个循环到另一个。年轻一代和老一代的集合都是“停止世界”事件,所以所有的线程都被暂停,直到它们完成。JVM上有不同类型的收集器 - 串行,并行,并发标记和扫描以及它在Java 7中的替换G1。

Python不使用跟踪垃圾收集,而是使用周期性循环检测引用计数来解决两个相互指向的死对象的情况。但与经典引用计数相反,它将它与世代方法相结合,并使用引用计数而不是标记和扫描算法。它不处理内存碎片,但试图通过在不同的内存池上分配对象来避免它。

Javascript中,没有统一的垃圾收集方法。它掌握在浏览器供应商的手中,并且他们采用不同的方式 - Internet 6和7使用DOM对象的引用计数垃圾收集器。从2012年开始,几乎所有现代浏览器都附带了跟踪标记和扫描算法以及一些额外的改进 - 代,增量收集,并发和并行。

 

资料:https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals

https://blog.csdn.net/aoshilang2249/article/details/38581101

https://chodounsky.net/2017/05/03/garbage-collection-in-c-sharp/

https://kb.cnblogs.com/page/106720/

https://en.wikipedia.org/wiki/Tracing_garbage_collection

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值