c# 托管堆和垃圾回收

前言

我们已经理解了clr可以寄宿,然后宿主可以控制了,也就是说外部问题我们已经解决了,那么还有一件重要的事情。

那就是clr 是如何控制托管地的垃圾回收的,为什么有clr就是为了自动垃圾回收嘛,不然为啥不用c++这种是吧。

正文

首先我们要知道内存的分配呢?

在c语音中,内存分配直接调用操作系统的api,然后操作系统分配是吧。

如果也是直接调用操作系统的api,那么这个clr都不知道,那么怎么控制呢?

这里提前说一下,我们我们谈的是托管堆,而不谈栈呢?

那是因为栈不需要托管,用完就弹出,但是也有一套机制,这个不需要我们去管理。

看一下如何分配托管堆的:

  1. 调用 IL 执行 newobj,为代表资源的类型分配内存(一般使用 C# new 操作符来完成)。

  2. 初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始状态。

  3. 访问类型的成员来使用资源(有必要可以重复)。

  4. 摧毁资源的状态以进行清理。

  5. 释放内存。垃圾回收器独自负责这一步。

按道理来说,这样相当于托管的内存都被管理了,是不是托管的内存就不存在内存泄漏呢?

其实不是,主要是这个内存泄漏的名词问题,对于内存泄漏,然后内存越来越多了, 然后漏出去了,或者说内存信息没有混淆,被暴露出去了。

实际含义:

内存泄漏是指程序在运行时不再需要使用的内存空间未被正确释放的现象。这可能导致系统中的可用内存减少,最终可能导致系统性能下降甚至崩溃。内存泄漏通常由程序错误、不完善的代码设计或内存管理问题导致。

举个例子:

public class LeakyClass
{
    private List<string> leaks = new List<string>();

    public LeakyClass()
    {
        // Simulate memory leak by continuously adding items to the list
        while (true)
        {
            this.leaks.Add("Leaking memory...");
        }
    }
}

public class Program
{
    public static void Main()
    {
        // Create an instance of LeakyClass to cause memory leak
        LeakyClass leaky = new LeakyClass();
    }
}

这里内存可能就会无限叠加。。。

总之我们就会记录下来的,那么接下来似乎不是那么难了。

然而并非我们去newobj的时候需要24k内存,然后我们就去申请24k内存。

事实上clr是这样的,人家可能去申请1m的内存,然后将24k给了newobj的这个操作。

这样避免了高频率的申请,同时利用局部性,比如说将连续申请的内存块,放在同一个地方。

因为大多数应用来说,如果是连续申请的,那么很有可能将会连续使用,那么cpu的缓存就可以大大利用了。

这些都是优点,那么因为内存不可能是无限的,那么呢,这里就有一个需要回收的概念。

那么怎么去回收垃圾呢,也就是用不上的东西。

至于对象生存期的管理,有的系统采用的是某种引用计数算法。

这里引用计数法呢?简单的说,就是如果一个对象被引用了,然后就+1呗,如果引用为0,那么就是可以释放。

这似乎是可行的,而且挺安全的呀,不被引用肯定不被使用,但是也有一个问题哇,那就是可能存在循环引用。

鉴于引用计数垃圾回收器算法存在的问题,CLR 改为使用一种引用跟踪算法。

引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量都称为根。

CLR 开始 GC 时,首先暂停进程中的所有线程。这样可以防止线程在 CLR 检查期间访问对象并更改其状态。然后,CLR 进入 GC 的 标记阶段。在这个阶段,CLR 遍历堆中的所有对象,将同步块索引字段中的一位设为 0。这表明所有对象都应删除。然后,CLR 检查所有活动根。查看它们引用了哪些对象。这正是 CLR 的 GC 称为引用跟踪 GC 的原因。如果一个根包含 null, CLR 忽略这个根并继续检查下个根。

任何根如果引用了堆上的对象,CLR 都会标记那个对象,也就是将该对象的同步块索引中对的位设为 1。一个对象被标记后, CLR 会检查那个对象中的根,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了因为循环引用而产生死循环。

应用程序的根直接引用对象 A, C, D 和 F。所有对象都已标记。标记对象 D 时,垃圾回收器发现这个对象含有一个引用对象 H 的字段,造成对象 H 也被标记。标记过程会持续,直至应用程序的所有根所有检查完毕。

检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个根在引用它。我们说这种对象的可达(reachable)的,因为应用程序代码可通过仍在引用它的变量抵达(或访问)它。未标记的对象是不可达(unreachable)的,因为应用程序中不存在使对象能被再次访问的根。

CLR 知道哪些对象可以幸存,哪些可以删除后,就进入 GC 的压缩(compact)①阶段。在这个阶段,CLR 对堆中已标记的对象进行“乾坤大挪移”,压缩所有幸存下来的对象,使它们占用连续的内存空间。这样做有许多好处。首先,所有幸存对象在内存中紧挨在一起,恢复了引用的“局部化”,减小了应用程序的工作集,从而提升了将来访问这些对象时的性能。其实,可用空间也全部是连续的,所以整个地址空间区段得到了解放,允许其他东西进驻。最后,压缩意味着托管堆解决了本机(原生)堆的控件碎片化问题。

压缩好内存后,托管堆的 NextObjPtr 指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。

压缩阶段完成后,CLR 恢复应用程序的所有线程。这些线程继续访问对象,就好像 GC 没有发过一样。

如果 CLR 在一次 GC 之后回收不了内存,而且进程中没有空间来分配新的 GC 区域,就说明该进程的内存已耗尽。此时,试图分配更多内存的 new 操作符会抛出 OutOfMemoryException。应用程序可捕捉该异常并从中恢复。但大多数应用程序都不会这么做;相反,异常会成为未处理异常。

我们再来整理一下回收步骤:

  1. CLR 开始 GC 时,首先暂停进程中的所有线程

  2. 然后,CLR 进入 GC 的 标记阶段。在这个阶段,CLR 遍历堆中的所有对象,将同步块索引字段中的一位设为 0。

  3. CLR 检查所有活动根。查看它们引用了哪些对象。这正是 CLR 的 GC 称为引用跟踪 GC 的原因。如果一个根包含 null, CLR 忽略这个根并继续检查下个根。

  4. 进行compact压缩。

然后记得呢?检查的是活动根。 什么是根呢? 所以引用对象都是根。

举例一个活动根的例子:

一个实际的例子是,如果一个对象被一个全局变量引用,那么这个全局变量就是一个活动根,因为它直接引用了这个对象,从而阻止了垃圾回收器回收这个对象。相反,如果一个对象只被一个局部变量引用,而这个局部变量超出了其作用域,那么这个局部变量就是一个非活动根,因为它不再直接引用这个对象,垃圾回收器可以回收这个对象。

还有什么是活动根吗?

除了全局变量,还有其他情况会产生活动根,比如:

  1. 局部变量:在方法内部定义的局部变量如果引用了对象,这些局部变量也会成为活动根。
  2. 静态变量:静态变量也可以成为活动根,因为它们在整个应用程序生命周期内存在。
  3. 寄存器:寄存器中存储的引用也可以成为活动根。
  4. CPU 寄存器中的引用:在一些特定情况下,CPU 寄存器中存储的引用也可以成为活动根。

这里很多人就会疑问,局部变量的活动根是啥:

void MyMethod()
{
    var obj = new SomeObject();
    // 这里的 obj 是一个局部变量,它引用了 SomeObject 对象,因此 obj 是一个活动根
    // 在方法执行期间,obj 阻止了 SomeObject 对象被垃圾回收器回收
}

然而也不是离开了MyMethod才是非活动根了。

垃圾回收的例子:

using System;
using System.Threading;

public static class Program {
    public static void Main() {
        // 创建每 2000 毫秒就调用一次 TimerCallback 方法的 Timer 对象
        Timer t = new Timer(TimerCallback, null, 0, 2000);

        // 等待用户按 Enter 键
        Console.ReadLine();
    }

    private static void TimerCallback(Object o) {
        // 当调用该方法时,显示日期和时间
        Console.WriteLine("In TimerCallback: " + DateTime.Now);

        // 出于演示目的,强制执行一次垃圾回收
        GC.Collect();
    }
}

回收开始时,垃圾回收器首先假定堆中的所有对象都是不可达的(垃圾);这自然也包括Timer对象。

然后,垃圾回收器检查应用程序的根,发现在初始化之后,Main方法再也没有用过变量t。

既然应用程序没有任何变量引用Timer对象,垃圾回收自然会回收分配给它的内存;这使计数器停止触发。并解释了为什么TimerCallback方法只被调用了一次。

现在,假定用调试器单步调试 Main,而且在将新 Timer 对象的地址赋给 t 之后,立即发生了一次垃圾回收。然后,用调试器的“监视”窗口查看t 引用的对象,会发生什么事情呢?因为对象已被回收,所以调试器无法显示该对象。大多数开发人员都没有料到这个结果,认为不合常理。所以,Microsoft 提出了一个解决方案。

使用 C# 编译器的 /debug 开关编译程序集时,编译器会应用 System.Diagnostics.DebuggableAttribute,并为结果程序集设置DebuggingModes 的 DisableOptimizations 标志。运行时编译方法时,JIT 编译器看到这个标志,会将所有根的生存期延长至方法结束。在我的例子中,JIT 编译器认为 Main 的 t 变量必须存活至方法结束。所以在垃圾回收时,GC 认为 t 仍然是一个根,t引用的Timer对象仍然“可达”。Timer对象会在回收中存活,TimerCallback 方法会被反复调用,直至Console.ReadLine 方法返回而且 Main 方法退出。

这很容易验证,只需在命令行中重新编译程序,但这一次指定 C# 编译器的 /debug 开关。运行可执行文件,会看到 TimerCallback 方法被反复代用。注意,C# 编译器的 /optimize+编译器开关会将 DisableOptimizations 禁止的优化重新恢复,所以实验时不要指定该开关。

那么这也就是在实验状态是吧,那么线上不可能去搞这种东西吧。

public static void Main() {
    // 创建每 2000 毫秒就调用一次 TimerCallback 方法的 Timer 对象
    Timer t = new Timer(TimerCallback, null, 0, 2000);

    // 等待用户按 Enter 键
    Console.ReadLine();

    // 在 ReadLine 之后引用 t(会被优化掉)
    t = null;
}

给大家实验一下:

release:

debug:

所以呢,有些时候,尤其是垃圾回收的时候,我们要多调试一下release环境,而不仅仅是debug环境。

下一节,描述一下优化

posted @   敖毛毛  阅读(12)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示