JavaScript引擎工作原理解析

JavaScript引擎是什么

想知道JavaScript引擎是什么,首先要知道JavaScript(简称js)是什么,相信对于屏幕前的你来说,js是干什么的已经不用再多说,但还是有必要介绍下JavaScript的语言性质。

首先计算机不能直接理解高级语言,只能直接理解机器语言,所以必须要把高级语言翻译成机器语言,计算机才能执行高级语言编写的程序。

高级语言有两种执行方式:一个是编译,一个是解释,与之对应的就是编译型语言和解释性语言,两种方式只是翻译的时间不同。

编译型语言写的程序执行之前,需要一个专门的编译过程,把程序编译成为机器语言的文件,比如exe文件,运行时不用再重新翻译,直接使用编译的结果就行了,因为翻译只做了一次,所以编译型语言的程序执行效率很高。

解释性语言写的程序不需要编译,省了道工序,其在运行程序的时候才翻译,这样每执行一次就要翻译一次,效率比较低。

对于JavaScript来说:源代码不是直接翻译成机器语言,而是先翻译成中间代码,再由解释器对中间代码进行解释运行,每执行一次都要翻译一次,这也是js代码不能脱离解释器独立运行的原因,因此效率比较低,大约是等效c语言的10%。又由于JIT (即时编译)的存在,使得JavaScript兼具编译和解释型语言的特性,让人们很难明确的界定它的语言性质,百度百科关于此词条是这样解释的

本文不深究于此,感兴趣的同学请移步《JavaScript到底是解释型语言还是编译型语言?》。

但是无论JavaScript是什么类型的语言,其执行都离不开运作它的工具——JavaScript引擎:一个专门处理JavaScript脚本的虚拟机,它是能够“读懂”JavaScript代码,并准确地给出代码运行结果的一段程序,一般会附带在网页浏览器之中。

常见JavaScript引擎:

应用程序(实现)

方言和最后版本

ECMAScript版本

Chrome浏览器,V8引擎

JavaScript

ECMA-262,版本3

Mozilla Firefox,Gecko排版引擎,SpiderMonkey和Rhino

JavaScript 1.8.1

ECMA-262,版本3

Opera

一些JavaScript 1.5特性及一些JScript扩展

ECMA-262,版本3

KHTML排版引擎,KDE项目的Konqueror与苹果的Safari

JavaScript 1.5

ECMA-262,版本3

Adobe Acrobat

JavaScript 1.5

ECMA-262,版本3

OpenLaszlo Platform

JavaScript 1.4

ECMA-262,版本3

Max/MSP

JavaScript 1.5

ECMA-262,版本3

ANT Galio 3

JavaScript 1.5附带RMAI扩展

ECMA-262,版本3

JavaScript解析执行过程

JavaScript是一种ECMAScript方言,在许多程序中得以实现,特别是在网页浏览器。这些方言通常扩展了语言,或者标准库和相关API,例如W3C定义的DOM。这意味着以一种方言实现的程序不兼容于另一种方言的实现,除非程序使用了方言中的公共子集所具有的特性和API,解析引擎就是根据ECMAScript定义的语言标准来动态执行JavaScript字符串。

在整体上,JavaScript的解析执行分为两个步骤:

  1. 语法检查(编译阶段)
  2. 执行阶段

第一阶段:语法检查

语法检查也是JavaScript解析器的工作之一,包括词法分析和语法分析,过程大致如下:

一、词法分析:

JavaScript引擎先把JavaScript代码(字符串)的字符流按照ECMAScript标准转换为记号流

// js代码
a = (b - c);

转换为记号流:

NAME "a"
EQUALS
OPEN_PARENTHESIS
 NAME "b"
MINUS 
NAME "c"
CLOSE_PARENTHESIS
SEMICOLON

二、语法分析:

JavaScript语法分析器在经过词法分析后,将记号流按照ECMAScript标准把词法分析所产生的记号生成语法树。通俗地说就是把从程序中收集的信息存储到数据结构中,每取一个词法记号,就送入语法分析器进行分析。

语法分析不做的事:去掉注释,自动生成文档,提供错误位置(可以通过记录行号来提供)。
ECMAScript标准如下:

var,if,else,break,continue等是JavaScript的关键词
怎么样算是数字、怎么样算是字符串等等
定义了操作符(+,-,=)等操作符
定义了JavaScript的语法
定义了对表达式,语句等标准的处理算法,比如遇到==该如何处理

当语法检查正确无误之后,就可以进入执行阶段了。

第二阶段:执行阶段

执行阶段主要包括代码的预解析阶段和执行阶段。

一、预解析

第一步、创建执行上下文(Execution Context,可以把它理解为当前代码的执行环境),解析器将语法检查正确后生成的语法树复制到当前执行上下文中。

在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定this的指向。

执行上下文由三部分组成:

  • 变量对象(Variable Object):由VariableDeclaration(变量声明)、FunctionDeclaration(函数声明)、arguments(形参)构成。
  • 作用域链(Scope Chain):VariableObject + all parent scopes(变量对象以及所有父级作用域)构成。
  • this(thisValue):contentobject。this值在进入上下文阶段就确定了。一旦进入执行代码阶段,this值就不会变了。

注意:执行上下文和作用域是两个完全不同的概念。作用域是在编译阶段就确定下来的,执行上下文是在执行阶段才能够创建的。个人理解就是执行上下文的创建,让我们可以通过自身作用域,遍历自身的变量对象到全局对象,直到找到对应的变量,从而形成了作用域链。

this指向问题

this的指向是函数调用,即执行上下文创建时才能确定的,判断标准如下:

  1. new绑定:this指向新创建的对象
  2. call,apply绑定:this指向指定的对象
  3. 显式绑定:this指向调用该函数的对象
  4. 默认绑定:非严格模式下,this指向全局对象,严格模式,this为undefined
  5. 全局代码中的this永远指向全局变量

特例之箭头函数下的this

箭头函数是ES6的新语法,形式如下:

    /**
     * (参数) => { 函数体 }
     * 如果只有一个参数,则可以省略括号;
     * 如果函数体只有一个返回值,那么也可以省略{}及return
     */
    (b) => { return b * 2 }
    // 等价于
    b => b * 2

箭头函数中this指向规则与普通函数的规则不同,他的this指向规则为:

捕获其所在(即定义的位置)上下文的this值, 作为自己的this值

    function Person() {
      console.log(this)
      setTimeout(() => {
        console.log(this) // 这里this依然指向Person,因为箭头函数中的this指向定义它环境的this
      });
    }
    var p = new Person();

第二步、变量收集(变量提升)和分号补全等。

变量收集

重点注意收集变量这一功能,又名为变量提升,收集的正是变量对象的组成部分:

  • 函数的所有形参(如果我们是在函数执行上下文中)
  • 所有函数声明(FunctionDeclaration, FD)
  • 所有变量声明(var, VariableDeclaration)

想搞懂变量提升,var、let、const,函数等,我们一定要明确声明的创建、初始化和赋值之间的区别,首先说var声明的变量:

console.log(a);
var a = 1;
console.log(a);

运行结果为:

所以实际以上代码执行过程中分别发生了:

  1. 变量创建:在当前上下文中找到所有用 var 声明的变量,这里为a,并在此创建变量a;
  2. 变量初始化:把创建的变量初始化为undefined并提升;
  3. 变量赋值:把a赋值为1。

它等价于:

var a; // 创建 并 初始化为undefined 并 经过变量提升
console.log(a); // undefined
a = 1; // 赋值
console.log(a); // 1

再来看函数Function:

a();
console.log(a);
function a() {
  console.log(1);
}
a();
console.log(a);

运行结果为

可见函数的声明过程中

  1. 在当前上下文中找到所有用 function 声明的变量,这里为a,并在此创建变量a(与var相同);
  2. 将变量a直接初始化并赋值为ƒ a() {console.log(1);}并提升。

所以我们前后两次打印a都为ƒ a() {console.log(1);},并且前后都成功输出了1。它等价于

function a() {
  console.log(1);
}
a();
console.log(a);
a();
console.log(a);

接下来我们看let:我们只知道let不支持变量提升,那到底怎么不支持呢?

console.log(a);
let a = 1;
console.log(a);

直接报错了,说初始化前无法访问“a”,而没说a is not defined,这说明let定义的变量声明过程中,变量的创建也是得到提升了的,而初始化和赋值却没有。这种不支持在变量初始化前访问的现象,就是我们说的暂时性死区。

再说const,其实 const 和 let变现基本一致,只不过const a = 1;是直接创建了a并初始化为1,而没有赋值操作,也不支持赋值,所以一经定义,无法改变。 

tips: 提升时若遇到变量名有重复的情况,按以下优先级来确定:

function声明定义>函数参数>var声明的变量

分号补全

JS执行是需要分号的,它引导引擎逐句解析,但为什么以下语句却可以正常运行呢?

	console.log('a')
	console.log('b')

正是因为预解析阶段会进行分号补全操作。

列举几条自动加分号的规则:

  • 当有换行符(包括含有换行符的多行注释),并且下一个token没法跟前面的语法匹配时,会自动补分号。
  • 当有}时,如果缺少分号,会补分号。
  • 程序源代码结束时,如果缺少分号,会补分号。

不过若是以下的情况,必须得加上';',否则的话,会出现报错。

  • 如果一条语句以"(","{","/","+","-"开始,当前一条语句没有用“;”结尾的话,就会与前一条语句合在一起解释。

其实只要做好分隔工作,那么所有js代码就可以写在同一行,例如压缩后的js脚本。

二、执行

代码经过预解析操作也就是执行上下文创建完毕后,就正式进入执行阶段了,这个时候,会完成变量赋值(内存分配),函数引用,以及其他代码的执行操作。

创建的执行环境大致可以分为三种:

  1. 全局执行环境:JavaScript代码运行起来会首先进入该环境
  2. 函数执行环境:当函数被调用时,会进入当前函数的环境
  3. 块级上下文

  4. eval(极不常用,忽略)

一般情况下我们只研究JavaScript的全局作用域和函数作用域,JavaScript引擎会以栈的方式处理它们,当开始执行JavaScript代码时,会先创建一个全局上下文,压入栈底,每当执行一个函数,就会创建一个函数执行上下文,依次压入栈,这个栈我们称为函数调用栈。栈顶就是当前正在执行的执行上下文,当函数执行完,该执行上下文弹出栈。栈底永远是全局上下文,直到关闭该页面,才会弹出全局上下文。

tips:尽量减少使用全局变量,因为全局变量对应着主函数,除非关闭该页面,否则主函数一直不会弹出调用栈,使得全局变量在关闭页面前一直不会被释放。

内存分配

JavaScript属于弱类型语言,各变量在执行前无法确定其类型,也就无法为其合理分配内存,只能在运行时为其动态分配内存,导致JavaScript内存的使用率不会很高,这也是它执行效率不如java等强类型语言的又一大原因。

变量对象(VO variable Object)与活动对象(AO active Object)

变量对象(VO)存储的就是经过变量提升后的arguments参数,var声明的变量以及函数声明,在未进入执行阶段时,VO中的属性都不能访问。但在进入执行阶段时,变量对象会被赋值从而转换为了活动对象(AO),里面的属性可以被访问。VO和AO其实都是一个对象,只是处于执行上下文的不同生命周期。

在VO转化为AO的过程中,因为发生了变量对象的赋值,这时每个变量的类型才被明确,内存才被分配。

JavaScript有6种数据类型(暂且不论symbol):Number,Boolean,String,Null,Undefined,Object,分为两大类别

  1. 基本数据类型:Number,Boolean,String,Null,Undefined,存储于栈内存中。
  2. 引用数据类型:Object(Object数据类型由Function,Object,Array组成),存储于堆内存中。

关于不同类型的数据是如何存储在内存的,参考下图:

我们可以看到,栈中储存的引用类型数据并不是数据本身,而是其在堆内存中的地址,来看一个例子

var arr = [1,2,3];
console.log(arr);
var arr1 = arr;
arr1[1] = 4;
console.log(arr);

这就是因为arr和arr1保存的指针指向了同一处内存地址,所以改变arr1,arr也会改变,而基本类型的值就不会这样。

tips:

  1. 栈和堆的区别:
      一、堆栈空间分配区别:
      1、栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈;
      2、堆(操作系统): 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收,分配方式类似于链表。
      二、堆栈缓存方式区别:
      1、栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放;
      2、堆是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对低一些。
      三、堆栈数据结构区别:
      堆(数据结构):堆可以被看成是一棵树,如:堆排序;
      栈(数据结构):一种先进后出的数据结构。
  2. null为空对象指针,undefined为未初始化

JavaScript的单线程执行和事件循环(Event Loop)

单线程

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。这与它的用途有关,作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。

比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

事件循环

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

  • 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

具体来说,异步执行的运行机制如下。

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断循环上面的第三步。

不同的异步任务又被分为两类:微任务(micro task)和宏任务(macro task),举几个例子

宏任务:

  • setInterval()
  • setTimeout()

微任务

  • new Promise()
  • Vue.nextTick()

前面我们介绍过,在一个事件循环中,异步事件返回结果后会被放到任务队列中,其实这个任务队列又分为宏任务队列和微任务队列,异步事件会根据其类型分别放到各自的队列中去。在当前执行栈为空的时候,主线程会先查看微任务队列是否有事件存在,如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后再去执行宏任务队列中的事件。

总结一下:

1、JS是单线程语言,包括同步任务、异步任务、异步任务又包括宏观任务和微观任务

2、执行顺序:同步任务——>微观任务——>宏观任务

JavaScript引擎中的垃圾回收机制

内存是有限的,所以分配的内存必须得在适当的时机回收以供后继使用,JavaScript引擎会帮助我们自动回收的,不需要我们操心。

现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数。

1、标记清除

  这是javascript中最常用的垃圾回收方式。当变量进入执行环境时,就标记这个变量为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。当变量离开环境时,则将其标记为“离开环境”。
  垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器会定时查找并销毁那些带标记的值,回收他们所占用的内存空间,完成内存清除工作。


2、引用计数

  另一种不太常见的垃圾回收策略是引用计数。引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是1。相反,如果包含对这个值引用的变量又取得了另外一个值(即变量改变),则这个值的引用次数就减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。这样,垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占的内存。

总结

本文到这里就结束了,那么总结一下JavaScript引擎的工作流程——

  1. JavaScript引擎先编译代码,进行词法分析(Lexical analysis),然后将代码分解成词元(token);
  2. 对词元进行语法分析(parsing),然后将代码整理成语法树(syntax tree);
  3. 使用翻译器,将代码转为字节码(bytecode),使用字节码解释器(bytecode interpreter),将字节码转为机器码;
  4. 当开始执行JavaScript代码时,先预解析其可执行码(变量提升,分号补全等),然后执行执行栈中的同步代码;
  5. 当同步代码执行完毕后,接下来循环执行微任务队列中的事件(如果有的话);
  6. 当微任务队列中的事件执行完毕后,再循环执行宏任务队列中的事件,直至所有代码执行完毕。

Tips:JavaScript解析过程中,如遇错误就会跳出当前代码块,直接执行下一个script代码块。所以,虽然一个script内的代码遇到错误不会继续执行,但是却不会对下一个script内的代码造成影响。

本文参考:《JavaScript引擎运行原理解析》、《JavaScript引擎浅析

  • 1
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值