JavaScript工作原理探秘——JavaScript引擎篇

1,227 阅读10分钟

我们每天都在写的JavaScript是怎么运行的?

准确地说,Javascript 在其运行时环境上是如何工作的才对,Node 和 浏览器都是 Javascript 的运行时环境。这篇文章我们来探索下运行时环境的核心——JavaScript引擎。

一、JavaScript引擎介绍

JavaScript 引擎是什么:一种用于将我们的代码转换为机器可读语言的引擎。

JavaScript引擎作用:将js代码编译成机器码,还负责执行代码、分配内存以及垃圾回收。

解释器和编译器

我们已经知道了JavaScript引擎就是为了把JavaScript代码转换为机器可读语言,通常来说转换方法有两种:

  • 解释器 Interpreter,逐行解释代码并立即执行

  • 编译器 Compiler ,读取整个代码,进行一些优化,生成对应的机器代码

我们来看一个例子:

for(i=0; i < 1000; i++){
    sum += i;
}

编译器会把sum +=i;编译成机器码,然后运行机器码1000次,速度比较快;

解释器会把sum +=i;转换编译1000次,性能不高;

解释器和编译器的优缺点:

  • 解释器优点在于无需等待编译,立即执行代码。这对于在浏览器上执行JavaScript提供了极大的便利。但是当有大量的js代码需要执行时就会比较慢,例如大量循环操作
  • 编译器需要花费一些时间来编译代码,进行一些优化工作,最终生成执行优化过的代码

为了综合利用解释器和编译器,提高程序编译、执行速度,出现了**JIT(Just In Time)**即时编译技术,它在执行前进行编译,现代浏览器大都实现了这一功能。

JIT工作原理:

  • 第一步使用解释器执行源码,当某一行代码被执行了几次,这行代码就会打上Warm标签;当某行代码被执行了很多次,就会打上Hot标签

  • 第二步,被打上 Warm 标签的代码会被传给Baseline Compiler 编译且储存,同时按照行数 (Line number)变量类型 (Variable type) 被索引。

    例如上面的例子sum+=i,因为js的动态类型,编译结果可能会有很多种:

    • sum是number类型,这里的+就是加法运算
    • sum是string类型,这里的+就是字符串拼接,并把i转为string类型
    • sum是boolean类型,会把sum转为number类型再进行加法运算
    • 。。。

    因为不同的变量类型会对应不同的编译结果,所以编译后的代码要使用行数和变量类型做索引。

    当发现执行的代码命中索引,会直接取出编译后的代码执行,从而不需要重复编译已经编译过的代码

  • 第三步,被打上 Hot 标签的代码会被传给 Optimizing compiler,这里会对这部分代码做更优化的编译。怎么更优化?做一些关于变量类型和运行环境中值的假设,如果假设不成立就将这个优化的版本回退,如果假设成立的话,这将让代码性能更高。

    JIT 直接假设一个前提,比如这里我们直接假设 sum 是 number,i 也是 number,于是就只用一种编译结果就好了。实际上,在执行前会做类型检查,看是假设是否成立,如果不成立执行就会被打回 interpreter 或者 baseline compiler 的版本,这个操作叫做 "反优化 (deoptimization)"。

    可以看出,只要假设的成功率足够高,那么代码的执行速度就会快。但是如果假设的成功率很低,那么会导致比没有任何优化的时候还要慢(因为要经历 反优化 的过程)

JavaScript 运行速度的提升离不开 JIT compiler 的贡献,通过对多次执行的代码的编译结果的存储,以及对变量类型的合理推测,尽管存在运行时间加长的可能,但还是整体降低了 JavaScript 代码的平均执行时间。这也提醒我们保持一个很好的习惯就是不要随意修改一个变量的类型。

二、V8引擎

比较出名的JavaScript引擎有:

  • V8,chrome浏览器、node.js
  • SpiderMonkey,火狐浏览器
  • JavaScriptCore,safari浏览器
  • Chakra,微软

由于V8是事实上最流行、性能最佳的JavaScript引擎,我们来看看它的构成。

V8由很多子模块构成,其中有四个是最重要的:

  • Parser:负责将JavaScript代码转换为AST抽象语法树
  • Ignition:interpreter,即解释器,负责将AST转换为ByteCode,解释执行ByteCode,同时执行TurboFan优化编译所需的信息,比如函数参数的类型
  • TurboFan:compiler,即编译器,利用Ignition收集的类型信息,将bytecode转为优化的汇编代码
  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收,使用标记清除的原理。

其流程图如下:

三、JavaScript引擎解析js源码

分为两个阶段:语法检查和运行阶段。

第一阶段语法检查,分为词法分析和语法分析

1,词法分析

JavaScript解释器将js源码按照ECMAScript标准转换为词法单元。例如,考虑程序 var a = 2;。

这段程序通常会被分解成为下面这些词法单元:

var、a、=、2 、;

2,语法分析

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法

结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。

可以通过访问 AST Explorer 来查看实际的 AST 树。

第二阶段:运行阶段,也分为两个部分,预解析阶段和执行阶段

1,预解析阶段

第一步:创建执行上下文,JavaScript引擎将语法检查正确后的AST复制到当前执行上下文中。

创建的执行上下文包括:

  • 变量对象,由变量声明、函数声明、参数(arguments)构成
    • 引擎每次遇到声明语句,就会把声明传到作用域(scope)中创建一个绑定。每次声明都会为变量分配内存。只是分配内存,并不会修改源代码将变量声明语句提升。正如你所知道的,在JS中分配内存意味着将变量默认设为undefined。所以变量提升只是执行上下文的小把戏,不是真的源代码修改。
  • 作用域链,由变量对象及所有的父级作用域构成
  • this,this值在进入上下文阶段确定,一旦进入执行阶段this值不会再变化

第二步:属性填充,JavaScript引擎会对语法树当中的变量对象/活动对象(VO/AO)的变量声明、函数声明、形参进行属性填充

填充顺序、比重为函数的形参->函数声明->变量声明

var a=1;
    function b(a) { 
        alert(a);
    }
    var b;
    alert(b); // function b(a) { alert(a); }
    b();  //undefined

以上代码在进入执行上下文时,按照函数的形参->函数声明->变量声明顺序来填充,并且优先权永远都是函数的形参>函数声明>变量声明,所以只要alert(a)中的a是函数中的形参,就永远不会被函数和变量声明覆盖。就算没有赋值也是默认填充的undefined值。

2,执行阶段

进入执行代码阶段,VO/AO就会重新赋予真实的值,“预解析”阶段赋予的undefined值会被覆盖。

此阶段才是程序真正进入执行阶段,Javascript引擎会一行一行的读取并运行代码。此时那些变量都会重新赋值。

假如变量是定义在函数内的,而函数从头到尾都没被激活(调用)的话,则变量值永远都是undefined值。

四、JavaScript引擎垃圾回收原理:

目前主流的浏览器JavaScript引擎进行垃圾回收,都使用标记清除的原理,只有ie6、7浏览器使用引用计数的原理。

标记清除(mark and sweep)

这是JavaScript最常见的垃圾回收方式,当变量进入执行环境的时候,比如函数中声明一个变量,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”。至于怎么标记有很多种方式,比如特殊位的反转、维护一个列表等,这些并不重要,重要的是使用什么策略,原则上讲不能够释放进入环境的变量所占的内存,它们随时可能会被调用的到。

垃圾回收器会在运行的时候给存储在内存中的所有变量加上标记,然后去掉环境中的变量以及被环境中变量所引用的变量(闭包),在这些完成之后仍存在标记的就是要删除的变量了,因为环境中的变量已经无法访问到这些变量了,然后垃圾回收器回收这些带有标记的变量机器所占空间。

引用计数(reference counting)

在低版本IE中经常会出现内存泄露,很多时候就是因为其采用引用计数方式进行垃圾回收。引用计数的策略是跟踪记录每个值被使用的次数,当声明了一个变量并将一个引用类型赋值给该变量的时候这个值的引用次数就加1,如果该变量的值变成了另外一个,则这个值的引用次数减1,当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问了,因此可以将其占用的空间回收,这样垃圾回收器会在运行的时候清理掉引用次数为0的值占用的空间。

这种方式没办法解决循环引用问题。比如对象A有一个属性指向对象B,而对象B也有有一个属性指向对象A,这样相互引用

function test(){
            var a={};
            var b={};
            a.prop=b;
            b.prop=a;
        }

这样a和b的引用次数都是2,即使在test()执行完成后,两个对象都已经离开环境,在标记清除的策略下是没有问题的,离开环境的就被清除,但是在引用计数策略下不行,因为这两个对象的引用次数仍然是2,不会变成0,所以其占用空间不会被清理,如果这个函数被多次调用,这样就会不断地有空间不会被回收,造成内存泄露。

V8的垃圾回收模块Orinoco大致是这样做的

  • 采用多线程的方式进行垃圾回收,尽量避免对JavaScript本身的代码执行造成暂停;
  • 利用浏览器渲染页面的空闲时间进行垃圾回收;
  • 根据The Generational Hypothesis,大多数对象的生命周期非常短暂,因此可以将对象根据生命周期进行区分,生命周期短的对象与生命周期长的对象采用不同的方式进行垃圾回收;
  • 对内存空间进行整理,消除内存碎片化,最大化利用释放的内存空间;

这篇文章参考了许多优秀作者的文章、博客,列举如下(排名不分先后):

五、结尾

最后,是一凡的公众号,记录日常的学习与工作思考,大家一起努力~