v8工作原理 垃圾回收机制

Posted by WWJ Blog on April 13, 2020

V8工作原理–垃圾回收机制

为什么需要进行垃圾回收?


有些数据使用之后,可能就永远都不再被需要了。这种数据就会成为垃圾数据,如果这些垃圾数据一直占据着内存,那么内存就会越用越多,最后就会导致内存溢出。所以需要对这些垃圾数据进行回收,以释放内存空间来存储必要的数据。

垃圾回收方式


一、手动分配和回收

第一种垃圾手机方式是手动分配和回收。

稍微了解过C/C++开发语言的童鞋应该都知道(不知道也没关系,看完就了解了),C/C++是通过调用malloc()函数动态分配内存空间,在变量使用完之后再调用free()函数来释放内容。

举例例子

// 在堆上分配了100个字节内存
char *p = (char *) malloc(100);
// 将hello字符串复制到指针p中
strcpy(p, "hello"); 
// 使用结束后,释放p指向的内存
free(p);
p = NULL; 

从这段代码可以看的出来,要使用堆中的一块空间,需要先使用malloc函数来分配内存,当不再使用这块数据的时候,需要手动调用free函数来释放这块数据所占的内存。如果这块数据没有再使用了,且又没有手动释放,那么这种情况就会造成内存泄漏。

二、自动内存管理 除了手动分配和回收之外,第二种回收方式自动回收。像Javascript、Python、Java用的都是语言自身的自动内存管理来回收垃圾数据。

自动内存管理所带来的问题就是会让开发者以为可以不用去关心内存,会对这方面的知识有所缺失。

那笔者就围绕Javascript是如何进行垃圾回收的这个话题展开探讨。 在Javascript中,数据是存储在堆和栈中的,那么我们就来看看堆和栈中的垃圾数据是如何被回收的。

调用栈中的数据是如何回收的


首先来一段代码,通过代码的执行流程来分析其中的回收机制。代码如下

function foo() {
	const a = 1;
	const b = { name: 'wwj' };
	
	function bar() {
		const c = 5;
		const d = { age: 25 };
	}
	
	bar();
}

foo();

当执行到const d = { age: 25 };这行代码的时候,此时系统中调用栈和调用堆的空间状态可以用下面这张图来表示: Alt text

从图中可以看出,原始数据被分配在了栈中,而引用数据则被分配到了堆中。在执行完foo()之后,foo执行上下文会被销毁,那么在被销毁前和被销毁后调用栈和调用堆都做了哪些处理呢?

当执行到foo函数的时候,Javascript引擎会创建foo执行上下文,并将foo执行上下文压入调用栈中;当执行到bar函数的时候,Javascript引擎会创建bar执行上下文,并将bar执行上下文压入调用栈中。以此同时,Javascript引擎还会维护一个栈顶指针ESP,用来表示当前处于执行上下文中。

当结束了bar函数之后,就要销毁bar函数上下文,那么是如何销毁的呢? 这个时候栈顶指针就帮上忙了,Javascript引擎会将ESP栈顶指针下移,那么这个时候栈顶的上下文就是foo执行上下文了。所以ESP栈顶指针下移操作就是销毁bar执行上下文的过程。 可能会有点懵,为什么ESP下移就可以销毁栈顶上线文。 看下面一张图 Alt text

当ESP下移之后,虽然bar执行上下文依然保存在了内存中,但已经是一个无效内存了,当foo函数再次调用另外一个函数时,这块内存将会被直接覆盖掉,用来存放另外一个执行上下文。那么自然而然bar执行上下文中的原始数据也将随着执行上下文一起被回收掉。

调用堆中的数据是如何回收


当上面的代码全部执行完毕之后,那么ESP指向的就是全局上下文了。此时foo执行上下文和bar执行上下文都成了无效内存。但是可以看到,保存在堆中的两个对象依然占据着内存。

那么Javascript引擎是如何回收存放于堆中的数据呢?

代际假说和分代收集


搞清楚如何回收之前先来了解一下两个术语:代际假说和分代收集,接下来要说的都是基于这两个术语之上的。

代际假说有下面两个特点:

  • 第一个是大部分对象存活时间较短。
  • 第二个是不死对象,会存活很久。

这两个特点同样适用于其他的动态语言,比如Python. 有了代际假说的基础,那我们就来看看Javascript引擎是如何回收堆中的垃圾数据的。 通常情况下,垃圾算法有很多种,而且并没有哪一种算法可以胜任所有情况,所以需要根据对象的声明周期来使用不同的算法,以适应所有情况。

在V8中,会将堆分为新生代和老生代两个区域,新生代存储存活时间较短的对象,而老生代存储不死对象。对于新生代区的数据容量,一般是1~8M,而老生代的容量就会大很多,所有V8会针对两个区的垃圾数据使用不同的垃圾回收器。

  • 副垃圾回收器:用来回收新生代区的垃圾数据。
  • 主垃圾回收器:用来回收老生代区的垃圾数据。

垃圾回收器的工作流程


现在知道了V8将堆分成了新生代和老生代两个区域,并使用不同的垃圾回收器来回收垃圾数据。其实不管是哪种回收器,它们都有一套共同的执行流程

第一步: 标记内存空间中的活动对象和非活动对象。所谓活动对象,就是现在正在使用的对象,而非活动对象,就是可以被回收的对象。

第二步: 回收被垃圾对象占据的内存空间。

第三步: 进行内存整理。一般来说,频繁回收对象之后,内存中就会出现大量的不连续空间,我们将这些不连续空间称做为内存碎片。如果出现了大量的内存碎片,当要分配一个较大内存空间的时候,就会出现内存不足的情况。

那么接下来,我们就根据这个执行流程,来分析副垃圾回收器(新生代区)和主垃圾回收器(老生代区)是如何回收垃圾数据的。

副垃圾回收器


上面说到,副垃圾回收器主要是负责新生代区的垃圾回收。新生代区存储的是生命周期比较短的对象,所以大多数比较小的对象会被分配到此区,虽然新生代区的容量只有1~8M,但这个区进行垃圾回收的次数还是很频繁的,因为在日常开发中,主要还是使用内存较小的对象。

副垃圾回收器使用的垃圾回收算法是Scavenge算法,该算法执行过程大概分为以下四个步骤:

第一、 将新生代区划分为连个区域:对象区域和空间区域,如下图所示: Alt text

第二、 一开始所有的对象都会存放在对象区域,当对象区域快写满时,就会执行一次垃圾数据清理工作。

清理之前,会将对象区域的对象标记为活动对象和非活动对象,当标记完成之后,副垃圾回收器就会将这些存活的活动对象复制到空间区域,与此同时同时还会将这些可能不连续的活动对象有序的排列起来,这一步其实就是内存整理。

完成复制后,之后再将对象区域和空闲区域进行角色翻转,即原来的对象区域变成空闲区域,而原来的空闲区域变成对象区域。这样就完成了垃圾回收操作,同时这种角色翻转操作还可以使新生代的这两块区域可以无限重复使用下去。

每一次进行数据清理,都需要进行一次活动对象的复制和空闲区域和对象区域的角色翻转,但复制需要时间成本,如果新生区空间设置的较大,那么执行效率就会很低。如果设置的太小,那么很容易就会将空间占满,造成内存溢出。

所以为了解决空间较小的问题,Javascript引擎采用了对象晋升策略,就是经过两次垃圾回收清理工作之后依然还存活的对象,就会被移入老生区中。

主垃圾回收器


主垃圾回收器主要是负责老生区垃圾数据的回收。老生区的数据有两个特点,第一个特点是对象占用空间大,第二个特点是存活时间较久。

由于老生区对象所占内存空间大,如果在老生区依然使用Scavenge算法的话,那么复制对象这一步操作就会花费非常多的时间,从而导致执行效率低下。而且还会浪费一半的空间。

所以主垃圾回收器所采用的垃圾回收算法是Mark-sweep(标记-清除)算法。该算法执行过程如下 首先是标记阶段,标记阶段是遍历调用栈,可以被访问到的对象被标记为活动对象,访问不到的对象被标记为非活动对象 Alt text

当bar函数执行完成之后,ESP下移之后,bar执行上下文成为无效执行上下文,当前指向foo执行上下文,这个时候遍历调用栈,是无法找到1003这块地址的引用的,所以调用堆中1003这块数据就是垃圾数据,再继续乡向下遍历到b遍历的时候,b遍历指向的是调用堆中的1009这块数据,所以1009这块数据就会被标记为活动数据。这就是标记的过程。

清除之后就会出现很多不连续的内存,碎片过多就会造成内存溢出。为了处理这种情况,主垃圾回收器采用了和副垃圾回收器相似的方法,即标记-整理算法。标记-整理算法执行过程大概如下:

将所有活动对象全部移向端的一侧,然后直接清理掉端边界以外的内存,如下图:

Alt text

全停顿


因为Javascript是运行在主线程上的,且只有一个线程,所以一旦执行垃圾回收算法,都需要将正在进行的Javascript代码停止下来,等到垃圾算法执行完毕,再继续执行Javascript代码,这种行为就叫做全停顿。

全停顿造成的问题就是会使得页面卡顿,因为在执行垃圾回收算法的时候不会执行任何Javascript代码。

在V8新生代的垃圾回收中,因为其空间比较小,且存活对象少,所以全停顿的影响比较小。但对于老生代而言,空间大,存活对象多,因为在垃圾回收的过程中会一直占据这主线程,如果每次回收所耗费的时间都是200ms,那么页面卡顿的问题就非常明显。

为了解决这个问题,V8采用了增量标记-回收的方法,即将标记过程分为一个个的子标记过程,每次只进行一小部分的数据回收处理,比如将之前需要200ms才能完成的事情分为20次来完成,那么每次只会阻塞主进程10ms,这样一来卡顿的情况用肉眼是完全看不出来。 Alt text 让标记-清除工作和JS交替执行,这样就不会让用户感知到页面的卡顿了。

虽然可以用增量标记-回收的方法来解决老生代垃圾回收造成的页面卡顿的问题,但无论将子标记过程分割成多小,依然还是会阻塞主进程的执行。

总结


其实不管是垃圾回收还是解决全停顿的问题上,都没有一个完美非方法适应所有情况,所以站在工程师的角度上,需要在满足需求的前提下,衡量各个指标带来的得失情况,尽可能的适应最核心的需求。 在生活上也是如此,“两害相权取其轻,两利相权取其重”,以其患得患失,还不如冷静下来好好分析,哪些取舍才是最合理的。