07 V8工作原理

206 阅读15分钟

1. 数据存储

JavaScript是一种弱类型的动态语言。

弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来。

动态,意味着你可以使用同一个变量保存不同类型的数据。 image.png JavaScript的数据类型有以下八种: image.png 有一点,typeof检Null类型,返回值为object,这是JavaScript的一个早期bug,最初一个对象声明但是没有初始化的时候常常使用null来初始化,null在这里的意思表示一个空指针。之所以一直没修改过来,主要是为了兼容老的代码。

而typeof检测函数返回function,是认为函数也是一种特殊的引用类型,所以加以区分。

JavaScript的基本类型保存在栈中,引用类型保存在堆中,栈中只维持一个堆中的地址。

JavaScript的内存空间可用下图来描述: image.png 其中代码空间是用来存储可执行代码的、栈空间就是存储执行上下文的调用栈。

为了搞清楚栈空间是如何存储执行上下文的,我们先来看一段代码:

function foo() {
  var a = " 极客时间 "
  var b = a
  var c = {
    name: " 极客时间 "
  }
  var d = c
}
foo()

代码执行到第四行的时候,其调用栈的状态大致如下; image.png 现在你估计会疑问,为什么一定要分“堆”和“栈”两个存储空间呢?

这是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。

比如文中的 foo 函数执行结束了,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,foo 函数执行上下文栈区空间全部回收,具体过程你可以参考下图: image.png 所以栈空间一般都用来存储一些原始类型的数据。引用类型的数据一般都很大,采用堆来存储,缺点是是分配内存和回收内存都会占用一定的时间。

还有一点需要强调的是:原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址image.png 了解了基本的存储规则,下面我们再回过头来谈谈闭包。

function foo() {
  var myName = " 极客时间 "
  let test1 = 1
  const test2 = 2
  var innerBar = {
    setName: function(newName) {
      myName = newName
    },
    getName: function() {
      console.log(test1)
      return myName
    }
  }
  return innerBar
}
var bar = foo()
bar.setName(" 极客邦 ")
bar.getName()
console.log(bar.getName())

分析这段代码时,你也许有过这种想法:当 foo 函数执行结束之后,调用栈中 foo 函数的执行上下文会被销毁,其内部变量 myName、test1、test2 也应该一同被销毁。

但经过上一节闭包的学习我们知道,其实在执行上下文被销毁之后,产生了闭包,引用的test1和myName并没有被销毁,而是被保存在了内存中。但是该怎么从内存层面解释这种现象呢?

不妨尝试在内存模型的角度分析一下代码的执行过程:

  1. 当JavaScript引擎执行到foo函数的时候,首先会进行编译(函数只有第一次执行的时候才会被编译),并创建一个空的执行上下文。
  2. 在编译过程中,遇到内部函数setName,JavaScript引擎会对内部函数快速做一个词法扫描,发现该内部函数引用了foo函数的myName变量,因此JavaScript引擎判断这是一个闭包,在堆空间中创建一个“closure(foo)”的对象[内部对象,JavaScript无法访问],用来保存myName变量。
  3. 继续扫描遇到getName方法,发现函数内部也引用了foo中的test1,于是JavaScript引擎把test1加入到对象“closure(foo)”中。
  4. test2并没有被内部函数调用,所以仍然保存在调用栈中,执行完可执行代码后,释放foo的执行上下文。

foo执行到return语句时候的调用栈: image.png 从上图可以看出:即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了“clourse(foo)”。

总的来说,产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。

2. 垃圾回收

有些数据使用之后可能就不再需要了,存放在内存中很影响内存的使用,我们把这部分数据称为垃圾数据

通常情况下,垃圾回收分为自动回收和手动回收:

C/C++使用手动回收策略,何时分配、销毁内存都是由代码决定的。比如要使用堆空间中的一块儿内存,要先malloc(),再free(),如果忘记free(),就会造成内存泄漏

Java、JavaScript、Python等语言使用自动回收策略,产生的垃圾数据是由垃圾回收器来释放的

因为数据JavaScript是存放在堆和栈中的,所以接下来我们来了解一下堆和栈中的数据分别是如何让进行回收的。

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

首先分析一下下面的代码:

 function foo() {
   var a = 1
   var b = {
     name: " 极客邦 "
   }
​
   function showName() {
     var c = " 极客时间 "
     var d = {
       name: " 极客时间 "
     }
   }
   showName()
 }
 foo()

代码执行到第六行的时候,调用栈如下图: 7-8.png showName函数执行完之后,函数流程就进入了foo函数。showName的执行上下文就要被销毁,这时候ESP就会下移到foo函数

ESP: 调用栈中记录当前执行状态的指针 7-9.png 当ESP移动到foo函数之后,showName函数的执行上下文虽然仍在调用栈中,但已经是无效内存了,当有新的执行上下文进入调用栈的时候,这块儿内容会被覆盖掉。

所以说,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文,不需要V8的垃圾回收机制,效率非常高。

2.2 堆中的数据是如何被回收的

上面的代码执行完之后,调用栈就变成下面的状态: 7-10.png 虽然此时调用栈已经空了,但堆空间仍然有内存被占用。要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了

V8引擎把堆分为新生代和老生代两个区域,分别使用两个不同的垃圾回收器实现高效回收:

  1. 新生代中存放的是生存时间短的对象,通常只支持1~8M的容量,靠副垃圾回收器回收
  2. 老生代中存放的是生存时间久的对象,支持的容量大,靠主垃圾回收器回收

2.2.1 垃圾回收器的工作流程

不论什么类型的垃圾回收器,它们都有一套共同的执行流程

  1. 标记空间中活动对象和非活动对象

  2. 回收非活动对象所占据的内存

  3. 对内存进行整理:

    频繁回收对象之后,内存中就会存在大量不连续空间,也称为内存碎片。当出现大量内存碎片的时候,当需要分配较大连续内存空间的时候,就有可能出现内存不足的情况。

    这步是可选的,有的垃圾回收器不会产生内存碎片,比如副垃圾回收器。

熟悉了工作流程之后,我们接下来分析一下主垃圾回收器和副垃圾回收器是如何处理垃圾回收的。

2.2.2 副垃圾回收器

通常情况下,大多数小的、生存时间短的对象会被送到新生区,由副垃圾回收器负责回收。这个区域不大,但回收频率很高。

新生代中采用Scavenge 算法来处理。所谓Scavenge 算法是把新生代对等分为两个区域:对象区域和空闲区域。 7-11.png 新加入的对象被存放在对象区域,当对象区域快满的时候,执行一次垃圾回收操作:

  1. 先对对象区域中的垃圾做标记(和老生区的标记是同一个过程)
  2. 副垃圾回收器把存活的对象复制到空闲区域,同时有序排列起来(相当于执行了内存整理操作,复制后空闲区没有内存碎片)
  3. 翻转对象区域和空闲区域

因为每次执行清理操作的时候,都需要对存活对象进行复制,所以为了执行效率,新生区的空间一般都被设置的比较小。但新生区空间不大就会很容易被存活的对象装填满。为了解决这个问题,JavaScript引擎采用对象晋升策略

经过两次两次垃圾回收还存活的对象,会被移动到老生区中。

2.2.3 主垃圾回收器

除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。老生区的对象一般具有占用空间大、存活时间长的特点。因为这些特点,在老生区采用Scavenge算法进行垃圾回收,复制大的对象将要花费很多时间,同时还要浪费一半的空间,执行效率不高。因此,主垃圾回收器采用标记清除(Mark-Sweep) 的算法进行垃圾回收。

  1. 标记过程

    从每一组根元素开始,递归遍历这组根元素,堆中对象被引用就被称为活动对象,没有引用就被判断为垃圾数据。V8维护了一个空闲列表,垃圾清理过程就是把没有标记的添加到空闲列表中。

    如上面代码,当showName函数退出后:7-12.png

  2. 垃圾清除

    参考下图:7-13.png

由于上述的垃圾清除可能会造成内存碎片过多,于是又产生了另外一种算法——标记 - 整理(Mark-Compact) ,这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 7-14.png

2.2.4 全停顿

由于JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

堆中数据过多,由于垃圾回收而引起 JavaScript 线程暂停执行的时间会边长,会影响到应用的性能和响应能力。 7-15.png

新生代中数据少,因为垃圾回收造成的停顿影响不大。但老生代一般存储的数据大且多,。如果在执行垃圾回收的过程中,占用主线程时间过久,会造成页面卡顿现象。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。如下图所示: 7-16.png

3. 编译器和解释器

3.1 执行流程

编译器和解释器存在的目的都是为了将我们的代码转换为机器能读懂的机器码。按语言的执行流程,可把语言分为解释型语言编译型语言

编译型语言执行之前,需要先编译。编译之后直接保留机器能读懂的二进制文件,每次运行程序的时候,都可以直接执行二进制文件,不需要重新编译。如C/C++、Go等。

解释型语言每次运行时都需要通过解释器对程序进行动态解释和执行。如Python、JavaScript等。

编译器和解释器执行代码的流程可参见下图: 7-17.png 两者的大致执行流程客阐述为:

  1. 编译器编译过程中,首先对源代码进行词法分析、语法分析。生成抽象语法树(AST)、然后是优化代码、最后生成处理器能理解的机器码。如果编译成功、会生成一个可执行文件,如果编译过程发生了错误,编译器会抛出异常,最后的二进制文件不会成功生成。
  2. 解释型语言的解释过程中,解释器也会对源代码进行词法分析、语法分析,生成抽象语法树,不过它会基于语法树生成字节码,最后根据字节码来执行程序、输出结果。

3.2 V8是怎样执行一段JS代码的

话不多说,先上图: 7-18.png

3.2.1 生成抽象语法树和执行上下文

a. AST是什么

关于执行上下文,主要是代码在执行过程中的环境信息,前面已经介绍的足够多了,这里不再赘述。接下来主要谈谈抽象语法树,看看什么是抽象语法树以及抽象语法树是怎样形成的。

编译器和解释器无法直接理解高级语言,必须转换为类似于AST才能被理解。所以无论是编译型语言还是解释型语言,在编译过程中都会生成一个AST.

你可以参考这段代码感受一下什么是AST:

var myName = " 极客时间 "
function foo(){
  return 23;
} 
myName = "geektime"
foo()

这段代码经编译后变成了:

7-19.png 你可以把AST看作是源代码的结构化表示,编译器或者解释器后续的工作都需要依赖于AST,而不是源代码。

AST是一种非常重要的数据结构,许多项目中有着广泛运用。比如Babel,就是先把ES6转换为AST、再把ES6的AST转为ES5的AST,最后再利用ES5的AST生成ES5代码。除了Babel外,ESlint也使用AST检查代码规范化问题。

现在你知道AST是什么了,接下来再来看看AST是如何生成的。

b. AST的生成
  1. 分词

    也称作词法分析,是将一行行代码拆分为一个个的token。token是指语法上不能再分的、最小的单个字符或者字符串。 7-20.png

  2. 解析

    也称作语法分析,将上一步生成的token数据,根据语法规则转换为AST,如果语法错误,终止,抛出语法错误信息。

    有了AST之后,V8 就会生成该段代码的执行上下文。

3.2.2 生成字节码

有了AST和执行上下文后,解释器根据AST生成字节码,并解释执行字节码。

一开始的V8没有字节码,直接将AST转换为机器码,机器码效率很高。但是由于手机的普及,Chrome运行再手机上,V8需要消耗大量的内存来保留转换后的机器码。为了解决内存占用为问题抛弃了之前的编译器,引入了字节码。

字节码是一种介于AST和机器码之间的代码,与特定类型的机器码无关,字节码需要解释器转换为机器码之后才能被执行。 7-21.png 从图中可以看出,机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

3.2.3 执行代码

如果一段字节码是第一次执行,解释器(Ignition)会逐条解释执行。如果在执行代码的过程中发现有热点代码,只需要执行这段热点代码编译后的机器码。

热点代码:一段代码重复出现多次,后台的编译器(TurboFan)就会把这段代码编译为高效的机器码

V8执行效率随着执行时间越来越高效率,因为热点代码都被编译器 TurboFan 转换了机器码,直接执行机器码就省去了字节码“翻译”为机器码的过程。

字节码配合解释器和编译器是最近一段时间很火的技术,我们把这种技术称为即时编译(JIT)。具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。 7-22.png

3.3 JavaScript的性能优化

在过去几年中,JavaScript 的性能得到了大幅提升,这得益于 V8 团队对解释器和编译器的不断改进和优化。虽然在 V8 诞生之初,也出现过一系列针对 V8 而专门优化 JavaScript 性能的方案,比如隐藏类、内联缓存等概念都是那时候提出来的。不过随着 V8 的架构调整,越来越不需要这些微优化策略了,相反,对于优化 JavaScript 执行效率,应该将优化的中心聚焦在单次脚本的执行时间和脚本的网络下载上,主要关注以下三点内容:

  1. 提升单次脚本的执行速度,避免 JavaScript 的长任务霸占主线程,这样可以使得页面快速响应交互;

  2. 避免大的内联脚本,因为在解析 HTML 的过程中,解析和编译也会占用主线程

  3. 减少 JavaScript 文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。