本文章主要是从V8引擎的内存分配以及垃圾回收机制,讲解一下在JavaScript代码执行的整个生命周期中V8引擎是如何进行垃圾回收的从而来减少内存占比的,了解了GC算法机制虽然并不太影响我们写代码的流程,因为在一般情况下我们很少会遇到浏览器端出现内存溢出而导致程序崩溃的情况,但是我们对这方面有一定的学习和了解,在编写代码时可以提高我们的代码质量、减少内存占用、避免内存泄漏,也许能够帮助你写出更加健壮和对V8引擎更加友好的代码,从而提升系统性能。
// V8引擎介绍 //
V8引擎是目前市面上最主流的JavaScript执行引擎,它是一个可以独立的模块,我们日常所使用的Chrome浏览器以及目前的nodejs平台都在采用V8引擎执行我们的JavaScript代码,在Google V8 引擎使用 C++ 代码编写。对于这两个平台,javascript之所以能在它们的上面高效的运转,正是因为V8引擎的存在,那么这个速度之所以快,除了它的背后有一套优秀的内存管理机制之外呢,其实V8还有一个特点,就是采用即时编译,之前很多的javascript引擎都需要将原代码转成字节码,然后才能去执行,而对于V8就可以直接将源码翻译成当前可以执行的机器码,即时编译,所以速度是非常快的。
V8引擎垃圾回收器采用了众多技术,使得其运行效率大大提高。通过暴露宿主对象 ( 变量,函数等 ) 到 JavaScript,JavaScript可以访问宿主环境中的对象,并在脚本中完成对宿主对象的操作。
V8引擎工作流程
V8引擎的工作流程,如图1.1所示。
图 1.1 V8引擎工作流程
V8由许多子模块构成,其中Parser、Ignition、TurboFan、Orinoco这4个模块是必要重要的,V8中将js源码通过Scanner把字符串分割出来,生成一系列的token词法单元,接下来使用Parser进行语法分析,然后输出AST,在V8中有两个解析器用于解决JS代码,分别就是Parser和PreParser,Parser会解析所有立即执行的代码;PreParser只解析未被立即执行的代码,它不会生成AST,只确定作用域。Ignition会将输出的AST转换成字节码,这该阶段开始执行JavaScript,最后TurboFan负责将字节码和一些分析数据作为输入并生成优化的机器码。
Orinoco是V8中的垃圾回收模块,负责将程序中不再需要的内存空间回收,这也是这篇文章重点聊到的模块。
// 为什么需要垃圾回收 //
在C语言和C++语言中,我们如果想要开辟一块堆内存的话,需要先计算需要内存的大小,然后自己通过malloc函数去手动分配,在用完之后,还要时刻记得用free函数去清理释放,否则这块内存就会被永久占用,造成内存泄露。
但是在JavaScript程序执行的过程中,却没有这个过程,因为已经替我们封装好了,V8引擎会根据你当前定义对象的大小去自动申请分配内存,不需要我们去手动管理内存了,程序执行时当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量、常量,该函数执行完后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而造成内存泄漏导致程序的性能直线下降甚至崩溃,所以自然要有垃圾回收。
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,垃圾回收除了释放没用的对象,也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便将整理出的内存分配给新的对象。
垃圾回收的好处是不需要我们去管理内存,把更多的精力放在实现复杂应用上,但坏处也来自于此,不用管理了,就有可能在写代码的时候不注意,造成循环引用等情况,导致内存泄露。随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。
// V8引擎内存空间 //
V8引擎的内存是设有上限的,对于V8来说它的内存空间去设置了一个数值,在64位的操作系统中上限是不超过1.5G,对于32位的操作系统不超过800M,为什么V8要采用这样的做法呢?
原因两方面:
(1)V8本身就是为了浏览器而去制造的,所以现有的内存大小对于网页应用来说是足够使用。
(2)V8内部所去实现的垃圾回收机制也决定了它采用这样的设置是非常合理的,因为官方去做过这样一个测试,当我们的垃圾内存去达到一个1.5G时,如果V8去采用增量标记的算法垃圾回收只需要消耗50毫秒,而采用非增量标记的形式去回收需要1s,从用户体验角度来说1s是比较长的时间,所以这儿以1.5G为限制。
V8中将内存分为了两个部分,左侧区域就是新生代存储区,右侧是老生代存储区,如图1.2所示。
图1.2 内存分配
V8内存空间一分为二, 小空间用于存储新生代对象,在64位操作系统中大小32M,在32位操作系统中大小16M ,新生代指的是存活时间较短的对象。
注意:坚定存活时间较短:当前代码中有一个局部作用域,其中的变量在执行完后就肯定要去回收,而在其实的地方比如全局的地方也可能会有一个变量,而全局的变量肯定要等到程序退出之后才会被回收。
// V8引擎中常用的GC算法介绍 //
GC定义与作用
GC就是垃圾回收机制的简写,当GC工作的时候可以帮我们找到内存中的垃圾对象,然后对于这个空间进行一个释放,并且可以回收空间方便后续代码继续去使用。
算法就是工作时查找和回收所遵循的规则。
GC算法是什么?GC是一种机制,垃圾回收器可以完成具体的工作,而工作内容的本质就是查找垃圾释放空间并且回收空间,那么在这个过程当中就会有几个行为:如果去查找空间?找到如何去释放,回收空间后如何进行分配,GC的算法就可以理解为是上述的垃圾回收器在工作过程中所遵循的一些规则。
V8引擎中GC算法主要是分代回收、空间复制、标记清除、标记整理、标记增量进行垃圾回收。
GC中的垃圾是什么
程序中不再需要使用的对象,如下代码:
function fn() {
name = 'wthreesix'
return `${name} is a name`
}
fn()
程序中不能再访问到的对象,如下代码:
function fn() {
const name = 'wthreesix'
return `${name} is a name`
}
fn()
常见的GC算法
(1)引用计数
通过当前对象上的引用数字是否为0,从而决定它是不是一个垃圾对象。
A. 引用计数算法实现原理
在内部设置引用计算器,来维护当前对象的引用数,判断当前引用数是否为0来决定是不是一个垃圾对象。
当某一个对象引用关系发生改变时,引用计数器就会主动的修改当前对象所对应的引用数字 。
当这个引用数为0时,GC就将其所在的对象空间回收释放再使用。引用关系发生变化的现象,如下代码:
// infoList指向了info和info1空间,即使脚本执行完一遍,因为info和info1空间还都会被别人引用的,此时引用计数器就不是0,就不会被当作垃圾去回收
const info = {name: 'wthreesix'}
const info1 = {name: 'wthreesix1'}
const infoList = [info.name, info1.name]
// 挂载在wimdows,外部可以访问不会被回收
function fn() {
num1 = 1
num2 = 2
}
fn()
// num1和num2只能在作用域起作用,当函数调用执行完,这时num1和num2引用计数就会回到0,此时GC就会当作垃圾进行对象回收
function fn1() {
const num1 = 1
const num2 = 2
}
fn1()
B.引用计数算法优点:
发现垃圾时立即回收。 可以最大限度的减少程序暂停。
应用程序在执行的过程中必然会对内存进行消耗,而我们当前的执行平台内存肯定是有上限的,内存肯定有占满的时候,不过由于引用计数算法是时刻监控的那些引用数字为0的对象,就可以认为当发现这个内存即将占满的时候,引用计数就会立即找到数字为0的对象空间进行释放,这样就保证了当前内存是不会有占满的时候,所以就会减少程序暂停。
C. 引用计数算法的缺点:
无法回收循环引用的对象,资源消耗较大,因为要维护一个数字的变化,要时刻监控的当前对象引用数字是否需要修改。对象之间的循环引用,如下代码所示:
function fn() {
const obj1 = {}
const obj2 = {}
obj1.name = obj2
obj2.name = obj1
return
}
fn()
// 造成内存空间的浪费
(2) 标记清除(在V8中大量的使用)
可以在GC工作的时候给活动对象添加一个标记来判断它是否是一个垃圾。
A. 标记清除算法的实现原理
(1)将整个垃圾回收操作分成两个阶段:标记、清除。
(2)第一个阶段遍历所有的对象找活动对象(可达对象)进行标记操作 。
(3)第二个阶段遍历所有对象,把没有标记的对象进行清除,也会将第一个阶段设置的标记抹掉,便于GC下次还能正常工作 。
(4)回收相应的空间,放在空闲列表,方便后续使用,如图1.3所示。
图1.3 标记清除
B. 标记清除算法优点
解决了对象循环引用的回收操作(在做标记时不可达的对象不可能被标记,在第二阶段会找到没有标记的对象进行空间释放)。
C. 标记清除算法缺点
容易产生碎片化空间,浪费空间,不会立即回收垃圾对象,如图1.4所示。
图1.4 标记清除碎片化
AC释放后会放人空闲列表,由于中间有B区域,会出现地址不连续,以后想去申请空间,要申请的空间大小正好小于A空间,但大于C空间这时就不太适合去使用,产生空间碎片化。
(3)标记整理
和标记清除很类似,标记整理在回收的时候做一些处理。
A. 标记整理算法的实现原理
(1)标记整理可以看作是标记清除的增强操作。
(2)标记阶段的操作和标记清除一致。
(3)清除阶段会在执行之前进行一个整理,移动对象的位置,让它们能够在地址上产生连续。
图1.5 标记整理算法
B. 标记整理算法的优点
减少碎片化空间,会进行地址整理。
C. 标记整理算法的缺点
不会立即回收垃圾对象,移动对象位置,回收效率慢。
V8垃圾回收策略
V8引擎中采用分代回收的思想,将内存分为新生代、老生代。垃圾回收中会针对不同对象采用不同算法,如图1.6所示。
图1.6 V8回收策略
(1)新生代对象如何回收
①回收过程中采用了复制算法和标记整理。
②首先将左侧的新生代内存区域分成两个等大小的空间From和To。
③From为使用空间,To为空闲空间。
④代码在执行的时候,需要申请空间来使用,首先将所有的活动对象存储于From空间,在这个过程当中To是空闲的。
⑤当From空间应用到一定程度时,就会触发GC操作,采用标记整理来对from空间进行一个活动对象的标记,找到这样的活动对象之后,继续使用整理的操作将位置变得连续,也为了后续不会产生碎片化的空间。
⑥将From活动对象拷贝至To,拷贝后就意味的from中的活动对象有了一个备份,这时就可以考虑去做回收操作了。
⑦将From空间进行一个释放。
回收细节说明如下:
①如果拷贝时发现某一个对象所指用的空间它在我们当前的老生代空间也会出现,这时就会出现一个晋升的操作。
②晋升就是指将新生代对象移动至老生代。
③触发晋升操作的时机。新生代当中的某些对象经过一轮GC之后还存活,这时就可以将它拷贝至老生代存储,如果在拷贝的过程中To空间使用率超过25%,这时需要将活动对象移动到老生代存储。
注意:为什么选择25%?将来进行回收操作的时候,最终是要把from空间和to空间进行一个交换,这就意味的如果我们的to使用率达到80%它变成活动对象的存储空间后,那么新的对象好像就存不进去了。
(2)老生代对象如何回收
A. 老生代对象说明
①老生代对象存放在右侧老生代区域。
②在64位操作系统是1.4G,32位操作系统中700M。
③老生代对象就是指存活时间较长的对象。
B. 回收实现
①主要采用了标记清除,标记整理,增量标记算法。
②首先使用标记清除完成对应垃圾空间的回收。
③如果想将新生代的内容往当前一个老生代存储区域进行移动的时候,而且这个时间节点上老生代存储区域上的空间又不足存放新生代区域移过来的对象,也就是晋升,这种情况下就会触发标记整理,把之前的碎片空间进行整理回收,这样就会有更多的空间使用。
④对于V8来说针对老生代对象回收主要采用的还是标记清除,当需要新生代往老生代移动出现空间不足时采用标记整理。
⑤采用增量标记进行效率提升。
C. 细节对比
①新生代区域垃圾回收使用空间换时间,因为采用的是复制算法。
②老生代区域垃圾回收不适合复制算法,因为老生代空间是比较大的,如果一分为二可能会出现几百兆空间浪费;老生代中存放的对象数据是比较多,在复制的过程中消耗的时间也会很多。
V8中标记增量如何优化垃圾回收
首先分出两个部分,程序执行和垃圾回收,当垃圾回收进行工作时,它会阻塞javascript程序执行,在这里面就会有一个空档期,例如程序执行完成之后会停下来执行当前的回收操作。
标记增量值:将我们当前一整段的垃圾回收操作拆分成多个小部分组合的去完成我们当前的整个回收,从而去替代之前一口气做完的垃圾回收操作,好处主要实现垃圾回收和程序执行交替的去完成,而不是程序执行的时候就不能进行垃圾回收,而在做垃圾回收的时候程序就不能执行,这样时间消耗能会更加合理,如图1.7所示。
图1.7 增量标记垃圾回收
V8增量标记的实现原理
程序在运行的时候是不需要去执行垃圾回收的,一旦触发了垃圾回收时无论采用的是何种的算法这而都会进行一个遍历对象进行标记,因为是针对的老年代所以存在遍历操作的,遍历的过程中它要做标记,其中存在的一个直接可达和间接可达操作,如果在做的时候第一步找到第一层的可达对象,那么我们就可以停下来了,让程序去执行一会,如果程序执行完一会以后可以让GC机制做一个标记操作,下面有些子元素可能也是可达的,那么就继续做一个标记,标记一轮后再让GC停止回到程序执行,交替的去做这两件事情,最后标记操作完成以后,标记操作肯定要去完成这样一个垃圾回收了,当完成垃圾回收后程序回到它该执行的地方。
// 如何编写高性能代码//
为了提高性能,我们可以尽量不要去改变变量的类型。对于性能要求比较高的项目,使用TypeScript也是不错的选择,理论上,如果严格遵守类型化的编程方式,也是可以提高性能的,我们还需要在写代码的过程中,多关注代码中的内存泄漏,虽然一般浏览器和大部分的前端框架在底层已经帮助我们处理了常见的内存泄漏问题,但是编写的过程中还是会因为编写不注意造成内存泄漏,下面介绍几种避免内存泄漏的方法:
避免使用全局变量
在ES5中以 var 声明的方式在全局作用域中创建一个变量时,或者在函数作用域中不以任何声明的方式创建一个变量时,都会挂载到 window全局对象上,如下所示:
var a = 1; // 等价于 window.a = 1;
function foo() {
a = 1;
}
等价于:
function foo() {
window.a = 1;
}
我们在 foo 函数中创建了一个变量a但是忘记使用 var 来声明,此时会创建一个全局变量并挂载到window对象上:
function foo() {
this.a = 1;
}
foo(); // 相当于 window.foo()
当 foo 函数在调用时,它所指向的运行上下文环境为 window 全局对象,因此函数中的 this 指向的其实是 window,这样就会创建了一个全局变量。当进行垃圾回收时,在标记阶段因为 window 对象可以作为根节点,在 window 上挂载的属性均可以被访问到,也就是可达对象,并将其标记为活动对象,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,需要在全局变量使用完毕后将其设置为null 这样就可以触发回收机制。
手动清除定时器
在我们的程序中经常会有使用到 setTimeout 或者 setInterval 等定时器的场景,定时器本身是一个非常有用的功能,但是如果我们不在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏,示例如下:
const numbers = [];
const foo = function() {
for(let i = 0;i < 100000;i++) {
numbers.push(i);
}
};
window.setInterval(foo, 1000);
在这个示例中,由于我们没有手动清除定时器,导致回调任务会不断地执行下去,回调中所引用的 numbers 变量也不会被垃圾回收,最终导致 numbers 数组长度无限递增,从而引发内存泄漏。
避免使用闭包
闭包是JS中的一个高级特性,巧妙地利用闭包可以帮助我们实现很多高级功能。一般来说,我们在查找变量时,在本地作用域中查找不到就会沿着作用域链从内向外单向查找,但是闭包的特性可以让我们在外部作用域访问内部作用域中的变量,示例如下:
function foo() {
let local = 123;
return function() {
return local;
}
}
const bar = foo();
console.log(bar()); // -> 123
在这个示例中,foo 函数执行完毕后会返回一个匿名函数,该函数内部引用了 foo 函数中的局部变量 local ,并且通过变量 bar 来引用这个匿名的函数定义,通过这种闭包的方式我们就可以在 foo 函数的外部作用域中访问到它的局部变量 local 。一般情况下,当 foo 函数执行完毕后,它的作用域会被销毁,但是由于存在变量引用其返回的匿名函数,导致作用域无法得到释放,也就导致 local 变量无法回收,只有当我们取消掉对匿名函数的引用才会进入垃圾回收阶段。
清除DOM引用
我们在很多时候操作DOM元素时,为了避免多次获取DOM元素,我们会将DOM元素存储在一个数据字典中,示例如下:
const els = {
button: document.getElementById('button')
};
function removeButton() {
document.body.removeChild(document.getElementById('button'));
}
在这个示例中,我们想调用 removeButton 方法来清除 button 元素,但是由于在 els 字典中存在对 button 元素的引用,所以即使我们通过removeChild 方法移除了 button 元素,它其实还是依旧存储在内存中无法得到释放,只有我们手动清除对 button 元素的引用才会被垃圾回收。
弱引用
以上几种情况我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,为此,在ES6中为新增了两个有效的数据结构 WeakMap 和 WeakSet ,就是为了解决内存泄漏的问题而诞生的。其表示弱引用,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。这也就意味着我们不需要关心 WeakMap 中键名对其他对象的引用,也不需要手动地进行引用清除,我们尝试在node中演示一下过程(参考阮一峰ES6标准入门中的示例)
首先打开node命令行,输入以下命令:
node --expose-gc // --expose-gc 表示允许手动执行垃圾回收机制
然后我们执行下面的代码:
// 手动执行一次垃圾回收保证内存数据准确
> global.gc();
undefined
// 查看当前占用的内存,主要关心heapUsed字段,大小约为4.4MB
> process.memoryUsage();
{ rss: 21626880,
heapTotal: 7585792,
heapUsed: 4708440,
external: 8710 }
// 创建一个WeakMap
> let wm = new WeakMap();
undefined
// 创建一个数组并赋值给变量key
> let key = new Array(1000000);
undefined
// 将WeakMap的键名指向该数组
// 此时该数组存在两个引用,一个是key,一个是WeakMap的键名
// 注意WeakMap是弱引用
> wm.set(key, 1);
WeakMap { [items unknown] }
// 手动执行一次垃圾回收
> global.gc();
undefined
// 再次查看内存占用大小,heapUsed已经增加到约12MB
> process.memoryUsage();
{ rss: 30232576,
heapTotal: 17694720,
heapUsed: 13068464,
external: 8688 }
// 手动清除变量key对数组的引用
// 注意这里并没有清除WeakMap中键名对数组的引用
> key = null;
null
// 再次执行垃圾回收
> global.gc()
undefined
// 查看内存占用大小,发现heapUsed已经回到了之前的大小(这里约为4.8M,原来为4.4M,稍微有些浮动)
> process.memoryUsage();
{
rss: 22110208,
heapTotal: 9158656,
heapUsed: 5089752,
external: 8698
}
在上述示例中,虽然我们没有手动清除WeakMap 中的键名对数组的引用,但是内存依旧已经回到原始的大小,说明该数组已经被回收,那么这个也就是弱引用的具体含义了。
// 总结 //
V8引擎垃对新生代使用 Scavenge 算法,将空间分成两半,每次只使用一半,回收时相当于从根对象开始做 BFS,将存活的对象移到另一半中,死亡对象原来占据的空间就相当于被释放了,这一过程一般不超过 1ms。老生代使用增量式地标记存活对象,相当于从根对象开始做 DFS,每次标记一般不超过 5ms。标记完后,使用标记清除扫描并回收死亡对象占据的空间,用标记整理移动存活对象整理碎片严重的内存页。清理按需执行(lazy),一般不阻碍主线程执行程序,并且由多个清理线程并行完成。为了降低新生代 GC 扫描的开销,以及保证增量式的结果不会随着程序运行失效,V8 引入了写屏障(write barrier),在发生引用变化的时候执行一段代码,用于记录相关信息以供 GC 快速查询。
V8引擎中的垃圾回收功能可以避免影响JS执行的情况下,同时回收内存空间,提高内存利用率,很多我们并不需要考虑垃圾回收,但是了解一些垃圾回收的内部原理,可以更好了解内存的使用情况,以及采取合适的编范式。
★
往期推荐
● 编排场景技术模型
//致力于互联网教育技术的创新和推广//
@学而思网校技术团队
分享、点赞与在看,至少我要拥有一个吧