垃圾回收算法(计数、标记、复制),golang垃圾回收

Golang GC 发展

Golang 从第一个版本以来,GC 一直是大家诟病最多的。但是每一个版本的发布基本都伴随着 GC 的改进。下面列出一些比较重要的改动。

  • v1.1 STW

  • v1.3 Mark STW, Sweep 并行

  • v1.5 三色标记法

  • v1.8 hybrid write barrier

GC 算法简介

这一小节介绍三种经典的 GC 算法:引用计数(reference counting)、标记-清扫(mark & sweep)、节点复制(Copying Garbage Collection),分代收集(Generational Garbage Collection)。

引用计数

引用计数的思想非常简单:每个单元维护一个域,保存其它单元指向它的引用数量(类似有向图的入度)。当引用数量为 0 时,将其回收。引用计数是渐进式的,能够将内存管理的开销分布到整个程序之中。C++ 的 share_ptr 使用的就是引用计算方法。

引用计数算法实现一般是把所有的单元放在一个单元池里,比如类似 free list。这样所有的单元就被串起来了,就可以进行引用计数了。新分配的单元计数值被设置为 1(注意不是 0,因为申请一般都说 ptr = new object 这种)。每次有一个指针被设为指向该单元时,该单元的计数值加 1;而每次删除某个指向它的指针时,它的计数值减 1。当其引用计数为 0 的时候,该单元会被进行回收。虽然这里说的比较简单,实现的时候还是有很多细节需要考虑,比如删除某个单元的时候,那么它指向的所有单元都需要对引用计数减 1。那么如果这个时候,发现其中某个指向的单元的引用计数又为 0,那么是递归的进行还是采用其他的策略呢?递归处理的话会导致系统颠簸。关于这些细节这里就不讨论了,可以参考文章后面的给的参考资料。

优点

  1. 渐进式。内存管理与用户程序的执行交织在一起,将 GC 的代价分散到整个程序。不像标记-清扫算法需要 STW (Stop The World,GC 的时候挂起用户程序)。

  2. 算法易于实现。

  3. 内存单元能够很快被回收。相比于其他垃圾回收算法,堆被耗尽或者达到某个阈值才会进行垃圾回收。

缺点

  1. 原始的引用计数不能处理循环引用。大概这是被诟病最多的缺点了。不过针对这个问题,也除了很多解决方案,比如强引用等。

  2. 维护引用计数降低运行效率。内存单元的更新删除等都需要维护相关的内存单元的引用计数,相比于一些追踪式的垃圾回收算法并不需要这些代价。

  3. 单元池 free list 实现的话不是 cache-friendly 的,这样会导致频繁的 cache miss,降低程序运行效率。

标记-清扫

标记-清扫算法是第一种自动内存管理,基于追踪的垃圾收集算法。算法思想在 70 年代就提出了,是一种非常古老的算法。内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度。这个时候系统会挂起用户程序,也就是 STW,转而执行垃圾回收程序。垃圾回收程序对所有的存活单元进行一次全局遍历确定哪些单元可以回收。算法分两个部分:标记(mark)和清扫(sweep)。标记阶段表明所有的存活单元,清扫阶段将垃圾单元回收。可视化可以参考下图。

标记-清扫算法的优点也就是基于追踪的垃圾回收算法具有的优点:避免了引用计数算法的缺点(不能处理循环引用,需要维护指针)。缺点也很明显,需要 STW。

三色标记算法

三色标记算法是对标记阶段的改进,原理如下:

  1. 起初所有对象都是白色。

  2. 从根出发扫描所有可达对象,标记为灰色,放入待处理队列。

  3. 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。

  4. 重复 3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。
    可视化如下。

三色标记的一个明显好处是能够让用户程序和 mark 并发的进行,具体可以参考论文:《On-the-fly garbage collection: an exercise in cooperation.》。Golang 的 GC 实现也是基于这篇论文,后面再具体说明。

节点复制

节点复制也是基于追踪的算法。其将整个堆等分为两个半区(semi-space),一个包含现有数据,另一个包含已被废弃的数据。节点复制式垃圾收集从切换(flip)两个半区的角色开始,然后收集器在老的半区,也就是 Fromspace 中遍历存活的数据结构,在第一次访问某个单元时把它复制到新半区,也就是 Tospace 中去。在 Fromspace 中所有存活单元都被访问过之后,收集器在 Tospace 中建立一个存活数据结构的副本,用户程序可以重新开始运行了。

优点

所有存活的数据结构都缩并地排列在 Tospace 的底部,这样就不会存在内存碎片的问题。
获取新内存可以简单地通过递增自由空间指针来实现。

缺点

内存得不到充分利用,总有一半的内存空间处于浪费状态。

分代收集

基于追踪的垃圾回收算法(标记-清扫、节点复制结合)一个主要问题是在生命周期较长的对象上浪费时间(长生命周期的对象是不需要频繁扫描的)。同时,内存分配存在这么一个事实 “most object die young”。基于这两点,分代垃圾回收算法将对象按生命周期长短存放到堆上的两个(或者更多)区域,这些区域就是分代(generation)。对于新生代的区域的垃圾回收频率要明显高于老年代区域。

分配对象的时候从新生代里面分配,如果后面发现对象的生命周期较长,则将其移到老年代,这个过程叫做 promote。随着不断 promote,最后新生代的大小在整个堆的占用比例不会特别大。收集的时候集中主要精力在新生代就会相对来说效率更高,STW 时间也会更短,java采用这种。

Golang的GC

三⾊标记

⾸先当垃圾回收器第⼀次启动的时候,它把所有的对象都看成⽩⾊的,如果这个对象引⽤了另外⼀个对象,那么被引⽤的对象称之为灰⾊的,把灰⾊的放⼊⼀个队列⾥去,那么当它第⼀次扫描完了以后这个⽆⾮就是变成两种状态,⽩⾊的和灰⾊的,⽩⾊的不属于我们要管的。

接下来扫描所有灰⾊的对象,灰⾊对象从队列⾥拿出来进⾏扫描,灰⾊对象被拿出来以后灰⾊对象本⾝被标记为⿊⾊的。如果它引⽤了其他对象那么这个对象重新变成灰⾊的,它会放⼊队列⾥⾯去,那么⿊⾊对象肯定是活着的不⽤管了,那么通过这样⼀级⼀级的扫描最终因为灰⾊对象被放⼊队列⾥⾯然后灰⾊对象拿出来进⾏扫描,灰⾊对象本⾝变成⿊⾊的,最终⾥就变成两种对象,⼀种是活下来⿊⾊的,第⼆种是所有扫描都没有⼈碰过的⽩⾊,那么⿊⾊的都是活着的,⽩⾊的都是统统干掉的。

那么最早的扫描是从哪来的呢,我们称之为从根 Root 对象来的,⽣命周期可以保证的对象是根对象,线程栈本⾝就是⼀个根,线程栈⾥⾯可能存了某个对象的指针,那线程栈就会引⽤那个对象,所以像全局变量、线程栈这些就是根对象。从它们开始扫描,如果全局变量没有引⽤任何东⻄,线程栈也没有引⽤任何东⻄,那这些根对象引⽤的对象肯定可以干掉。全局变量就不说了,线程栈就表⽰了当前正在引⽤的那对象,如果线程栈都没有引⽤过,那些对象肯定不要了,⽩⾊对象可以去掉了。

从根对象开始扫描从⼀开始⼤家都是⽩的,如果根对象有引⽤,那个对象变成灰⾊的,灰⾊对象依次扫描以后就剩下变成两种对象,⽩⾊对象和灰⾊对象,⽩⾊对象先放在这,灰⾊对象放⼊队列⾥⾯去,接下来我们从队列⾥把灰⾊对象取出来,看看灰⾊对象引⽤了什么对象,灰⾊对象本⾝变成⿊⾊的它肯定活下来的,因为它是被别⼈引⽤了才会放⼊队列⾥⾯,所以它从灰⾊变成⿊⾊肯定是活下来的。通过这样把灰⾊对象⼀级⼀级进⾏递归扫描以后最后这个队列被清空了,剩下来的世界只有两种对象,⼀种是⿊⾊的肯定被引⽤过,第⼆种是没有被引⽤过的⽩⾊对象,⿊⽩两⾊,⿊⾊活着⽩⾊干掉,这就是很典型的三⾊标记

golang垃圾回收使用的标记清理

STW(stop the world)

在扫描之前执⾏ STW(Stop The World)操作,就是Runtime把所有的线程全部冻结掉,所有的线程全部冻结掉意味着⽤户逻辑肯定都是暂停的,所有的⽤户对象都不会被修改了,这时候去扫描肯定是安全的,对象要么活着要么死着,所以会造成在 STW 操作时所有的线程全部暂停,⽤户逻辑全部停掉,中间暂停时间可能会很⻓,⽤户逻辑对于⽤户的反应就中⽌了。

如何减短这个过程呢, STW过程中有两部分逻辑可以分开处理。我们看⿊⽩对象,扫描完结束以后对象只有⿊⽩对象,⿊⾊对象是接下来程序恢复之后需要使⽤的对象,如果不碰⿊⾊对象只回收⽩⾊对象的话肯定不会给⽤户逻辑产⽣关联,因为⽩⾊对象肯定不会被⽤户线程引⽤的,所以回收操作实际上可以和⽤户逻辑并发的,因为可以保证回收的所有目标都不会被⽤户线程使⽤,所以第⼀步回收操作和⽤户逻辑可以并发,因为我们回收的是⽩⾊对象,扫描完以后⽩⾊对象不会被全局变量引⽤、线程栈引⽤。回收⽩⾊对象肯定不会对⽤户线程产⽣竞争,⾸先回收操作肯定可以并发的,既然可以和⽤户逻辑并发,这样回收操作不放在 STW时间段⾥⾯缩短 STW 时间。

写屏障

写屏障:该屏障之前的写操作和之后的写操作相比,先被系统其它组件感知。

刚把⼀个对象标记为⽩⾊的,⽤户逻辑执⾏了突然引⽤了它,或者说刚刚扫描了 100 个对象正准备回收结果⼜创建了1000个对象在⾥⾯,因为没法结束没办法扫描状态不稳定,像扫描操作就⽐较⿇烦。于是引⼊了写屏障的技术。

,先做⼀次很短暂的STW,为什么需要很短暂的呢,它⾸先要执⾏⼀些简单的状态处理,接下来对内存进⾏扫描,这个时候⽤户逻辑也可以执⾏。⽤户所有新建的对象认为就是⿊⾊的,这次不扫描了下次再说,新建对象不关⼼了,剩下来处理已经扫描过的对象是不是可能会出问题,已经扫描后的对象可能因为⽤户逻辑造成对象状态发⽣改变,所以对扫描过后的对象使⽤操作系统写屏障功能⽤来监控⽤户逻辑这段内存。任何时候这段内存发⽣引⽤改变的时候就会造成写屏障发⽣⼀个信号,垃圾回收器会捕获到这样的信号后就知道这个对象发⽣改变,然后重新扫描这个对象,看看它的引⽤或者被引⽤是否被改变,这样利⽤状态的重置从⽽实现当对象状态发⽣改变的时候依然可以判断它是活着的还是死的,这样扫描操作实际上可以做到⼀定程度上的并发,因为它没有办法完全屏蔽STW起码它当开始启动先拿到⼀个状态,但是它的确可以把扫描时间缩短,现在知道了扫描操作和回收操作都可以⽤户并发。

golang回收的本质

实际上把单次暂停时间分散掉了,本来程序执⾏可能是“⽤户逻辑、⼤段GC、⽤户逻辑”,那么分散以后实际上变成了“⽤户逻辑、⼩段 GC、⽤户逻辑、⼩段GC、⽤户逻辑”这样。其实这个很难说 GC 快了。因为被分散各个地⽅以后可能会频繁的保存⽤户状态,因为垃圾回收之前要保证⽤户状态是稳定的,原来只需要保存⼀次就可以了现在需要保存多次,很难说这种⽅式就⼀定让程序变的快了

辅助回收

Go 语⾔“⼀段⽤户逻辑,⼀段并发扫描Scan,⼀段并发回收Collect”,那可能会造成这种状态:描的速度跟不上⽤户分配的速度,会造成扫描永远结束不了,结束不了的情况下很⼤的⿇烦在于垃圾回收就会出问题,⽤户内存膨胀,必须在性能和内存膨胀之间做出平衡。

以 Go 语⾔如果发现扫描后回收的速度跟不上分配的速度它依然会把⽤户逻辑暂停,⽤户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把⽤户线程抢过来加⼊到垃圾回收⾥⾯加快垃圾回收的速度。因为并⾏有四个核,有三个核⽤户线程执⾏只有⼀个核在做垃圾回收,那⼀个核就有可能跑不过三个核,那把那三个核也抢过来做垃圾回收。这样⼀来原来的并发还是变成了STW,还是得把⽤户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象⽐回收快,所以这种东⻄叫做辅助回收

控制器

很多语⾔⽐如 Java 对垃圾回收器做了很多控制开关,是因为那些算法未必适合当前的这种算法,有些语⾔⽐较适合并发扫描有些语⾔不适合,甚⾄是像做⼤数据计算完全把GC关掉就是⼀直把内存⽤完了导致系统崩溃了为⽌,只不过崩溃之前保证把状态保存然后重新执⾏这个进程然后进⾏密集计算,把垃圾回收那段时间抢出来⽤来做密集计算。所以说垃圾回收器的算法不是万能的,它也没有办法做到真正意义上的智能。

Java、 Go 语⾔都有垃圾回收预值,甚⾄来决定预值什么时候启动垃圾回收,像Go语⾔有百分⽐来控制到底有多⼤合适,这个 GC堆到底分配多⼤合理,这都需要在了解垃圾回收器原理情况下做动态调节。因为我们的服务程序很复杂,在服务器上可能⻓时间运⾏,垃圾回收器算法对性能影响很关键的.

Go 语⾔垃圾回收器⼀直被⼤家说实现的是原始版,因为Go早期版本对垃圾回收器预值怎么触发的特别蠢,第⼀次回收的时候回收完了剩下来对象是2M,那么下次垃圾回收的内存消耗变成4M,假设第⼀次回收之前内存是 100G,下次回收可能就变成 200G,可问题是下次回收⽤不了200G,可能第⼀次回收⽤的 100G 是引⽤了⼤字典,在下次回收之前这字典清空了接下来⼀直⽤⼏⼗M,垃圾回收器很难启动,所以 Go 语⾔在后台⽤⼀个循环线程扫描,每2分钟发现不执⾏就强制回收⼀次,这样的做法显然⽐较蠢。后来在 1.5 版本引⼊⼀个控制器,控制器有点像Java语⾔动态概念,当这次回收释放⽐例、或者是这些对象相关⼀些数据,控制器和辅助回收的作⽤GitChat来对预值动态调整决定下次回收

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值