JavaScript 作用域,引擎工作流程及优化

最近一直在写 React,抽空对一些 Javascript 概念及引擎工作方式学习总结一下。

作用域

静态作用域与动态作用域

静态作用域通常称为词法作用域,采用词法作用域的变量叫词法变量,有一个编译时静态确定的作用域。(定义在词法阶段的作用域)。词法变量的作用域可以是一个函数或一段代码,该变量在这段代码区域内可见(visibility);在这段区域以外该变量不可见(或无法访问)。词法作用域里,取变量的值时,会检查函数定义时的文本环境,捕捉函数定义时对该变量的绑定。JavaScript, C/C++ 等都使用的静态作用域规则
如下,变量 a 只能在 foo 里被访问到。

function foo() {
    var a = 5;
}

动态作用域则是在运行过程中确定的作用域。采用动态作用域的变量叫做动态变量。只要程序正在执行定义了动态变量的代码段,那么在这段时间内,该变量一直存在;代码段执行结束,该变量便消失。

变量作用域

ES5 及之前,变量 (var) 都是函数级作用域。
ES6 引入了 letconst 关键字,支持块级作用域。

with/try-catch 变量也是块级作用域。
IIFE 匿名函数可以隐藏变量,限制其生命周期。

运行时作用域与执行上下文

JavaScript 在运行过程中涉及到的作用域有3种:

1- 全局作用域 (Global Scope) : 代码开始运行的默认环境
2- 局部作用域 (Local Scope) : 代码进入一个 Javascript 函数
3- Eval 作用域

当 JavaScript 代码执行的时候,引擎会创建不同的执行上下文,这些执行上下文就构成了一个执行上下文栈(Execution context stack,ECS)。

全局执行上下文永远都在栈底,当前正在执行的函数在栈顶。

解释器创建执行上下文的步骤:

1- 创建阶段 创建 Scope Chain, 创建 Variable Object, Activation Object, 设置 this 的值。
2- 激活/代码执行阶段 设置变量的值,函数的引用,然后解释执行代码。

执行上下文对象有三个重要的属性:

  • 变量对象 (Variable Object 简称 VO)
  • 作用域链 (Scope Chain)
  • "this"

VO 是与执行上下文相关的作用域,包含:

  • arguments object
  • 函数声明
  • 变量声明

js_vo

这就能够解释 JavaScript 中的变量提升现象以及函数提升优先级会比变量提升优先级高。

如上所述, Activation Object 进入函数上下文时被创建,包括:

callee: 指向当前函数的引用
length: 真正传递的参数个数
properties-indexes: 参数值

vo_ao

VO 和 AO 的关系可以理解为,VO 在不同的 Execution Context 中会有不同的表现:当在 Global Execution Context 中,直接使用的 VO;但是,在函数 Execution Context 中,AO 就会被创建。

作用域链

JavaScript 中有两种变量传递的方式

通过函数调用,执行上下文的栈传递变量

函数每调用一次,就需要给它的参数和变量准备新的存储空间,就会创建一个新的环境将(变量和参数的)标识符合变量做映射。对于递归的情况,执行上下文,即通过环境的引用是在栈中进行管理的。这里的栈对应了调用栈。

作用域链

有一个内部属性 [[scope]] 来记录函数的作用域,在调用时,会为这个函数所在的新作用域创建一个环境,这个环境有一个外层域,它通过 [[scope]] 指向了外部作用域。所以,作用域链以当前域作为起点,在全局环境里终结。

作用域链,是由当前环境与上层环境的一系列变量组成,保证了当前执行环境对符合访问权限的变量和函数的有序访问。

这就是 Javascript 语言特有的"作用域链"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

作用域链是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端始终是当前执行的代码所在环境的变量对象。而前面我们已经讲了变量对象的创建过程。作用域链的下一个变量对象来自包含环境即外部环境,这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

不同函数中的 "this"

  • 函数调用 this -> window
  • 方法调用 this -> caller
  • 构造方法 this -> 构造出来的对象实例
  • 间接调用 (call, apply) this -> 第一个参数
  • Bound Function this -> bind(obj) 的 obj
  • 箭头函数 this -> 外部函数的 this 值

JS 引擎

概述

JavaScript 是脚本语言,只有 runtime,没有 buildtime,需要引擎进行代码解析,在最短时间内编译除最优化的代码。

当前流行引擎:

  • V8 (Google Chrome)
  • JavaScriptCore (Apple)
  • SpiderMonkey (Mozilla)
  • Chakra (Microsoft Edge)

编译加执行/编译加解释执行

比较下编译执行以及编译加解释执行的大致流程:

  • 编译加执行流程:

源码(字符流) --> 词法分析,生成 token 流 --> 语法分析, 生成语法树/抽象语法树 --> 语义分析, 标注了属性的抽象语法树 --> 代码生成,生成目标代码 --> 操作系统/硬件,执行结果

  • 编译加解释执行流程:

源码 --> 词法分析,生成 token 流 --> 语法分析, 生成语法树/抽象语法树 --> 语义分析, 标注了属性的抽象语法树 --> 抽象语法树解释器,执行结果。

解释器也可以做更多操作,类似单词流解释器,直接对 token 流解释执行,或者词法,语法,类型检查字符流解释器等等。

引擎工作流程

一般来说,所有 JavaScript 引擎都有一个包含解释器和优化编译器的处理流程。解释器可以快速生成未优化的字节码,而优化编译器需要更长时间,来生成高度优化的机器码。

一般我们称这个一层层处理源代码的流程称为分层编译。

不同的引擎在生成 AST 之后处理优化的流程会有很大不同,下面会主要对比下 Chrome 的 V8 和 Apple 的 JavaScriptCore :

  • V8

Chrome 的流程与上面描述的流程几乎一致,名称上,V8 引擎中的解释器被称作 Ignition,它负责生成并执行字节码。当一个函数变得 hot,例如它经常被调用,生成的字节码和分析数据则会被传给 TurboFan —— 优化编译器,它会依据分析数据生成高度优化的机器码。

  • JavaScriptCore

Apple 的 JavaScript 引擎,被用于 Safari 和 React Native 两个项目中,它通过三种不同的优化编译器使效果达到极致。低级解释器 LLInt 将代码解释后传递给 Baseline 编译器,而(经过 Baseline 编译器)优化后的代码便传给了 DFG 编译器,(在 DFG 编译器处理后)结果最终传给了 FTL 编译器进行处理。

为什么有些引擎会拥有更多的优化编译器呢?这些引擎选择添加具有不同耗时/效率特性的多个优化编译器,以更高的复杂性为代价来对这些折中点进行更细粒度的控制。

JavaScriptCore 的分层编译及并发编译

jsc_compile_pipeline

OSR(on-stack replacement 堆栈替换),这个技术可以将执行转移到任何 statement 的地方。OSR 可以不管执行引擎是什么都在去解析字节码状态,并且能够重构它去让其它引擎继续执行,OSR entry 是进入更高层优化,OSR exit 是降至低层。LLInt 到 Baseline JIT 时 OSR 只需要跳转到相应的机器代码地址就行,因为 Baseline JIT 每个指令边界上的所有变量的表示和 LLInt 是一样的。进入 DFG JIT 就会复杂些,DFG 通过将函数控制流图当作多个进入点,一个是函数函数启动入口一个是循环的入口。

ftl_dfg_jit

上图描述了 DFG/FTL JIT(Just In Time) 的 pipeline

DFG(data flow graph) 数据流图 JIT, 一种推测优化技术。开始会对一个类型做出一个性能好的假设,先编译一个版本,若后面发现假设不对就跳回原先代码,称为 speculation failure。是一个并发编译器, pipeline 每部分都是同时运行。

FTL 实际上是 DFG backend 的替换,会先在 DFG 的函数表示转换为 SSA(静态单一指派格式)上做些特性优化,接着把 DFG IR 转换成 FTL 里用到的 B3 的 IR,最后生成机器码,一步步消除 Javascript 的动态特性。

con_compile

代码在 LLInt,Baseline JIT 和 DFG JIT 运行一段时间才会调用 FTL。 FTL 在并发线程中会从它们那收集分析信息。short-running 代码是不会导致 FTL 编译的,一般超过 10 ms 的函数会触发 FTL 编译。

FTL 通过并发编译减小对启动速度的影响。

JS 引擎对属性访问的优化

观察 JavaScript 程序,访问属性是最常见的一个操作。使得 JavaScript 引擎能够快速获取属性便至关重要。

const object = {
 foo: 'bar',
 baz: 'qux',
};

// Here, we’re accessing the property `foo` on `object`:
doSomething(object.foo);
//          ^^^^^^^^^^
Shapes

在 JavaScript 程序中,多个对象具有相同的键值属性是非常常见的。这些对象都具有相同的形状。

function logX(object) {
 console.log(object.x);
 //          ^^^^^^^^
}

const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };

logX(object1);
logX(object2);

考虑到这一点,JavaScript 引擎可以根据对象的形状来优化对象的属性获取。它是这么实现的来保证相同 shape 形状键值属性信息只存一次。

不同的引擎对形状的称呼也不相同:

  • V8 -> Maps
  • JavaScriptCore -> Structures
  • SpiderMonkey -> Shapes
transition 链

如果你有一个具有特定形状的对象,但你又向它添加了一个属性,此时会发生什么? JavaScript 引擎是如何找到这个新形状的?

const object = {};
object.x = 5;
object.y = 6;

在 JavaScript 引擎中,shapes 的表现形式被称作 transition 链。以下展示一个示例:

我们甚至不需要为每个 Shape 存储完整的属性表。相反,每个 Shape 只需要知道它引入的新属性。 例如在此例中,我们不必在最后一个 Shape 中存储关于 'x' 的信息,因为它可以在更早的链上被找到。要做到这一点,每一个 Shape 都会与其之前的 Shape 相连:

但是,如果不能只创建一个 transition 链呢?例如,如果你有两个空对象,并且你为每个对象都添加了一个不同的属性?

const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;

在这种情况下我们便必须进行分支操作,此时我们最终会得到一个 transition 树 而不是 transition 链:

在这里,我们创建一个空对象 a,然后为它添加一个属性 'x'。 我们最终得到一个包含单个值的 JSObject,以及两个 Shapes:空 Shape 和仅包含属性 x 的 Shape。

第二个例子也是从一个空对象 b 开始的,但之后被添加了一个不同的属性 'y'。我们最终形成两个 shape 链,总共是三个 shape。

这是否意味着我们总是需要从空 shape 开始呢? 并不是。引擎对已包含属性的对象字面量会应用一些优化。比方说,我们要么从空对象字面量开始添加 x 属性,要么有一个已经包含属性 x 的对象字面量:

const object1 = {};
object1.x = 5;
const object2 = { x: 6 };

在第一个例子中,我们从空 shape 开始,然后转向包含 x 的 shape,这正如我们我们之前所见。

在 object2 一例中,直接生成具有属性 x 的对象是有意义的,而不是从空对象开始然后进行 transition 连接。

包含属性 'x' 的对象字面量从包含 'x' 的 shape 开始,可以有效地跳过空的 shape。V8 和 SpiderMonkey (至少)正是这么做的。这种优化缩短了 transition 链,并使得从字面量构造对象更加高效。

Benedikt 的博文 surprising polymorphism in React applications 讨论了这些微妙之处是如何影响实际性能的。

ICS (Inline Caches)

JavaScript 引擎利用 ICs 来记忆去哪里寻找对象属性的信息,以减少昂贵的查找次数。

这里有一个函数 getX,它接受一个对象并从中取出属性 x 的值:

function getX(o) {
 return o.x;
}

如果我们在 JSC 中执行这个函数,它会生成如下字节码:

后续运行, IC 只需要对比 shape,若相同,直接从 offset 加载值,跳过属性信息查找过程。

高效存储数组

对于数组来说,存储属性诸如数组索引等是非常常见的。这些属性的值被称为数组元素。存储每个数组中的每个数组元素的属性特性(property attributes)将是一种很浪费的存储方式。相反,由于数组索引默认属性是可写的、可枚举的并且可以配置的,JavaScript 引擎利用这一点,将数组元素与其他命名属性分开存储。

每个数组都有一个单独的 elements backing store,其中包含所有数组索引的属性值。JavaScript 引擎不必为数组元素存储任何属性特性,因为它们通常都是可写的,可枚举的以及可配置的。

如果使用 Object.defineProperty() 修改属性, Javascript 引擎会将全部的 elements backing store 表示为一个由数组下标映射到属性特性的字典。即使只有一个数组元素具有非默认属性,整个数组的 backing store 处理也会进入这种缓慢而低效的模式。 避免在数组索引上使用 Object.defineProperty()

-- EOF --
以上就是这篇文章全部内容,大部分内容来源及参考于:

  1. 从 JavaScript 作用域说开去
  2. 深入剖析 JavaScriptCore
  3. 深入了解JavaScript引擎精华
  4. 引擎V8推出“并发标记”,可节省60%-70%的GC时间
  5. HotSpot是较新的Java虚拟机技术,用来代替JIT技术,那么HotSpot和JIT是共存的吗? - RednaxelaFX的回答 - 知乎
  6. V8 Ignition:JS 引擎与字节码的不解之缘

个人联系方式详见关于

Comments
Write a Comment