今天和大家一起解析下常见的GC算法设计。


什么是GC

GC是一种软件进行自动的内存回收的方式。

如果软件运行过程中,发现某些对象没有了引用(或者称之为不可达)的状态时,就会启动GC过程。将这部分内存进行释放。以避免程序员因为忘记显示释放内存导致程序出现out of memory。

GC的过程

GC的过程主要分为标记、移动和压缩

标记

软件先分析堆中的所有内存对象,判断是否“存活”。

如果一个对象没有保持着被其他存活对象引用,就需要被清理

移动

将标记为存活的对象移动到另一个内存空间(老生代)

压缩

由于内存清理之后会出现很多碎片(非连续的小段可用内存),因此往往需要对其进行移动,确保大块的内存可用空间。(有时候移动和压缩会放在一起操作)

image-20200322161334489

GC算法分析

为什么GC时要移动对象至另一空间

这里我们先思考一个问题,为什么需要把存活的对象移动到另一个内存空间。

首先,GC是一个非常耗性能的过程。

因为在GC过程中,你的程序中各个对象的引用指向的内存地址可能发生改变。

image-20200322161546326

如果此时你仍然在执行程序,就可能访问到错误的内存地址。

因此GC期间会挂起程序的执行,也就是我们俗称的Stop the world。

在这一个限制下,我们设计GC算法的时候,必须做到尽可能少的进行GC

如何才能尽可能少的进行GC呢?

我们每次GC时,尽可能保证扫描的对象最大比例的释放内存。这样可用内存越多,触发GC的次数就会越少。

OK,我们再引进一个社会工程学知识,就是越新创建的对象,被清理的概率越高。

如果一个对象经过了一次GC,仍然存活了下来,那么它很有可能再下一次GC中存活。

那么将其放在一个单独的内存空间(老生代)中,可以有效的减少这些长生命周期对象的GC次数。

而仅对这些新生成的对象(新生代)进行GC,可以使用更少的对象扫描,完成近似相当的内存释放。

只有当老生代的内存空间也满了,才会进行老生代的GC。

image-20200322161745963

老生代对象移动到哪里?

我们再思考一下,如果老生代也满了,那么GC时内存对象还能移动到哪里去呢?

在开一个“老老生代”?那“老老生代”也满了呢?

我们当然不能无限的开辟这么多的内存空间,放置GC存活的对象,这样内存的有效使用率太低了。

这里我们会在原始的老生代内存空间直接移动对象,将那些被回收的对象产生的内存碎片压缩即可。

image-20200322161946782

.NET的GC设计

.NET的GC设计很简单,就同我们刚刚分析的情况基本一致。

它将内存分为三个区域,第0代,第1代,第2代。

所有的新对象的内存会分配至第0代。

当第0代满了,触发GC将第0代清空,将存活对象提升至第1代。

当第1代满了,再存活对象提升至第2代。

当第2代满了,GC后的存活对象直接在第2代的空间进行碎片压缩。

V8的GC设计

在.NET的GC设计中可能会出现这种情况,在第0代GC过程中,最新生成的内存对象被提升到了第1代中。

但是这些对象很可能是一些短生命周期的对象,仅仅是“偶然”在GC之前生成罢了。

但是这些对象被提升至第1代之后,很可能在之后的多次GC中,都不能被清理。

这些“无用”的内存占用就会消耗我们软件的内存空间。

V8 采用了另一种设计思想。

V8仅有新生代和老生代两个空间,并且它将新生代分成了From和To两个半空间。

每次新对象直接生成在From半空间内。

当触发GC时,From半空间内存活的对象被移动到To半空间内。

然后令To成为新的From半空间,From成为新的To半空间,即“半空间翻转”

在下一次GC时,检查GC存活对象,如果已经经历了一次GC,那么提升至老生代,否则还是移动到To半空间。

image-20200322162423865

在V8的这种设计下,对象必须经历2次GC后才能提升到老生代。

避免了因为生成时机问题,导致内存中的“生命周期”延长。

JVM的GC设计

V8半空间的设计也会带来的一个问题就是,新生代的内存可用空间只有一半。

那么有没有什么方式可以增加一些空间呢?

JVM设计GC时将新生代分成了三块,eden,s0和s1(From幸存空间,To幸存空间)。

所有的对象创建时,内存分配至eden区域。

而当第一GC时存活对象提升至s0区域(From幸存空间)。

再下一次GC时,会将eden和s0一起进行GC,并且将存活对象移动至s1区域(To幸存空间)。

然后和V8一样s0和s1,发生“幸存空间翻转”

image-20200322162634767

仅当对象在幸存空间存活超过了8次,才提升至老生代。

这种设计基于如下现实:每次GC仅有少部分对象存活。

因此JVM就将需要进行翻转的半空间缩小了,这样就能有更大的空间用于新对象内存分配。

后记

这里我们分析了.NET,V8和JVM的GC设计,但是并不是说明那种软件的GC算法更好,所有的算法架构都是进行一种设计取舍。

我们需要知道大部分情况下,GC能够直接帮我们处理内存问题。

而在那些内存敏感的应用场景,期望依赖某一软件的GC设计原生机制来自动处理,也是不会奏效的。


参考文档:


本文会经常更新,请阅读原文: https://dotnet-campus.github.io//post/%E5%B8%B8%E8%A7%81%E8%BD%AF%E4%BB%B6%E7%9A%84GC%E7%AE%97%E6%B3%95%E8%A7%A3%E6%9E%90.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 dotnet 职业技术学院 (包含链接: https://dotnet-campus.github.io/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系