# JS 代码的执行原理

JS 代码的执行原理

  • 我们知道,浏览器内核是由两部分组成的,以 webkit 为例:
    • WebCore 负责 HTML 解析、布局、渲染等等相关的工作;
    • JavaScriptCore 解析、执行 JavaScript 代码;另外一个强大的 JavaScript 引擎就是 V8 引擎。

1660112626791

V8 引擎的执行原理

  • 官方对 V8 引擎的定义:
    • V8 是用 C ++编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于 Chrome 和 Node.js 等。
    • 它实现 ECMAScript 和 WebAssembly,并在 Windows 7 或更高版本,macOS 10.12+和使用 x64,IA-32,ARM 或 MIPS 处理器的 Linux 系统上运行。
    • V8 可以独立运行,也可以嵌入到任何 C ++应用程序中。

1660112808469

V8 引擎的架构

  • V8 引擎本身的源码非常复杂,大概有超过 100w 行 C++ 代码,通过了解它的架构,我们可以知道它是如何对 JavaScript 执行的:

  • Parse 模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码;

  • Ignition 是一个解释器,会将 AST 转换成 ByteCode(字节码)

    • 同时会收集 TurboFan 优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
    • 如果函数只调用一次,Ignition 会解释执行 ByteCode;
    • Ignition 的 V8 官方文档:https://v8.dev/blog/ignition-interpreter
  • TurboFan 是一个编译器,可以将字节码编译为 CPU 可以直接执行的机器码;

  • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过 TurboFan 转换成优化的机器码,提高代码的执行性能;

  • 但是,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如 sum 函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;

  • TurboFan 的 V8 官方文档:https://v8.dev/blog/turbofan-jit

1660113245614

ECMAScript 版本说明

  • 在 ECMA 早期的版本中( ECMAScript3 ),代码的执行流程的术语和 ECMAScript5 以及之后的术语会有所区别:

    • 目前网上大多数流行的说法都是基于 ECMAScript3 版本的解析 ,并且在面试时问到的大多数都是 ECMAScript3 的版本内容。
    • 但是 ECMAScript3 终将过去, ECMAScript5 必然会成为主流 ,所以最好也理解 ECMAScript5 甚至包括 ECMAScript6 以及更好版本的内容;
    • 事实上在 TC39 的最新描述中,和 ECMAScript5 之后的版本又出现了一定的差异;
  • 通过 ECMAScript3 中的概念学习 JavaScript 执行原理、作用域、作用域链、闭包 等概念;

  • 通过 ECMAScript5 中的概念学习 块级作用域、 let 、 const 等概念;

JS 代码的执行过程

  • 假如我们有下面一段代码,它在 JavaScript 中是如何被执行的呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var message = 'Global Message'

function foo(num) {
var message = 'Foo Message'
var age = 18
var height = 1.88
console.log('foo function')
}

foo(123)

var num1 = 10
var num2 = 20
var result = num1 + num2
console.log(result)

JS 代码的执行过程

初始化全局对象

  • js 引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
  • 该对象 所有的作用域(scope)都可以访问;
  • 里面会包含 Date、Array、String、Number、setTimeout、setInterval 等等;
  • 其中还有一个 window 属性指向自己;

1674136596767

执行上下文

  • js 引擎内部有一个执行上下文栈(Execution Context Stack,简称 ECS),它是用于执行代码的调用栈
  • 那么现在它要执行谁呢?执行的是全局的代码块:
  • 全局的代码块为了执行会构建一个 Global Execution Context(GEC)(全局执行上下文);
  • GEC 会 被放入到 ECS 中 执行;
  • GEC 被放入到 ECS 中里面包含两部分内容:
  • 第一部分:在代码执行前,在 parser 转成 AST 的过程中,会将全局定义的变量、函数等加入到 GlobalObject 中,但是并不会赋值;
    - 这个过程也称之为变量的作用域提升(hoisting)
  • 第二部分:在代码执行中,对变量赋值,或者执行其他的函数;

1674136652927

认识 VO 对象

  • 每一个执行上下文会关联一个 VO(Variable Object,变量对象),变量和函数声明会被添加到这个 VO 对象中。

1674136723774

  • 当全局代码被执行的时候,VO 就是 GO 对象了

1674136730223

全局代码的执行过程

执行前

1674136785563

执行后

1674136799002

函数的执行过程

  • 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称 FEC),并且压入到 EC Stack 中。
    因为每个执行上下文都会关联一个 VO,那么函数执行上下文关联的 VO 是什么呢?
  • 当进入一个函数执行上下文时,会创建一个 AO 对象(Activation Object);
  • 这个 AO 对象会使用 arguments 作为初始化,并且初始值是传入的参数;
  • 这个 AO 对象会作为执行上下文的 VO 来存放变量的初始化;

1674136853384

执行前

1674136882431

执行后

1674136895779

作用域与作用域链

作用域与作用域链

  • 当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
  • 作用域链是一个对象列表,用于变量标识符的求值;
  • 当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;

1674137043066

作用域链面试题

1

JavaScript 内存管理

认识内存管理

  • 不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存:

  • 不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:

    • 第一步:分配申请你需要的内存(申请);
    • 第二步:使用分配的内存(存放一些东西,比如对象等);
    • 第三步:不需要使用时,对其进行释放;
  • 不同的编程语言对于第一步和第三步会有不同的实现:

    • 手动管理内存:比如 C、C++,包括早期的 OC,都是需要手动来管理内存的申请和释放的(malloc 和 free 函数);
    • 自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,它们有自动帮助我们管理内存;
  • 对于开发者来说,JavaScript 的内存管理是自动的、无形的。

  • 我们创建的原始值、对象、函数……这一切都会占用内存;

  • 但是我们并不需要手动来对它们进行管理,JavaScript 引擎会帮助我们处理好它;

JavaScript 的内存管理

  • JavaScript 会在定义数据时为我们分配内存。

  • JS 对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配;

  • JS 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;

1660128151953

JavaScript 的垃圾回收

  • 因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。
  • 在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如 free 函数:
    • 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
    • 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露;
  • 所以大部分现代的编程语言都是有自己的垃圾回收机制:
    • 垃圾回收的英文是 Garbage Collection,简称 GC;
    • 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
    • 而我们的语言运行环境,比如 Java 的运行环境 JVM,JavaScript 的运行环境 js 引擎都会内存垃圾回收器;
    • 垃圾回收器我们也会简称为 GC,所以在很多地方你看到 GC 其实指的是垃圾回收器;
  • 但是这里又出现了另外一个很关键的问题:GC 怎么知道哪些对象是不再使用的呢?
    • 这里就要用到 GC 的实现以及对应的算法;

常见的垃圾回收算法

  • 01 引用计数:
    • 当一个对象有一个引用指向它时,那么这个对象的引用就+1;
    • 当一个对象的引用为 0 时,这个对象就可以被销毁掉;
    • 这个算法有一个很大的弊端就是会产生循环引用;

1660128423769

  • 02 标记清除:
    • 标记清除的核心思路是可达性(Reachability)
    • 这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于哪些没有引用到的对象,就认为是不可用的对象;
    • 这个算法可以很好的解决循环引用的问题;

1660128457208

其他算法优化补充

  • JS 引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于 V8 引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。
  • 标记整理(Mark-Compact) 和“标记-清除”相似;
    • 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;
  • 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。
    • 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
    • 那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
  • 增量收集(Incremental collection)
    • 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
    • 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
  • 闲时收集(Idle-time collection)
    • 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

V8 引擎的详细内存图

1660128544663