当前位置: 首页 > 科技观察

科普:常见垃圾收集算法与JSGC原理

时间:2023-03-17 22:36:58 科技观察

一、前言在程序运行过程中,几乎无时无刻不在为进程分配新的内存,但是计算机的内存空间总是有限的,内存空间总是有限的。有时满了,我们就需要进行“垃圾数据收集”,释放内存空间。不同的编程语言有不同的垃圾回收策略。通常,它们可以分为“手动收集”和“自动收集”两种类型。例如C/C++采用“手动回收”策略,内存空间的分配和销毁等操作由开发者自己通过代码控制。如果数据使用后没有主动释放无用内存,那么内存会随着程序运行时间的增加而逐渐变满。这种情况称为“内存泄漏”。JavaScript/Java/Python等采用自动回收策略,产生的垃圾数据由“垃圾收集器”主动释放,工程师无需通过代码手动释放内存。不过,虽然是自动回收,但如果工程师根本不关心内存管理,还是很容易产生内存泄漏。接下来,让我们看看自动垃圾收集的基本原理。2.自动垃圾收集算法随着时间的演进,垃圾收集算法也在不断改进。说它完美是不准确的。应该说是根据不同的需求进行了不同的取舍,从而产生了不同的算法。其实无论哪种垃圾回收算法都有一个共同的过程:在内存空间中标记活跃对象(正在使用的对象)和非活跃对象(可以回收的对象)。删除非活动对象以释放内存空间。整理内存空间,避免频繁回收后产生大量内存碎片(不连续的内存空间)。2.1mark-clear方法mark-clear方法是由JohnMcCarthy在1960年发表的一篇论文提出的,主要分为两个阶段:第一阶段是mark,从一个GC根集合开始,找到沿“指针”,将其标记为活动对象。第二阶段是清除,删除内存中未标记的对象,释放内存空间。从上面的描述来看,mark-sweep算法可以说是非常简单了,各种垃圾回收算法也是其思路的延续。虽然简单,但也有明显的缺点,就是多次回收操作后,会产生大量的内存碎片。由于该算法并没有对内存空间进行重组,因此内存空间会变得非常碎片化。如果需要申请更大的Large内存空间,即使总的剩余内存大小足够,也很容易因为没有足够的连续内存而分配失败。2.2复制算法为了解决上述问题,MarvinL.Minsky在1963年提出了著名的“复制算法”:把整个空间分成from和to两部分。先在from空间进行内存分配,当空间满时,标记活动对象,复制到to空间。复制完成后,交换from和to空格。由于活动对象是直接复制到另一半空间,在清除阶段没有开销,所以恢复操作可以在较短的时间内完成,并且每次复制对象时,对象都会聚集在一起,相当于同时进行排序操作。避免内存碎片。复制算法虽然具有吞吐量高、不分片等优点,但缺点也非常明显。首先,复制操作也需要时间成本。如果堆空间很大,活动对象很多,每次清理的时间会很长。其次,将空间一分为二的操作,直接将可用内存空间减少了一半。2.3引用计数该算法由GeorgeE.Collins于1960年提出,主要操作是:对指向对象的引用(指针数)进行实时计数。当引用数为0时,实时回收该对象。该算法可以瞬间回收垃圾数据,对程序的影响非常短,效率非常高。高性能实时回收,看似完美的解决方案其实是有问题的。当对象中存在循环引用时,对象不会被回收,因为引用次数不会降为0。以上三种算法的出现,基本确立了垃圾回收的基本内容。后续的垃圾回收算法基本都是基于以上三种算法的选择和组合。2.4Mark-compression算法该算法出现于1970年,它结合了mark-sweep方法和copy算法的优点。主要操作如下:从一个GC根集开始,标记所有活跃对象。将所有活动对象一起移动到内存的一端。直接清理边界外的内存,释放连续空间。可以发现,该算法既避免了mark-sweep方法带来的内存碎片问题,也避免了copy算法带来的可用内存空间减少的问题。当然,这种算法也不是没有缺点。因为清零和排序的操作非常麻烦,甚至需要对整个堆进行多次查找,所以堆越大越费时。2.5代际假设和代际收集“代际假设”:根据经验观察,在许多程序中,最近创建的对象也是最有可能很快变得不可达的对象。经过排查,发现应用中的大部分数据有以下两个特点:大多数对象的生命周期都很短,很快就不再需要了。那些一直存在的对象通常会存在很长时间。简单的说,对象的生存时间有点两极分化:“分代收集:”所以可以将对象分成几代,针对不同的代实现不同的垃圾收集算法,以达到更高的效率(比如JavaGC:https://plumbr.io/handbook/garbage-collection-in-java/generational-hypothesis)。3.JavaScript垃圾回收JavaScript原始数据类型存放在栈中,引用数据类型存放在堆中,所以讨论JavaScript垃圾回收就是讨论栈中数据的回收和堆中数据的回收.3.1栈上的垃圾回收ESP(ExtendedStackPointer):扩展栈指针寄存器用于存放函数栈顶指针。当JavaScript执行一个函数时,它的上下文会被压入栈中,ESP也会被上移。函数执行完毕后,可以销毁其执行上下文。这时只需要将ESP下移到下一个函数执行上下文即可。当下一个函数入栈时,会直接覆盖ESP上面的空间。所以JavaScript引擎通过向下移动ESP来完成栈的垃圾回收。3.2堆中的垃圾回收不同于栈中的垃圾回收。堆中的垃圾数据收集需要JavaScript垃圾收集器。JavaScript堆中的垃圾数据收集采用了分代收集的思想。引擎将堆空间划分为“young-space”和“old-space”,并对两个区域实施不同的垃圾回收方式。回收策略。“新生代”:新生代用于存放寿命短的对象。大多数新创建的小对象都会分配到这个区域,这个区域的垃圾回收会更频繁。在新一代中,引擎使用Scavenge算法(https://v8.dev/blog/trash-talk)进行垃圾回收,也就是上面提到的复制算法。它将新生代空间划分为两个区域,from-space和to-space。新创建的对象存储在from-space中,当空间快满时触发垃圾回收。先对from-space中的对象进行标记,然后将标记后的对象复制到to-space的一端,然后将两个区域的角色互换,完成回收操作。由于每次清理操作都需要拷贝对象,拷贝对象需要时间成本,所以新生代空间会设置的比较小(1~8M)。“老年代:”老年代用于存放长寿命对象和大对象:即一些大对象会直接分配到老年代空间。新生代经过两次垃圾回收后仍然存活的对象将被提升到老年代空间引擎,这个空间主要使用上面提到的“标记-压缩算法”。首先标记活动对象。标记完成后,将所有存活的对象移动到一段内存中,然后清理边界外的内存。由于JavaScript在单线程上运行,这意味着垃圾收集算法和脚本任务在同一个线程中运行。当垃圾回收逻辑执行完毕后,后续的脚本任务需要等待垃圾回收完成后才能继续执行。如果堆中的数据量非常大,那么一次完整的垃圾回收时间就会非常长,这会导致应用程序的性能和响应能力直线下降。为了不让垃圾回收影响应用性能,V8将标记过程拆分成多个子标记,让垃圾回收标记和应用逻辑交替执行,避免脚本任务长时间等待。