Javascript之V8内存和垃圾回收讲解

2023-03-25  本文已影响0人  上善若泪

1 Javascript内存

1.1 Javascript引擎

常见JavaScript引擎有V8JavaScriptCoreTraceMonkeyJScriptJavaScript在不同引擎中的内存模型实现不同,其中V8引擎开源且市占率远高于其它引擎,因此本文将围绕V8进行讲解。

在学习之前,要明白为什么要学习内存模型:

1.2 V8内存模型

image.png

从图中可以看到,JS虚拟内存空间分为两大部分,其中堆空间又被进一步划分为多个分区,这么划分堆空间与垃圾回收算法有关,后面会作说明解。目前我们只需要记住虚拟内存空间包含两大部分。

1.2.1 栈

栈特点:

1.2.2 堆

堆特点:

堆一般用来存储比较复杂的数据对象。同时也被划分为了各个区:

1.3 内存生命周期

内存一般有如下生命周期:

现从栈和堆内存的生命周期来做进一步讲解

1.3.1 栈内对象生命周期

栈内存的生命周期与函数上下文生命周期息息相关,函数上下文生命周期主要分为三个阶段:

每次进行函数调用的时候,都会在栈上分配一段内存空间(这里我们把它叫做栈帧),用于保存当前函数的上下文,当函数执行结束后,就会释放栈栈帧,举个例子:

function pFn(){
    function cFn(){};
    cFn();
};

pFn();

栈内存分配与回收情况如下:


image.png

函数上下文的生命周期讲完了,那么它与栈内对象的生命周期有什么关系呢?我们先来了解一下栈帧分配阶段的流程:

现在我们无需关注词法环境和变量环境有什么作用,只需要记住两点:
第一,函数上下文信息保存在栈空间,这里我们把它叫做栈帧;
第二,栈帧内存放在当前函数内声明的变量以及外部环境引用。

举个例子:


image.png

函数执行结束后,就会回收当前函数对应的栈帧,当前栈帧中的变量自然会被释放,这就是栈上数据的生命周期。

1.3.2 堆内对象生命周期

堆空间上的对象声明周期主要分为三个阶段:

举个例子:

var obj = {id:"object"}; //在堆上分配一段内存空间存放对象
alert(obj.id); 
obj = null; //告诉GC释放堆上的内存

这些概念理解起来仍然太抽象,结合例子来理解:

引用名obj就是mutator root,它存放在栈上,通过obj可以访问到对象中的数据;
声明var obj = {id:"object"}; 的时候,allocator会在堆上分配一段内存空间存放{id:"object"};
mutator就是我们写的代码程序;
在执行obj=null前,我们可以通过obj这个mutator root访问到{id:"object"},{id:"object"}是可达对象;
执行obj=null后,{id:"object"}变成了不可达对象,由collector回收。
垃圾回收操作什么时候执行?

我们写的代码并不是不间断执行的,每执行一段时间,就会周期性地停下来转而去执行垃圾回收操作。上例obj=null;执行结束后,{id:"object"}所在内存没有立即被释放,而是在下次执行垃圾回收操作的时候释放。

2 Javascript垃圾回收

2.1 引言

不同于C++Javascript虚拟机有垃圾回收机制,因此我们经常在网上看到这么一句话:Javascript不需要用户管理内存。然而,这句话是错误的!Javascript使用不当也会导致内存泄露,
内存泄露的概念:内存泄露是指用户申请的、不再使用的内存没有得到及时释放,导致程序运行期间,内存占用越来越大。

2.2 基本概念解析

2.2.1 垃圾回收

在说这个东西之前,先要解释什么是内存泄漏,因为内存泄漏了,所以引擎才会去回收这些没有用的变量,这一过程就叫垃圾回收

2.2.2 内存泄漏

程序的运行需要占用内存,当这些程序没有用到时,还不释放内存,就会引起内存泄漏。举个通俗的例子,就好比占着茅坑不拉屎,坑位(内存量)就这么多,你还不出去(释放内存),就会引起想拉的人不能拉(系统变卡,严重点的会引起进程崩溃)
也就是说不再用到的内存,没有及时释放,就被称为内存泄漏。而内存泄漏,会让系统占用极高的内存,让系统变卡甚至奔溃。所以会有垃圾回收机制来帮助我们回收用不到的内存

当我们遇到遇到内存泄漏时,我们需要做什么呢?
不需要做任何事,因为 JavaScript 中的垃圾回收是自动的

自动内存管理(垃圾回收)阵营:
JavaScript、Java、Go、Python、PHP、Ruby、C#
手动内存管理阵营:
C、C++、Rust

2.2.3 垃圾回收运行机制

JavaScript 的数据类型可分为基本类型和引用类型。基本类型存在栈内存,引用类型存在堆内存
JavaScript 中,引擎需要用栈来维护程序执行时的上下文状态(即执行上下文),如果栈空间大了的话,所有数据存放在栈空间中,会影响到上下文切换的效率,从而影响整个程序的执行效率,所以栈内存大的数据会放在堆空间中,引用它的地址来表示这个变量

2.3 GC模块

var obj = {id:"object"}; //在堆上分配一段内存空间存放对象
alert(obj.id); 
obj = null; //告诉GC释放堆上的内存

GC主要组成模块如下:

2.4 常见GC算法

回收算法 产生背景或算法思想
引用计数法 它是早期IE浏览器引擎JScript采用的垃圾回收算法。
标记清除法 引用计数法会因为循环引用产生内存泄露,为解决这个问题,提出了标记清除法。
标记压缩法 为了解决标记清除法引起的内存碎片问题,提出了标记压缩法。
复制算法 复制算法同样解决了标记清除法的内存碎片问题,它与标记压缩法各有优劣,两者适用于不同的场景。
并行回收 为减少垃圾回收时间,提出并行回收算法,在垃圾回收阶段启用多个线程执行垃圾回收操作。
增量标记 js每执行一段时间,就会停顿下来执行一次垃圾回收操作,为了防止js代码过长时间不被执行,把每次的垃圾回收操作拆分为多个时间片,每执行完一个时间片后就返回执行js代码。
延迟清理 如果当前内存足以支持代码的快速运行,可让程序先运行,等内存不足的时候再进行清理。
分区思想 不同分区采用不同的回收算法。
分代思想 是分区算法的一种变种,不同代使用不同回收算法,当某个代中的数据达到某一条件后,就把数据拷贝到另一代中。

2.4.1 引用计数(reference counting)

简单来说:引擎会有张引用表,保存了内存里面的资源的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放
核心思想:设置引用数,判断当前引用数是否为0,引用计数器;引用关系改变时就会修改引用数字,比如有一个内存空间有一个变量指向它引用计数就会加一,如果这个变量不再指向它了引用计数就会减一,当这个内存空间引用数字为0时立即回收。

引用计数算法的优点:

引用计数算法的缺点:

2.4.2 标记清除算法

标记清除Mark-Sweep)主要分为两个阶段:标记清除

看下图来理解标记清除算法:
我们都知道标记清除算法标记的都是可达对象,可达的标准就是全局作用域Global下查找到的对象就是可达对象。下面来仔细看图,图中global是全局作用域就是根,下面的 A B C D E都是可达对象,而右边的obj1 和 obj2 在局部作用域中并且两个互相引用,不是可达对象无法进行标记就会被清除掉,其实标记清除算法也就解决了上述中的引用计数算法 的无法回收循环引用对象的问题。将回收的空间放在空闲链表的地方。

image.png

标记清除算法优点:

标记清除算法缺点:

如下图所示通过标记清除算法标记了可达对象B,而对象A和对象C都是不可达的,就会被回收掉他们的内存空间,但是B的内存空间正好在A和C的中间位置 这样就会导致回收的空间地址不连续的,比如对象D空间大小正好是2或者1就会被分配到A或C,如果D的空间大小是1.5那么找A的空间就会太大,而找C的空间就会太小,这样会导致内存空间会有很多碎片。


image.png

2.4.3 标记整理算法

由于标记清除算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。
为了解决这种内存碎片的问题,Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的另一端进行移动,移动完成后再清理掉边界外的全部内存

标记整理算法:

标记整理优缺点:

2.4.4 分代算法

分代算法:

2.4.5 增量标记(Incremental Marking)

由于JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为 全停顿(stop-the-world)
在标记阶段同样会阻碍主线程的执行,一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。
因此,为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。

得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能

2.5 V8中的GC算法

2.5.1 V8简介

什么是V8:

2.5.2 V8垃圾回收策略

V8垃圾回收策略:

V8的回收算法复杂,它是上述各种基本GC算法的集大成者:

2.5.3 V8回收新生代

首先我们先看一下V8的内存分配,如下图所示左侧红色区域专门存储新生代存储区,右侧为老生代存储区

新生代对象回收实现:

拷贝过程中可能出现晋升,晋升就是将新生代对象移动至老生代,如果一轮GC还存活的新生代需要晋升,如果To空间的使用率超过25%将新生代对象移动至老生代

image.png

2.5.4 V8回收老生代

V8如何回收老生代呢:

细节对比:

关于增量标记算法如何优化垃圾回收?
如下图示


image.png

分会两个部分一个是程序的执行一个是垃圾回收,当执行垃圾回收操作会停止程序的执行,将一整段的垃圾回收操作组合的完成垃圾回收,垃圾回收与程序执行交替执行这样所带来的时间消耗会合理一些,程序执行一会标记一轮,最后标记操作完成操作后就进行垃圾回收操作,当垃圾回收操作完成之后程序继续执行操作。以前的垃圾回收会进行一整段操作,也会使程序停顿很长的一段时间。

回顾V8垃圾回收:

2.6 新生代与老生代的垃圾回收

image.png

在介绍两种垃圾回收机制前,要先知道两个知识点:代际假说分代收集

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

因为有代际假说的认知,所以我们在垃圾回收时,会根据对象不同的生存周期采用不同的算法,其中 V8堆内存分为新生代和老生代两个区域(其他几个区域用处不大)

新生代中存放生存时间短的对象,老生代存放生存时间久的对象
为此,新生代区通常只支持1~8M 的容量,而老生代区会支持更大的容量,而针对这两块区域,V8 分别使用两个不同的垃圾回收器

2.6.1 新生代内存回收

新生代采用的是 Scavenge 算法,所谓 Scavenge 算法,是把新生代空间对半分为两个区域,一半是对象区域(from),一半是空闲区域(to)。如下图所示:

image.png

新的对象会首先被分配到对象(from)空间,当对象区域快写满时,就需要执行一次垃圾清理操作。当进行垃圾收回时,先将 from 空间中存活的对象复制到空闲(to)空间进行保存,对未存活的空间进行回收。复制完成后,对象空间空闲空间进行角色调换,空闲空间变成新的对象空间,原来的对象空间则变成空闲空间。这样就完成了垃圾对象的回收操作,同时这种角色调换的操作能让新生代中的这两块区域无限重复使用下去

image.png

而当一个对象在两次变换中还存在时,就会从新生代区晋升到老生代区。这一过程被称为对象晋升策略

image.png

2.6.2 老生代内存回收

主垃圾回收器负责老生代区的垃圾回收。其中的对象包括新生代区晋升的对象和一些大的对象。因此老生代区中的对象有两个特点,对象占用空间大,对象存活时间长

它不会像新生代区那样使用 Scavenge 算法,因为复制大对象所花费的时间长,执行效率并不高。所以它采用标记 - 清除Mark - Sweep)进行垃圾回收
简单来说,先标记,后清除,但是内存空间里的对象还是不连续,所以引入整理。这就是老生代区的垃圾回收过程 标记 - 清除 - 整理。先标记哪些是要回收的变量,再进行回收(清除),然后将内存空间整理(到一边),这样空间就大了

image.png

因为老生代区的对象相对大,虽然采用标记-清除算法会比 Scavenge 更快,但架不住卡顿问题。为什么会卡顿?因为 JavaScript 是单线程。为此,为了避免垃圾回收时间过长影响其他程序的执行,V8 将标记过程分为一个个子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,这一算法被称为增量标记算法

image.png image.png

而这一行为,与 React Fiber 的设计思路类似,将大人物分割成小任务,因为小,所以执行快,让人察觉不到卡顿

2.6.3 新生代 VS 老生代

新生代垃圾回收是临时分配的内存,存活时间短;老生代垃圾回收是常驻内存,存活时间长
新生代垃圾回收由副垃圾回收器负责;老生代垃圾回收由主垃圾回收器负责
新生代采用 Scavenge 算法;老生代采用标记-清除算法

上一篇下一篇

猜你喜欢

热点阅读