漫谈垃圾回收算法

GC简介:垃圾回收(Garbage Collection)也被称为自动内存管理技术,在现代编程语言中使用得相当广泛,常见的 Java、Go、C# 均在语言的 runtime 中集成了相应的实现。

对象创建,内存分配

观察对象分配时,主要有三个参与者,Application,allocator,grabage collector ,接下来看一下这三者如何工作的

 

 

Application指的是我们的应用,我们将堆上的对象看作一个图,应用代码分配变量时就是在不停地修改这张堆对象图里的指向关系。

下图可以帮我们理解分配对象时堆上的操作

 

 

allocator 就很好理解了,指的是内存分配器,应用需要内存的时候都要向 allocator 申请。

allocator 要维护好内存分配的数据结构,在多线程场景下工作的内存分配器还需要考虑高并发场景下锁的影响,并针对性地进行设计以降低锁冲突。

collector 是垃圾回收器。

死掉的堆对象、不用的堆内存都要由 collector 回收,最终归还给操作系统。当 GC 扫描流程开始执行时,collector 需要扫描内存中存活的堆对象,扫描完成后,未被扫描到的对象就是无法访问的堆上垃圾,需要将其占用内存回收掉。

内存分配器

    内存分配器在应用和操作系统之间工作,当应用需要分配内容是,应用将一个结构或者一个分片申请内存分配,内存分配器判断当前已经分配的内存是否足够,足够时将当前内存start和offset返回给应用程序,不足够时向应用程序申请一块足够大小的内存。内存分配器的存在:1.隔离了应用程序多次向操作系统申请内存。2.内存碎片化管理。

栈内存由操作系统分配,堆内存由人为手动分配和释放,下表是总结的栈和堆的区别

 
速度
空间管理 高效,不会产生碎片 会产生内存碎片
访问权限 只能局部变量 可以访问全局变量
空间大小限制 操作系统限制 没有特定的限制
内存分配 连续 随机分配
分配和释放 编译器指令自动管理 手动管理
开销
主要问题 空间小 内存碎片
灵活性 固定大小 可以resize

堆内存手动管理,创建一个堆内存时,也需要将堆内对象的指针在栈中存储。比如string对象内部由char[]组成数据部分,内部由char*进行操作,新建一个string对象在内存堆中手动分配一块char[]大小的数据,在栈中创建了一个指向这块内存的指针。

栈内存系统管理,系统最底层对象都会有系统直接分配内存,比如:int,bool,char,float等,还有方法指针,当一个方法需要执行时,在栈中弹出该方法指针,执行完毕之后删除该指针。

垃圾回收算法

垃圾回收,也就是在内存中不用的内存回收,垃圾是字义。垃圾回收算法演变至今主要有三种

  • 引用计数法
  • 标记清楚法
  • 分代回收

引用计数

当对象E指向A时,A的对象计数器的ref_count+1;

       优点:

  • 回收时只需要判断ref_count为0时即可回收,回收简单
  • 最大暂停时间短,每次通过指向mutator生成垃圾时,这部分垃圾都会被回收,大幅削减了mutator的最大暂停时间

       缺点:

  • 计数器的值增减处理繁重每次指针更新时,计数器的值会被更新
  • 计数器要占很多位,假如32位的机器,就有可能2的32次方个对象同时引用一个对象,所以必须确保各对象的计数器有32位大小,也就是对于所有的对象,必须留有32位的空间,使内存使用大大降低
  • 循环引用无法回收,就是两个对象相互引用的情况,在相互引用时无法释放ref_count,导致两个对象都无法释放。

标记清除法

标记清除法,主要分为两部分,1.标记,2.清除

阶段1:Mark-Sweep 标记清除阶段
先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的。

阶段2:Compact 压缩阶段
对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。

Heap内存经过回收、压缩之后,可以继续采用前面的heap内存分配方法,即仅用一个指针记录heap分配的起始地址就可以。主要处理步骤:将线程挂起=>确定roots=>创建reachable objectsgraph=>对象回收=>heap压缩=>指针修复

  • roots:heap中对象的引用关系错综复杂(交叉引用、循环引用),形成复杂的graph,roots是CLR在heap之外可以找到的各种入口点。GC搜索roots的地方包括全局对象、静态变量、局部对象、函数调用参数、当前CPU寄存器中的对象指针(还有finalizationqueue)等。主要可以归为2种类型:已经初始化了的静态变量、线程仍在使用的对象(stack+CPU register)
  • Reachable objects:指根据对象引用关系,从roots出发可以到达的对象。例如当前执行函数的局部变量对象A是一个rootobject,他的成员变量引用了对象B,则B是一个reachable object。从roots出发可以创建reachable objectsgraph,剩余对象即为unreachable,可以被回收
  • 指针修复:指针修复是因为compact过程移动了heap对象,对象地址发生变化,需要修复所有引用指针,包括stack、CPUregister中的指针以及heap中其他对象的引用指针
    Debug和release执行模式之间稍有区别,release模式下后续代码没有引用的对象是unreachable的,而debug模式下需要等到当前函数执行完毕,这些对象才会成为unreachable,目的是为了调试时跟踪局部对象的内容
    传给了COM+的托管对象也会成为root,并且具有一个引用计数器以兼容COM+的内存管理机制,引用计数器为0时这些对象才可能成为被回收对象
  • Pinnedobjects:指分配之后不能移动位置的对象,例如传递给非托管代码的对象,GC在指针修复时无法修改非托管代码中的引用指针,因此将这些对象移动将发生异常。
    pinnedobjects会导致heap出现碎片,但大部分情况来说传给非托管代码的对象应当在GC时能够被回收掉

STW 问题

标记清除算法开始时会将所有工作线程挂起,这就是“stop the world”将整个代码运行停止。假想一下,你的程序运行时必须要停止那么1~2s,要做垃圾回收,这是个很差的体验。

为什么一定需要挂起所有线程?

如果在获取到root Object之后,一边标记关联对象,一边运行程序,标记过的对象需要回收,未标记的对象重新恢复了引用,回收时直接就把内存数据搞乱了。导致程序奔溃。压缩和指针修复阶段,被回收的堆内存中存储了新创建的对象,压缩之后对象指针就指不到该对象内存块。

 

分代回收算法(C#垃圾回收算法)

分代算法的假设前提条件:
1、大量新创建的对象生命周期都比较短,而较老的对象生命周期会更长
2、对部分内存进行回收比基于全部内存的回收操作要快
3、新创建的对象之间关联程度通常较强。heap分配的对象是连续的,关联度较强有利于提高CPU cache的命中率

.NET将heap分成3个代龄区域: Gen 0、Gen 1、Gen 2

垃圾回收时,G0到达阈值后触发回收;剩下的对象进入到G1,G1到达阈值后触发G0、G1回收;剩下的对象进入到G2,G2到达阈值之后触发G0,G1,G2回收,也就是所谓的full GC。

G0与G1加起来总是保持在16M左右;Gen2的大小由应用程序确定,可能达到几G。回收时G0与G1在几毫秒至几十毫秒,G2回收一般需要几秒或者更久。

大致上来讲.NET应用运行期间2代、1代和0代GC的频率应当大致为1:10:100。如果该对象小于 85,000 字节,则将它置于 SOH 的段上,否则,将它置于 LOH 段。LOH是大对象段,超过85000字节之后在内存复制代价相对比较大,所以超过85000字节之后直接分配在G2,在触发FullGC时,才会判断是否要压缩优化大对象,不用从G0复制到G2。

 

分代回收从假设中成立,实际操作也是将不同大小的对象和使用时间不同的对象分配到不同的代中,回收时尽量缩短线程挂起的时间,在回收时实际使用的还是标记清除算法。

何时收集大型对象?

通常情况下,出现以下三种情形中的任一情况,都会执行 GC:

  • 分配超出第 0 代或大型对象阈值。

    阈值是某代的属性。 垃圾回收器在其中分配对象时,会为代设置阈值。 超出阈值后,会在该代上触发 GC。 因此,分配小型或大型对象时,需要分别使用第 0 代和 LOH 的阈值。 当垃圾回收器分配到第 1 代和第 2 代中时,将使用它们的阈值。 运行此程序时,会动态调整这些阈值。

    这是典型情况,大部分 GC 执行都因为托管堆上的分配。

  • 调用 GC.Collect 方法。

    如果调用无参数 GC.Collect() 方法,或另一个重载作为参数传递到 GC.MaxGeneration,将会一起收集 LOH 和剩余的托管堆。

  • 系统处于内存不足的状况。

    垃圾回收器收到来自操作系统 的高内存通知时,会发生以上情况。 如果垃圾回收器认为执行第 2 代 GC 会有效率,它将触发第 2 代。

 

 

C# 实战

public abstract class Animal
{
    public Animal(string name, int foot)
    {
        Name = name;
        Foot = foot;
    }
    /// <summary>
    /// 名字
    /// </summary>
    public  string Name { get; set; }
 
    /// <summary>
    ///脚
    /// </summary>
    public  int Foot { get; set; }
 
    /// <summary>
    /// 叫声
    /// </summary>
    public abstract void Call();
}
 
public class Dog:Animal
{
    public override void Call()
    {
        Console.WriteLine("汪汪汪");
    }
 
    public Dog(string name) : base(name, 4)
    {
 
    }
}
 
public static void Demo_With_ManualGC()
    {
        try
        {
            Animal animal = new Dog("小黑");
            var dog = animal;
            animal = null;
            System.GC.Collect();
            dog.Call();
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
            throw;
        }
    }

运行上边的代码,发现animal并没有马上回收,是因为animal还有一个应用dog。

 

手动GC,不会回收还有引用的对象

将dog置为null,手动触发GC回收没有回收,这是为什么?

使用using+dispose手动设置回收时操作

使用using创建对象,调用了对象的Dispose方法,这个也就是IDisposable接口中对象释放使用的方法,在dog对象中没有释放对象,在using之外还可以使用对象。

 

使用析构函数,在对象被回收时进行操作

当对象释放时,会经过析构函数,析构函数和构造函数正好相反,一个是对象创建时,一个是对象销毁时。(这里模拟回收所以重复创建了多个对象)

手动指定对象是否支持回收

使用System.GC.SuppressFinalize(this);告诉标准语言库,不用回收这个对象。

posted @ 2023-02-20 16:53  ThirteenAnimation  阅读(95)  评论(0编辑  收藏  举报