JS代码执行原理
JS 代码的执行原理
- 我们知道,浏览器内核是由两部分组成的,以 webkit 为例:
- WebCore 负责 HTML 解析、布局、渲染等等相关的工作;
- JavaScriptCore 解析、执行 JavaScript 代码;另外一个强大的 JavaScript 引擎就是 V8 引擎。
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 ++应用程序中。
V8 引擎的架构
V8 引擎本身的源码非常复杂,大概有超过 100w 行 C++ 代码,通过了解它的架构,我们可以知道它是如何对 JavaScript 执行的:
Parse 模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码;
- 如果函数没有被调用,那么是不会被转换成 AST 的;
- Parse 的 V8 官方文档:https://v8.dev/blog/scanner
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
ECMAScript 版本说明
在 ECMA 早期的版本中( ECMAScript3 ),代码的执行流程的术语和 ECMAScript5 以及之后的术语会有所区别:
- 目前网上大多数流行的说法都是基于 ECMAScript3 版本的解析 ,并且在面试时问到的大多数都是 ECMAScript3 的版本内容。
- 但是 ECMAScript3 终将过去, ECMAScript5 必然会成为主流 ,所以最好也理解 ECMAScript5 甚至包括 ECMAScript6 以及更好版本的内容;
- 事实上在 TC39 的最新描述中,和 ECMAScript5 之后的版本又出现了一定的差异;
通过 ECMAScript3 中的概念学习 JavaScript 执行原理、作用域、作用域链、闭包 等概念;
通过 ECMAScript5 中的概念学习 块级作用域、 let 、 const 等概念;
JS 代码的执行过程
- 假如我们有下面一段代码,它在 JavaScript 中是如何被执行的呢?
1 | var message = 'Global Message' |
JS 代码的执行过程
初始化全局对象
- js 引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
- 该对象 所有的作用域(scope)都可以访问;
- 里面会包含 Date、Array、String、Number、setTimeout、setInterval 等等;
- 其中还有一个 window 属性指向自己;
执行上下文
- js 引擎内部有一个执行上下文栈(Execution Context Stack,简称 ECS),它是用于执行代码的调用栈。
- 那么现在它要执行谁呢?执行的是全局的代码块:
- 全局的代码块为了执行会构建一个 Global Execution Context(GEC)(全局执行上下文);
- GEC 会 被放入到 ECS 中 执行;
- GEC 被放入到 ECS 中里面包含两部分内容:
- 第一部分:在代码执行前,在 parser 转成 AST 的过程中,会将全局定义的变量、函数等加入到 GlobalObject 中,但是并不会赋值;
- 这个过程也称之为变量的作用域提升(hoisting) - 第二部分:在代码执行中,对变量赋值,或者执行其他的函数;
认识 VO 对象
- 每一个执行上下文会关联一个 VO(Variable Object,变量对象),变量和函数声明会被添加到这个 VO 对象中。
- 当全局代码被执行的时候,VO 就是 GO 对象了
全局代码的执行过程
执行前
执行后
函数的执行过程
- 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称 FEC),并且压入到 EC Stack 中。
因为每个执行上下文都会关联一个 VO,那么函数执行上下文关联的 VO 是什么呢? - 当进入一个函数执行上下文时,会创建一个 AO 对象(Activation Object);
- 这个 AO 对象会使用 arguments 作为初始化,并且初始值是传入的参数;
- 这个 AO 对象会作为执行上下文的 VO 来存放变量的初始化;
执行前
执行后
作用域与作用域链
作用域与作用域链
- 当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
- 作用域链是一个对象列表,用于变量标识符的求值;
- 当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;
作用域链面试题
1 |
JavaScript 内存管理
认识内存管理
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存:
不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
- 第一步:分配申请你需要的内存(申请);
- 第二步:使用分配的内存(存放一些东西,比如对象等);
- 第三步:不需要使用时,对其进行释放;
不同的编程语言对于第一步和第三步会有不同的实现:
- 手动管理内存:比如 C、C++,包括早期的 OC,都是需要手动来管理内存的申请和释放的(malloc 和 free 函数);
- 自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,它们有自动帮助我们管理内存;
对于开发者来说,JavaScript 的内存管理是自动的、无形的。
我们创建的原始值、对象、函数……这一切都会占用内存;
但是我们并不需要手动来对它们进行管理,JavaScript 引擎会帮助我们处理好它;
JavaScript 的内存管理
JavaScript 会在定义数据时为我们分配内存。
JS 对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配;
JS 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;
JavaScript 的垃圾回收
- 因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。
- 在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如 free 函数:
- 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
- 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露;
- 所以大部分现代的编程语言都是有自己的垃圾回收机制:
- 垃圾回收的英文是 Garbage Collection,简称 GC;
- 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
- 而我们的语言运行环境,比如 Java 的运行环境 JVM,JavaScript 的运行环境 js 引擎都会内存垃圾回收器;
- 垃圾回收器我们也会简称为 GC,所以在很多地方你看到 GC 其实指的是垃圾回收器;
- 但是这里又出现了另外一个很关键的问题:GC 怎么知道哪些对象是不再使用的呢?
- 这里就要用到 GC 的实现以及对应的算法;
常见的垃圾回收算法
- 01 引用计数:
- 当一个对象有一个引用指向它时,那么这个对象的引用就+1;
- 当一个对象的引用为 0 时,这个对象就可以被销毁掉;
- 这个算法有一个很大的弊端就是会产生循环引用;
- 02 标记清除:
- 标记清除的核心思路是可达性(Reachability)
- 这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于哪些没有引用到的对象,就认为是不可用的对象;
- 这个算法可以很好的解决循环引用的问题;
其他算法优化补充
- JS 引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于 V8 引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。
- 标记整理(Mark-Compact) 和“标记-清除”相似;
- 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;
- 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。
- 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
- 那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
- 增量收集(Incremental collection)
- 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
- 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
- 闲时收集(Idle-time collection)
- 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
V8 引擎的详细内存图
- 感谢你赐予我前进的力量