其他相关文章看这里:
浏览器原理与优化—总揽
浏览器原理与优化—网络篇
浏览器原理与优化—渲染篇
对于前端开发来说,JavaScript 的内存机制是一个不经常被提及的概念,因此很容易被忽视.但是,想要开发一个高性能的前端应用,必须要搞清楚 JavaScript 的的内存机制.
我们都知道当程序运行时,有的数据在使用过之后可能就不再被需要了,这些数据我们称之为垃圾数据.当垃圾数据一直占用我们的内存时,内存得不到快速的释放,可能就会造成内存泄漏,导致我们的内存不够用了.
这个时候就需要对垃圾数据进行垃圾回收,释放被占用的内存空间
一般情况下,垃圾回收分手动回收和自动回收两种情况。
手动垃圾回收指的是我们需要自己分配内存和进行内存的回收等操作,如果当一段代码不需要了但是我们却没有进行垃圾回收的时候,就会造成内存泄漏。手动回收的代表语言就是 C 和 C++了
自动垃圾回收是指我们在编码的过程中不需要手动控制内存的分配和释放,这些操作都是自动完成的一个过程。自动垃圾回收的语言也有很多,例如:Java、JavaScript、Python 等
JS 中的垃圾回收策略是自动回收.也就是说不需要我们自己去进行垃圾回收,当满足一定条件的时候 JS 垃圾回收机制就会被触发,从而进行垃圾回收.
对于 JS 中的垃圾回收而言,他是一个自动完成的过程。在这个过程中我们是不需要关心内存的分配和回收的。
但是,如果我们想要开发出一个高性能的前端应用的话,却不得不关注垃圾回收。
我们想要了解 JS 中的垃圾回收,首先要关注的就是 JS 中的数据是如何存储的:
众所周知,JS 中的内存空间分为栈空间、堆空间和代码空间,其中代码空间主要是存储可执行代码的,因为与内存回收的关系不大,所以这里不做扩展介绍了。
这里的栈空间就是我们平常代码执行过程中遇到的调用栈,是用来存储执行上下文的。堆空间也是用来存储执行上下文之外的数据类型。
这里用一段代码来简要说明一下:
function foo(){
var a = '测试'
var b = a
var c = {name:'小明'}
var d = c
}
foo()
JS 执行代码的时候,需要先进行编译,创建执行上下文,然后在按照顺序执行代码。
所以上面这段代码的顺序执行到“var b=a”的时候,因为 a 的值为基本类型,所以根据 JS 的变量提升的规则,这个时候的调用栈状态应该是这样的:
当执行 foo 函数剩余部分的代码的时候,我们的调用栈会发生相应变化:
从上面我们可以看出,基本类型的变量的值是存储在栈空间中的,复杂类型变量的值存储在堆空间中,对于复杂类型,栈空间中只存放了一个指向其堆内存位置的一个引用,这里有一点需要注意的是闭包函数中的内部函数如果引用了外部函数声明的变量,那么该变量则会存储在堆内存中,这点我们后面在进行介绍。
这里简要解释一下为什么 JS 要分为栈空间和堆空间:
栈内存中的垃圾回收其实就是销毁执行栈中的执行上下文,我们都知道执行栈中存放的就是函数执行过程中的执行上下文,栈顶就是我们正在执行函数的执行上下文。
当我们的函数执行完毕后,执行栈中对应的执行上下文会被销毁,这也就是栈垃圾回收的过程。
为了了解这一过程,我们举个简单的例子:
function foo1(){
var a=1
function foo2(){
var b = 2
}
foo2()
}
foo1()
当执行到"foo2()"这一行时,执行栈中的状态其实是这样的:
图中的ESP 是执行栈中用来记录当前执行状态的指针,
当我们执行完"foo2()"这一行时,ESP 指针下移,这个下移的操作执行栈中的 foo2 函数对应的上下文被回收的过程。
其实这个指针下移的操作进行垃圾回收很好理解,因为 ESP 是记录当前执行的状态,当 ESP 指针下移的时候,之前的执行上下文就会被认为是无效的内存了,下次创建新的执行上下文的时候该内存就会被覆盖,从而达到了内存回收的目的。
综上所述,JS 引擎是通过 ESP 指针的下移操作来完成栈内存中的垃圾回收的
从上面的介绍我们知道,堆内存中主要存放的是复杂数据类型和闭包内部函数引用的基本类型的数据。那么堆内存又是怎么进行垃圾回收的,下面我们来看一下。
在看堆内存中的垃圾回收之前,我们要先看两个概念:代际假说和分代收集。这两个概念十分重要,在好多语言的垃圾回收中都有用到。JS 中堆内存的垃圾回收就是建立在这两个概念之上的。
代际假说的核心有两点:
有了上面两点,我们就很容易理解分代收集的概念了:
堆空间根据代际假说和分代收集分为了新生代区域和老生代区域,他们的垃圾回收分别对应了副垃圾回收器和主垃圾回收器。
其实这两个垃圾回收器的大致工作流程都是相同的,可以简化为三步:
副垃圾回收器主要是对新生代区域进行垃圾回收。我们上面也介绍了新生代区域的空间比较小,大约是 1 ~ 8M主要是存放一些存活时间比较短,占内存比较小的对象。
下面我们来看一下副垃圾回收器的工作流程:
副垃圾回收器采用的是 Scavenge 算法进行垃圾回收,这个所谓的 Scavenge 算法,主要是将新生代区域分成了两部分:空闲区域和对象区域
上面一系列的操作就完成了新生代区域的垃圾回收的工作,该过程完全是按照我们之前说过的垃圾回收器的三步工作流程完成的。
其中,内存整理的过程,因为是对象区域的有效数据按照一定顺序放到了空闲区域中,所以这里也顺便完成了内存碎片的整理。
这里有一个需要注意的地方,我们之前提到新生代区域的空间是很小的,大约是 1 ~ 8M,所以新生代区域会很快被填满,这个时候 JS 引擎有一个对象晋升策略用来应对这种情况:
对象晋升策略规定,两次垃圾回收还存活的对象就会被移动到老生代区域中
主垃圾回收器是对老生代区域进行垃圾回收的。我们上面提到了老生代区域主要是存放的存活时间比较长或者占内存比较大的对象。
我们都知道新生代区域的垃圾回收使用了 Scavenge 算法,但是老生代区域的内存空间明显要大很多,所以使用 Scavenge 算法进行垃圾回收的效率明显要低很多。
所以这个时候主垃圾回收器还是得老老实实的按照之前的三步进行垃圾回收操作。
这里,我们不得不提到的两个概念:引用计数算法和标记-清除算法。这两个算法都是针对垃圾数据标记的。
引用计数算法是 JS 引擎早期采用的一个垃圾标记算法,该算法存在一个问题,那就是无法应对互相引用的情况。也就是说当两个对象互相引用时,就会永远无法被回收,从而造成内存泄漏。
基于上面的问题,后来提出了标记-清除算法,这也是现在 JS 引擎采用的算法,该算法解决了互相引用的问题,但是也有他的局限性。例如,当我们在全局环境下定义一个 DOM 的点击监听事件的时候,因为可是直接到达,所以是无法被垃圾回收器回收的。这也就是为什么我们在合适的时机清除我们的监听事件,因为如果不这样的话会造成内存泄漏。
介绍完了标记-清除算法,我们开始正式介绍我们的主垃圾回收器的工作流程吧。
首先,通过标记-清除算法,进行垃圾数据的标记。当存在不能直接到达的对象的时候会将数据标记为垃圾数据。至于什么是不能直接到达,我们可以对照一下上面提到的对于复杂类型,栈内存中存放的是指向其堆内存地址的一个引用。这两个算法都是针对垃圾数据标记的。
当我们执行上下文销毁之后,这个引用也就被销毁了,也就是说没有直接到达该对象的路径了,那么这个对象会被标记为可回收对象。
标记好垃圾数据之后,主垃圾回收器开始进行垃圾回收。这个垃圾回收的过程其实就是把可回收对象加入到空闲列表中。
剩下的过程就是内存碎片整理了。主垃圾回收器会将存活的对象移动到一端,然后清理掉边界以外的内存。
至此,我们就介绍完了真个 JS 引擎的垃圾回收的过程。下面来稍微总结一下:
介绍完了 JS 引擎的垃圾回收,还有个地方需要我们关注一下。那就是全停顿问题。
我们都知道垃圾回收是运行在 JS 线程上的,一旦执行垃圾回收算法就需要将正在执行的 JS 脚本暂停下来,待垃圾回收完毕后再继续执行脚本。这个过程就是全停顿
而且,之前的文章我们介绍过,JS 线程和渲染线程是互斥的,也就是说垃圾回收过程中,暂停了 JS 脚本执行同时挂起了渲染引擎。一旦垃圾回收时间过长,就会造成界面卡顿。
通过前面的介绍我们知道,栈内存和新生代区域的垃圾回收都是很快的,也就是说造成全停顿的主要原因就是老生代区域的垃圾回收工作。
为了解决这一问题,V8 引擎将垃圾数据的标记过程,分为了一个个的子标记过程,同时让垃圾回收标记和 JS 脚本逻辑交替执行,直到标记阶段完成,这个算法就是增量标记算法。当存在不能直接到达的对象的时候会将数据标记为垃圾数据。至于什么是不能直接到达,我们可以对照一下上面提到的
通过增量标记算法的优化,可以把一个较大的垃圾回收任务拆分成多个子任务,通过这些子任务和 JS 脚本的交替执行,规避了全停顿。从而让用户感受不到垃圾回收造成的卡顿问题。
其实这章主要的是介绍了 JS 引擎的垃圾回收的原理。想必大家也发现了,无论是垃圾回收的策略还是全停顿的的策略,其实都没有完美的解决方案。
我们在编程的过程的时候,针对垃圾回收的优化其实也很有限,这可能也是一个权衡的过程,需要牺牲某些方面的指标来换取某些性能的提升。
关于垃圾回收方面的优化,我能想到的就是及时回收 JS 引擎不能自动回收的数据,比如我们在全局下注册的监听事件等。希望有别的大神可以给一些建设性的建议或者意见