8 Likes
从 JavaScript 编译原理到作用域(链)及闭包

从 JavaScript 编译原理到作用域(链)及闭包

63 PV8 LikesJavaScript
虽然作用域的相关知识是 JavaScript 的基础, 但要彻底理解必须要从原理入手. 从面试角度来讲, 词法/动态作用域、作用域(链)、变量/函数提升、闭包、垃圾回收 实属一类题目, 打通这几个概念并熟练掌握, 面试基本就不用担心这一块了. 这篇文章是对《JavaScript 高级程序设计 (第三版)》第四章, 同样也是 《你不知道的 JavaScript (上卷)》第一部分的学习和总结.

编译器和解释器

前两个章节先讲编译器和解释器, 以及 V8 是如何执行一段 JavaScript 代码的.

之所以存在编译器和解释器, 是因为机器不能直接理解我们所写的代码, 所以在执行程序之前, 需要将我们所写的代码翻译成机器能读懂的机器语言. 按语言的执行流程, 可以把语言划分为编译型语言和解释型语言.

编译型语言在程序执行之前, 需要经过编译器的编译过程, 并且编译之后会直接保留机器能读懂的二进制文件, 这样每次运行程序时, 都可以直接运行该二进制文件, 而不需要再次重新编译了. 而由解释型语言编写的程序, 在每次运行时都需要通过解释器对程序进行动态解释和执行.

在编译型语言的编译过程中, 编译器首先会依次对源代码进行词法分析, 语法分析, 生成抽象语法树(AST, Abstract Syntax Tree), 然后是优化代码, 最后再生成处理器能够理解的机器码. 如果编译成功, 将会生成一个可执行的文件. 但如果编译过程发生了语法或者其他的错误, 那么编译器就会抛出异常, 最后的二进制文件也不会生成成功.

在解释型语言的解释过程中, 同样解释器也会对源代码进行词法分析, 语法分析, 并生成抽象语法树(AST), 不过它会再基于抽象语法树生成字节码, 最后再根据字节码来执行程序, 输出结果.

编译型语言和解释型语言
编译型语言和解释型语言

V8 是如何执行一段 JavaScript 代码的

从图中可以清楚地看到, V8 在执行过程中既有解释器 Ignition, 又有编译器 TurboFan.

V8 执行一段代码流程图
V8 执行一段代码流程图

生成 AST 和 执行上下文

高级语言是开发者可以理解的语言, 但是让编译器或者解释器来理解就非常困难了. 对于编译器或者解释器来说, 它们可以理解的就是 AST 了. 所以无论你使用的是解释型语言还是编译型语言, 在编译过程中, 它们都会生成一个 AST. 比如下面这段代码的抽象语法树, 可以用 javascript-ast 这个网站来生成.

const sayHi = () => { return "Hi"; }; const MAX_AGE = 18; sayHi();

抽象语法树的结构
抽象语法树的结构

AST 本质是代码的结构化的表示, 编译器或者解释器后续的工作都需要依赖于 AST, 而不是源代码. 我们常用 Babel 进行 ES6 转 ES5, 它的原理就是先将 ES6 源码转换为 AST, 然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST, 最后利用 ES5 的 AST 生成 JavaScript 源代码. 此外, ESLint 的检测流程也是需要将源码转换为 AST, 然后再利用 AST 来检查代码规范化的问题.

生成 AST 通常有两个步骤:

首先第一步是分词(tokenize), 又称词法分析, 此过程将源代码分解成 词法单元(token), token 指的是语法上不可能再分的, 最小的单个字符或字符串. 如代码 const firstName = 'Yancey' 会被分解成 const, firstName, =, 'Yancey', 空格是否会被当成词法单元, 取决于空格对这门语言的意义. 这里推荐一个网站 Parser 可以用来解析 JavaScript 的源代码. 对于 const firstName = 'Yancey' 这段代码, 分词结构如下:

[ { type: "Keyword", value: "const", }, { type: "Identifier", value: "firstName", }, { type: "Punctuator", value: "=", }, { type: "String", value: "'Yancey'", }, ];

第二步是解析(Parsing), 又称语法分析. 这个过程将词法单元流转换成一棵抽象语法树. 语法分析会根据 ECMAScript 的标准来解析成 AST, 比如你写了 const new = 'Yancey', 就会报错 Uncaught SyntaxError: Unexpected token new. 有了 AST 后, 那接下来 V8 就会生成该段代码的执行上下文.

生成字节码

有了 AST 和执行上下文后, 就需要解释器(Ignition) 登场了, 它会根据 AST 生成字节码, 并解释执行字节码. 字节码就是介于 AST 和机器码之间的一种代码. 但是与特定类型的机器码无关, 字节码需要通过解释器将其转换为机器码后才能执行.

其实一开始 V8 并没有字节码, 而是直接将 AST 转换为机器码, 由于执行机器码的效率是非常高效的, 所以这种方式在发布后的一段时间内运行效果是非常好的. 但是随着 Chrome 在手机上的广泛普及, 特别是运行在 512M 内存的手机上, 内存占用问题也暴露出来了, 因为 V8 需要消耗大量的内存来存放转换后的机器码. 为了解决内存占用问题, V8 团队大幅重构了引擎架构, 引入字节码.

从图中可以看出, 机器码所占用的空间远远超过了字节码, 所以使用字节码可以减少系统的内存使用.

字节码和机器码占用空间对比
字节码和机器码占用空间对比

代码执行

通常, 如果有一段第一次执行的字节码, 解释器 Ignition 会逐条解释执行. 到了这里, 相信你已经发现了, 解释器 Ignition 除了负责生成字节码之外, 它还有另外一个作用, 就是解释执行字节码. 在 Ignition 执行字节码的过程中, 如果发现有热点代码(HotSpot), 比如一段代码被重复执行多次. 那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码, 然后当再次执行这段被优化的代码时, 只需要执行编译后的机器码就可以了, 这样就大大提升了代码的执行效率.

V8 的解释器和编译器的取名也很有意思. 解释器 Ignition 是点火器的意思, 编译器 TurboFan 是涡轮增压的意思, 寓意着代码启动时通过点火器慢慢发动, 一旦启动, 涡轮增压介入, 其执行效率随着执行时间越来越高效率, 因为热点代码都被编译器 TurboFan 转换了机器码, 直接执行机器码就省去了字节码“翻译”为机器码的过程.

其实字节码配合解释器和编译器是最近一段时间很火的技术, 比如 Java 和 Python 的虚拟机也都是基于这种技术实现的, 我们把这种技术称为即时编译(JIT). 具体到 V8, 就是指解释器 Ignition 在解释执行字节码的同时, 收集代码信息, 当它发现某一部分代码变热了之后, TurboFan 编译器便闪亮登场, 把热点的字节码转换为机器码, 并把转换后的机器码保存起来, 以备下次使用.

662413313149f66fe0880113cb6ab98a.webp
662413313149f66fe0880113cb6ab98a.webp

词法作用域和动态作用域

作用域有两种模型, 一种是 词法作用域(Lexical Scope), 另一种是 动态作用域(Dynamic Scope).

词法作用域是定义在词法阶段的作用域, 换句话说就是你写代码时将变量和块作用域写在哪里决定的. JavaScript 可以通过 evalwith 来改变词法作用域, 但这两种会导致引擎无法在编译时对作用域查找进行优化, 因此不要使用它们.

而动态作用域是在运行时定义的, 最典型的就是 this 了.

作用域

作用域是指在程序中定义变量的区域, 该位置决定了变量的生命周期. 通俗地理解, 作用域就是变量与函数的可访问范围, 即作用域控制着变量和函数的可见性和生命周期. 在 ES6 之前, ES 的作用域只有两种: 全局作用域和函数作用域.

不管是编译阶段还是运行时, 都离不开 引擎, 编译器, 作用域.

  • 引擎用来负责 JavaScript 程序的编译和执行.

  • 编译器负责语法分析, 代码生成等工作.

  • 作用域用来收集并维护所有变量访问规则.

以代码 const firstName = 'Yancey' 为例, 首先编译器遇到 const firstName, 会询问 作用域 是否已经有一个同名变量在当前作用域集合, 如果有编译器则忽略该声明, 否则它会在当前作用域的集合中声明一个新的变量并命名为 firstName.

接着编译器会为引擎生成运行时所需的代码, 用于处理 firstName = 'Yancey' 这个赋值操作. 引擎会先询问作用域, 在当前作用域集合中是否有个变量叫 firstName. 如果有, 引擎就会使用这个变量, 否则继续往上查找.

引擎在作用域中查找元素时有两种方式: LHSRHS. 一般来讲, LHS 是赋值阶段的查找, 而 RHS 就是纯粹查找某个变量.

看下面这个例子.

function foo(a) { var b = a; return a + b; } var c = foo(2);
  1. var c = foo(2); 引擎会在作用域里找是否有 foo 这个函数, 这是一次 RHS 查找, 找到之后将其赋值给变量 c, 这是一次 LHS 查找.

  2. function foo(a) { 这里将实参 2 赋值给形参 a, 所以这是一次 LHS 查找.

  3. var b = a; 这里要先找到变量 a, 所以这是一次 RHS 查找. 接着将变量 a 赋值给 b, 这是一次 LHS 查找.

  4. return a + b; 查找 ab, 所以是两次 RHS 查找.

全局作用域

全局作用域是在 V8 启动过程中就创建了, 且一直保存在内存中不会被销毁的, 直至 V8 退出.

以浏览器环境为例:

  • 最外层函数在最外层函数外面定义的变量拥有全局作用域

  • 所有末定义直接赋值的变量自动声明为拥有全局作用域

  • 所有 window 对象的属性拥有全局作用域

const a = 1; // 全局变量 // 全局函数 function foo() { b = 2; // 未定义却赋初值被认为是全局变量 const name = "yancey"; // 局部变量 // 局部函数 function bar() { console.log(name); } } window.navigator; // window 对象的属性拥有全局作用域

全局作用域的缺点很明显, 就是会污染全局命名空间, 因此很多库的源码都会使用 (function(){....})(). 此外, 模块化 (ES6, commonjs 等等) 的广泛使用也为防止污染全局命名空间提供了更好的解决方案.

函数作用域

函数作用域指属于这个函数的全部变量都可以在整个函数范围内使用及复用. 函数作用域是在执行该函数时创建的, 当函数执行结束之后, 函数作用域就随之被销毁掉了.

function foo() { const name = "Yancey"; function sayName() { console.log(`Hello, ${name}`); } sayName(); } foo(); // 'Hello, Yancey' console.log(name); // 外部无法访问到内部变量 sayName(); // 外部无法访问到内部函数

值得注意的是, if, switch, while, for 这些条件语句或者循环语句不会创建新的作用域, 虽然它也有一对 {} 包裹. 能不能访问的到内部变量取决于声明方式(var 还是 let/const). 这是因为在 ES6 之前, 没有了块级作用域, 再把作用域内部的变量统一提升无疑是最快速, 最简单的设计, 不过这也直接导致了函数中的变量无论是在哪里声明的, 在编译阶段都会被提取到执行上下文的变量环境中, 所以这些变量在整个函数体内部的任何地方都是能被访问的, 这也就是 JavaScript 中的变量提升.

if (true) { var name = "yancey"; const age = 18; } console.log(name); // 'yancey' console.log(age); // 报错

块级作用域

我们知道 let 和 const 的出现改变了 JavaScript 没有块级作用域的情况(具体可以看高程三的第 76 页, 那个时候还没有块级作用域的概念). 关于 let 和 const 不去细说, 这两个再不懂的话... 不过后面会介绍到临时死区的概念.

此外, try/catchcatch 分句也会创建一个块级作用域, 看下面一个例子:

try { noThisFunction(); // 创造一个异常 } catch (e) { console.log(e); // 可以捕获到异常 } console.log(e); // 报错, 外部无法拿到 e

作用域链

function bar() { console.log(myName); } function foo() { let myName = "A"; bar(); } let myName = "B"; foo(); // B

在每个执行上下文的变量环境中, 都包含了一个外部引用, 用来指向外部的执行上下文, 我们把这个外部引用称为 outer. 当一段代码使用了一个变量时, JavaScript 引擎首先会在“当前的执行上下文”中查找该变量. 比如上面那段代码在查找 myName 变量时, 如果在当前的变量环境中没有查找到, 那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找.

带有外部引用的调用栈
带有外部引用的调用栈

从图中可以看出, bar 函数和 foo 函数的 outer 都是指向全局上下文的, 这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量, 那么 JavaScript 引擎会去全局执行上下文中查找. 我们把这个查找的链条就称为作用域链.

不过还有一个疑问没有解开, foo 函数调用的 bar 函数, 那为什么 bar 函数的外部引用是全局执行上下文, 而不是 foo 函数的执行上下文? 要回答这个问题, 你还需要知道什么是词法作用域. 这是因为在 JavaScript 执行过程中, 其作用域链是由词法作用域决定的.

词法作用域上面有介绍, 词法作用域就是指作用域是由代码中函数声明的位置来决定的, 所以词法作用域是静态的作用域, 通过它就能够预测代码在执行过程中如何查找标识符. 上面这个例子中, foo 和 bar 的上级作用域都是全局作用域, 所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量, 那么它们会到全局作用域去查找. 也就是说, 词法作用域是代码编译阶段就决定好的, 和函数是怎么调用的没有关系.

提升

在 ES6 之前的"蛮荒时代", 变量提升在面试中经常被问到, 而 let 和 const 的出现解决了变量提升问题. 但函数提升一直是存在的, 这里我们从原理入手来分析一下提升. 以下面这段代码为例:

showName(); console.log(myname); var myname = "极客时间"; function showName() { console.log("函数showName被执行"); }

之所以有变量提升和函数提升, 这是因为一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译. 变量和函数声明在代码里的位置是不会改变的, 而且是在编译阶段被 JavaScript 引擎放入内存中, 编译完成之后, 才会进入执行阶段.

在编译阶段, 会生成两部分内容: 执行上下文(Execution context)和可执行代码. 执行上下文是 JavaScript 执行一段代码时的运行环境, 比如调用一个函数, 就会进入这个函数的执行上下文, 确定该函数在执行期间用到的诸如 this, 变量, 对象以及函数等. 在执行上下文中存在一个变量环境的对象(Viriable Environment), 该对象中保存了变量提升的内容.

JavaScript 执行流程细化图
JavaScript 执行流程细化图

比如上面代码中的变量 myname 和函数 showName, 都保存在该对象中.

VariableEnvironment: myname -> undefined, showName -> function: { console.log(myname) }

让我们一步一步来看上面的代码是怎样提升的:

showName(); console.log(myname); var myname = "极客时间"; function showName() { console.log("函数showName被执行"); }
  • 第 1 行和第 2 行, 由于这两行代码不是声明操作, 所以 JavaScript 引擎不会做任何处理;
  • 第 3 行, 由于这行是经过 var 声明的, 因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性, 并使用 undefined 对其初始化;
  • 第 4 行, JavaScript 引擎发现了一个通过 function 定义的函数, 所以它将函数定义存储到堆(HEAP)中, 并在环境对象中创建一个 showName 的属性, 然后将该属性值指向堆中函数的位置.

这样就生成了变量环境对象. 接下来 JavaScript 引擎会把声明以外的代码编译为字节码, 此时, 现在有了执行上下文和可执行代码了, 那么接下来就到了执行阶段了. 执行阶段 JavaScript 引擎便开始在变量环境对象中查找这些变量或函数. 此外, 一段代码如果定义了两个相同名字的函数, 那么最终生效的是最后一个函数. 如果变量和函数同名, 那么在编译阶段, 变量的声明会被忽略.

变量提升

我们回忆一下关于编译器的内容, 引擎会在解释 JavaScript 代码之前首先对其进行编译, 编译阶段的一部分工作就是找到所有的声明, 并且使用合适的作用域将它们串联起来. 换句话说, 变量和函数在内的所有声明都会在代码执行前被处理.

因此, 对于代码 var i = 2; 而言, JavaScript 实际上会将这句代码看作 var i;i = 2, 其中第一个是在编译阶段, 第二个赋值操作会原地等待执行阶段. 换句话说, 这个过程将会把变量和函数声明放到其作用域的顶部, 这个过程就叫做提升.

可能你会有疑问, 为什么 let 和 const 不存在变量提升呢?这是因为在编译阶段, 当遇到变量声明时, 编译器要么将它提升至作用域顶部(var 声明), 要么将它放到 临时死区(temporal dead zone, TDZ), 也就是用 let 或 const 声明的变量. 访问 TDZ 中的变量会触发运行时的错误, 只有执行过变量声明语句后, 变量才会从 TDZ 中移出, 这时才可访问.

下面这个例子你能不能全部答对.

typeof null; // 'object' typeof []; // 'object' typeof someStr; // 'undefined' typeof str; // Uncaught ReferenceError: str is not defined const str = "Yancey";

第一个, 因为 null 根本上是一个指针, 所以会返回 'object'. 深层次一点, 不同的对象在底层都表示为二进制, 在 Javascript 中二进制前三位都为 0 的会被判断为 Object 类型, null 的二进制全为 0, 自然前三位也是 0, 所以执行 typeof 时会返回 'object'.

第二个想强调的是, typeof 判断一个引用类型的变量, 拿到的都是 'object', 因此该操作符无法正确辨别具体的类型, 如 Array 还是 RegExp.

第三个, 当 typeof 一个 未声明 的变量, 不会报错, 而是返回 'undefined'

第四个, str 先是存在于 TDZ, 上面说到访问 TDZ 中的变量会触发运行时的错误, 所以这段代码直接报错.

函数提升

函数声明和变量声明都会被提升, 但值得注意的是, 函数首先被提升, 然后才是变量.

test(); function test() { foo(); bar(); var foo = function () { console.log("this won't run!"); }; function bar() { console.log("this will run!"); } }

上面的代码会变成下面的形式: 内部的 bar 函数会被提升到顶部, 所以可以被执行到;接下来变量 foo 会被提升到顶部, 但变量无法执行, 因此执行 foo() 会报错.

function test() { var foo; function bar() { console.log("this will run!"); } foo(); bar(); foo = function () { console.log("this won't run!"); }; } test();

提升带来的缺点

变量容易在不被察觉的情况下被覆盖掉. 下面这段代码, 在 showName 函数中, 由于 myname 是用 var 声明的, 所以不是块级作用域, 所以 myname 在 showName 函数中会被提升到顶部, 所以两个 log 都会打印出 undefined.

var myname = "极客时间"; function showName() { console.log(myname); if (0) { var myname = "极客邦"; } console.log(myname); } showName();

本应销毁的变量没有被销毁. 下面这段代码, 离开了 for 循环之后 i 并没有被销毁.

function foo() { for (var i = 0; i < 7; i++) {} console.log(i); } foo();

编译器是怎么即支持 var, 又支持 let, const 的

function foo() { var a = 1; let b = 2; { let b = 3; var c = 4; let d = 5; console.log(a); console.log(b); } console.log(b); console.log(c); console.log(d); } foo();

对于 var, 会发生变量提升, 也就是在一个变量赋值前就能访问它. 因此, 自 ECMAScript 5 开始约定, ECMAScript 的执行上下文将有两个环境, 一个称为词法环境(Lexical Environment), 另一个就称为变量环境(Variable Environment), 所有传统风格的 var 声明和函数声明通过变量环境来管理. 所有 let, const 的使用词法环境管理. 而在内核上, 全局上下文的词法环境和变量环境指向是一样的. 也就意味着词法变量和 var 变量共用一个名字表, 因此你声明了 var 变量, 那么就不能声明同名的 let/const 变量.

当进入函数的作用域块时, 作用域块中通过 let 声明的变量, 会被存放在词法环境的一个单独的区域中, 这个区域中的变量并不影响作用域块外面的变量.

在词法环境内部, 维护了一个小型栈结构, 栈底是函数最外层的变量, 进入一个作用域块后, 就会把该作用域块内部的变量压到栈顶; 当作用域执行完成之后, 该作用域的信息就会从栈顶弹出.

当执行到作用域块中的 console.log(a) 这行代码时, 就需要在词法环境和变量环境中查找变量 a 的值了, 具体查找方式是: 沿着词法环境的栈顶向下查询, 如果在词法环境中的某个块中查找到了, 就直接返回给 JavaScript 引擎, 如果没有查找到, 那么继续在变量环境中查找.

执行上下文

上下文指的是一个外部的, 内部的或由全局 / 模块入口映射成的函数. JavaScript 的执行系统由一个执行栈和一个执行队列构成. 在执行队列中保存的是待执行的任务, 称为 Job. 每一个执行上下文都需要关联到一个对照表. 这个对照表, 就称为词法环境(Lexical Environment).

模块入口是所有模块的顶层代码的顺序组合, 它们被封装为一个称为顶层模块执行(TopLevelModule Evaluation Job)的函数中来作为模块加载的第一个执行上下文创建. 一般 .js 文件也会创建一个脚本执行(Script Evaluation Job) 的函数, 这也是文件加载中所有全局代码块被称为 script 块的原因. eval 也是会开启一个执行上下文, JavaScript 为 eval() 所分配的这个执行上下文, 与调用 eval() 时的函数上下文享有同一个环境(包括词法环境和变量环境等等), 并在退出 eval() 时释放它的引用, 以确保同一个环境中同时只有一个逻辑在执行.

对于普通函数被调用, 它也会形成执行上下文, 但它是调用的, 所以它会创建一个 caller(调用者), 由于栈是先入后出的, 因此总是立即执行这个 callee 函数的上下文. 因此所有其他上下文都在执行栈上, 而生成器的上下文(多数时间是)在栈的外面.

执行上下文包含变量环境, 词法环境, outer, this.

执行上下文的构成
执行上下文的构成

调用栈

调用栈就是用来管理函数调用关系的一种数据结构, 在执行上下文创建好后, JavaScript 引擎会将执行上下文压入栈中, 通常把这种用来管理执行上下文的栈称为执行上下文栈, 又称调用栈. 以下面这段代码为例.

var a = 2; function add() { var b = 10; return a + b; } add();

在执行到函数 add() 之前, JavaScript 引擎会为上面这段代码创建全局执行上下文, 包含了声明的函数和变量.

全局执行上下文
全局执行上下文

执行上下文准备好之后, 便开始执行全局代码, 当执行到 add 这儿时, JavaScript 判断这是一个函数调用, 那么将执行以下操作:

  • 首先, 从全局执行上下文中, 取出 add 函数代码.
  • 其次, 对 add 函数的这段代码进行编译, 并创建该函数的执行上下文可执行代码.
  • 最后, 执行代码, 输出结果.

函数调用过程
函数调用过程

再换个复杂的例子:

var a = 2; function add(b, c) { return b + c; } function addAll(b, c) { var d = 10; result = add(b, c); return a + result + d; } addAll(3, 6);
  1. 首先创建全局上下文, 并将其压入栈底. 此时全局上下文的 a, add, adAll 被保存到变量环境对象中.
  2. 全局执行上下文压入到调用栈后, JavaScript 引擎便开始执行全局代码了. 首先会执行 a = 2 的赋值操作, 执行该语句会将全局上下文变量环境中 a 的值设置为 2.
  3. 接着调用 addAll 函数. 当调用该函数时, JavaScript 引擎会编译该函数, 并为其创建一个执行上下文, 最后还将该函数的执行上下文压入栈中.
  4. addAll 函数的执行上下文创建好之后, 便进入了函数代码的执行阶段了, 这里先执行的是 d=10 的赋值操作, 执行语句会将 addAll 函数执行上下文中的 d 由 undefined 变成了 10.
  5. 当执行到 add 函数调用语句时, 同样会为其创建执行上下文, 并将其压入调用栈, 当 add 函数返回时, 该函数的执行上下文就会从栈顶弹出, 并将 result 的值设置为 add 函数的返回值, 也就是 9
  6. 紧接着 addAll 执行最后一个相加操作后并返回, addAll 的执行上下文也会从栈顶部弹出, 此时调用栈中就只剩下全局上下文了, 整个 JavaScript 流程执行结束了

执行图
执行图

利用调用栈调试指南

通过在 Source 中打断点, 这时可以通过右边“ call stack 来查看当前的调用栈的情况, 如下图:

Source 断点
Source 断点

除了通过断点来查看调用栈, 你还可以使用 console.trace() 来输出当前的函数调用关系, 如下图:

console.trace()
console.trace()

栈溢出

现在你知道了调用栈是一种用来管理执行上下文的数据结构, 符合后进先出的规则. 不过还有一点你要注意, 调用栈是有大小的, 当入栈的执行上下文超过一定数目, JavaScript 引擎就会报错, 我们把这种错误叫做栈溢出. 这是因为这个函数是递归的, 并且没有任何终止条件, 所以它会一直创建新的函数执行上下文, 并反复将其压入栈中, 但栈是有容量限制的, 超过最大数量后就会出现栈溢出的错误. 除了对代码做出改进, 目前引擎都实现了尾递归优化, 可以利用这一点来避免爆栈 333333333333333333333333.

栈溢出
栈溢出

闭包

闭包是指那些能够访问独立(自由)变量的函数(变量在本地使用, 但定义在一个封闭的作用域中). 换句话说, 这些函数可以「记忆」它被创建时候的环境. -- MDN

闭包是有权访问另一个函数作用域的函数. -- 《JavaScript 高级程序设计(第 3 版)》

函数对象可以通过作用域链相互关联起来, 函数体内部的变量都可以保存在函数作用域内, 这种特性在计算机科学文献中称为闭包. -- 《JavaScript 权威指南(第 6 版)》

当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行. -- 《你不知道的 JavaScript(上卷)》

似乎最后一个解释更容易理解, 所以我们从"记住并访问"来学习闭包.

何为"记住"

在 JavaScript 中, 如果函数被调用过了, 并且以后不会被用到, 那么垃圾回收机制(后面会说到)就会销毁由函数创建的作用域. 我们知道, 引用类型的变量只是一个指针, 并不会把真正的值拷贝给变量, 而是把对象所在的位置传递给变量. 因此, 当函数被传递到一个还未销毁的作用域的某个变量时, 由于变量存在, 所以函数会存在, 又因为函数的存在依赖于函数所在的词法作用域, 所以函数所在的词法作用域也会存在, 这样一来, 就"记住"了该词法作用域.

看下面这个例子. 在执行 apple 函数时, 将 output 的引用作为参数传递给了 fruit 函数的 arg, 因此在 fruit 函数执行期间, arg 是存在的, 所以 output 也是存在的, 而 output 依赖的 apple 函数产生的局部作用域也是存在. 这也就是 output 函数"记住"了 apple 函数作用域的原因.

function apple() { var count = 0; function output() { console.log(count); } fruit(output); } function fruit(arg) { console.log("fruit"); } apple(); // fruit

"记住" 并 "访问"

但上面的例子并不是完整的"闭包", 因为只是"记住"了作用域, 但没有去"访问"这个作用域. 我们稍微改造一下上面这个例子, 在 fruit 函数中执行 arg 函数, 实际就是执行 output, 并且还访问了 apple 函数中的 count 变量.

function apple() { var count = 0; function output() { console.log(count); } fruit(output); } function fruit(arg) { arg(); } apple(); // 0

循环和闭包

下面是一道经典的面试题. 我们希望代码输出 0 ~ 4, 每秒一次, 每次一个. 但实际上, 这段代码在运行时会以每秒一次的频率输出五次 5.

for (var i = 0; i < 5; i++) { setTimeout(function timer() { console.log(i); }, i * 1000); }

因为 setTimeout 是异步执行的, 1000 毫秒后向任务队列里添加一个任务, 只有主线程上的任务全部执行完毕才会执行任务队列里的任务, 所以当主线程 for 循环执行完之后 i 的值为 5, 而用这个时候再去任务队列中执行任务, 因此 i 全部为 5. 又因为在 for 循环中使用 var 声明的 i 是在全局作用域中, 因此 timer 函数中打印出来的 i 自然是都是 5.

我们可以通过在迭代内使用 IIFE 来给每个迭代都生成一个新的作用域, 使得延迟函数的回调可以将新的作用域封闭在每个迭代内部, 每个迭代中都会含有一个具有正确值的变量供我们访问. 代码如下所示.

for (var i = 0; i < 5; i++) { (function (j) { setTimeout(function timer() { console.log(j); }, j * 1000); })(i); }

如果你 API 看得仔细的话, 还可以写成下面的形式:

for (var i = 0; i < 5; i++) { setTimeout( function (j) { console.log(j); }, i * 1000, i ); }

当然最好的方式是使用 let 声明 i, 这时候变量 i 就能作用于这个循环块, 每个迭代都会使用上一个迭代结束的值来初始化这个变量.

for (let i = 0; i < 5; i++) { setTimeout(function timer() { console.log(i); }, i * 1000); }

从词法作用域的角度理解闭包

function foo() { var myName = "极客时间"; let test1 = 1; var innerBar = { getName: function () { console.log(test1); return myName; }, setName: function (newName) { myName = newName; }, }; return innerBar; } var bar = foo(); bar.setName("极客邦"); bar.getName(); // 1 极客邦

根据词法作用域的规则, 内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量, 所以当 innerBar 对象返回给全局变量 bar 时, 虽然 foo 函数已经执行结束, 但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1.

foo 函数执行完成之后, 其执行上下文从栈顶弹出了, 但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1, 所以这两个变量依然保存在内存中. 这像极了 setName 和 getName 方法背的一个专属背包, 无论在哪里调用了 setName 和 getName 方法, 它们都会背着这个 foo 函数的专属背包.

之所以是专属背包, 是因为除了 setName 和 getName 函数之外, 其他任何地方都是无法访问该背包的, 我们就可以把这个背包称为 foo 函数的闭包.

在 JavaScript 中, 根据词法作用域的规则, 内部函数总是可以访问其外部函数中声明的变量, 当通过调用一个外部函数返回一个内部函数后, 即使该外部函数已经执行结束了, 但是内部函数引用外部函数的变量依然保存在内存中, 我们就把这些变量的集合称为闭包. 比如外部函数是 foo, 那么这些变量的集合就称为 foo 函数的闭包.

那这些闭包是如何使用的呢? 当执行到 bar.setName 方法中的 myName = "极客邦" 这句代码时, JavaScript 引擎会沿着 当前执行上下文 -> foo 函数闭包 -> 全局执行上下文 的顺序来查找 myName 变量. 同样的流程, 当调用 bar.getName 的时候, 所访问的变量 myName 也是位于 foo 函数闭包中的.

闭包
闭包

你可以通过打断点来了解闭包. 从图中可以看出来, 当调用 bar.getName 的时候, 右边 Scope 项就体现出了作用域链的情况: Local 就是当前的 getName 函数的作用域, Closure(foo) 是指 foo 函数的闭包, 最下面的 Global 就是指全局作用域, 从 Local -> Closure(foo) -> Global 就是一个完整的作用域链.

闭包回收

如果引用闭包的函数是一个全局变量, 那么闭包会一直存在直到页面关闭; 但如果这个闭包以后不再使用的话, 就会造成内存泄漏. 如果引用闭包的函数是个局部变量, 等函数销毁后, 在下次 JavaScript 引擎执行垃圾回收时, 判断闭包这块内容如果已经不再被使用了, 那么 JavaScript 引擎的垃圾回收器就会回收这块内存.

所以在使用闭包的时候, 如果该闭包会一直使用, 那么它可以作为全局变量而存在; 但如果使用频率不高, 而且占用内存又比较大的话, 那就尽量让它成为一个局部变量.

JavaScript 的内存与闭包

function foo() { var myName = "极客时间"; let test1 = 1; const test2 = 2; var innerBar = { setName: function (newName) { myName = newName; }, getName: function () { console.log(test1); return myName; }, }; return innerBar; } var bar = foo(); bar.setName("极客邦"); bar.getName(); console.log(bar.getName());

还是以这段代码为例. 当 foo 函数的执行上下文销毁时, 由于 foo 函数产生了闭包, 所以变量 myName 和 test1 并没有被销毁, 而是保存在内存中. 这应该怎么解释呢?

  1. 当 JavaScript 引擎执行到 foo 函数时, 首先会编译, 并创建一个空执行上下文.
  2. 在编译过程中, 遇到内部函数 setName, JavaScript 引擎还要对内部函数做一次快速的词法扫描, 发现该内部函数引用了 foo 函数中的 myName 变量, 由于是内部函数引用了外部函数的变量, 所以 JavaScript 引擎判断这是一个闭包, 于是在堆空间创建换一个 closure(foo) 的对象(这是一个内部对象, JavaScript 是无法访问的), 用来保存 myName 变量.
  3. 接着继续扫描到 getName 方法时, 发现该函数内部还引用变量 test1, 于是 JavaScript 引擎又将 test1 添加到 closure(foo) 对象中. 这时候堆中的 closure(foo) 对象中就包含了 myName 和 test1 两个变量了.
  4. 由于 test2 并没有被内部函数引用, 所以 test2 依然保存在调用栈中.

闭包的产生过程
闭包的产生过程

从上图你可以清晰地看出, 当执行到 foo 函数时, 闭包就产生了; 当 foo 函数执行结束之后, 返回的 getName 和 setName 方法都引用 closure(foo) 对象, 所以即使 foo 函数退出了, closure(foo) 依然被其内部的 getName 和 setName 方法引用. 所以在下次调用 bar.setName 或者 bar.getName 时, 创建的执行上下文中就包含了 closure(foo).

总的来说, 产生闭包的核心有两步: 第一步是需要预扫描内部函数; 第二步是把内部函数引用的外部变量保存到堆中.

垃圾回收

上面提到, 函数被调用过了, 并且以后不会被用到, 那么垃圾回收机制就会销毁由函数创建的作用域. JavaScript 有两种垃圾回收机制, 即 标记清除引用计数, 对于现代浏览器, 绝大多数都会采用 标记清除.

标记清除的主要原理是, 垃圾收集器在运行的时候会给存储在内存中的所有变量加上标记, 然后它会去掉环境中变量以及被环境中的变量引用的变量的标记. 而在此之后再被加上标记的变量将被视为准备删除的变量, 原因是环境中的变量已经无法访问到这些变量了. 最后, 垃圾收集器完成内存清除工作, 销毁那些带标记的值并且回收它们所占用的内存空间.

引用计数是跟踪记录每个值被引用的次数. 当声明了一个变量并将一个引用类型值赋给该变量时, 这个值得引用次数就是 1;相反, 如果包含对这个值引用的变量又取得了另外一个值, 则这个值得引用次数减 1; 下次运行垃圾回收器时就可以释放那些引用次数为 0 的值所占用的内存. 缺点: 循环引用会导致引用次数永远不为 0.

本文会详细讲标记清除, 在此之前, 我们先看一看垃圾是如何产生的, 以及调用栈中的数据和堆中的数据是如何回收的.

垃圾是如何产生的

window.test = new Object(); window.test.a = new Uint16Array(100);

以上面代码为例, 首先先为 window 对象添加一个 test 属性, 并在堆中创建了一个空对象, 并将该对象的地址指向了 window.test 属性. 随后又创建一个大小为 100 的数组, 并将属性地址指向了 test.a 的属性值, 此时的内存布局图如下所示:

内存布局
内存布局

我们可以看到, 栈中保存了指向 window 对象的指针, 通过栈中 window 的地址, 我们可以到达 window 对象, 通过 window 对象可以到达 test 对象, 通过 test 对象还可以到达 a 对象.

如果此时, 我将另外一个对象赋给了 a 属性, 代码如下所示:

window.test.a = new Object();

此时的内存布局图如下所示: 我们可以看到, a 属性之前是指向堆中数组对象的, 现在已经指向了另外一个空对象, 那么此时堆中的数组对象就成为了垃圾数据, 因为我们无法从一个根对象遍历到这个 Array 对象. 不过, 你不用担心这个数组对象会一直占用内存空间, 因为 V8 虚拟机中的垃圾回收器会帮你自动清理.

内存布局
内存布局

调用栈中的数据是如何回收的

function foo() { var a = 1; var b = { name: "极客邦" }; function showName() { var c = 2; var d = { name: "极客时间" }; } showName(); } foo();

我们知道, 如果执行到 showName 函数时, 那么 JavaScript 引擎会创建 showName 函数的执行上下文, 并将 showName 函数的执行上下文压入到调用栈中来执行, 与此同时, 还有一个记录当前执行状态的指针 ESP, 指向调用栈中 showName 函数的执行上下文, 表示当前正在执行 showName 函数.

接着, 当 showName 函数执行完成之后, 函数执行流程就进入了 foo 函数, 那这时就需要销毁 showName 函数的执行上下文了. ESP 这时候就帮上忙了, JavaScript 会将 ESP 下移到 foo 函数的执行上下文, 这个下移操作就是销毁 showName 函数执行上下文的过程.

之所以下移操作能奏效, 是因为当 showName 函数执行结束之后, ESP 向下移动到 foo 函数的执行上下文中, 上面 showName 的执行上下文虽然保存在栈内存中, 但是已经是无效内存了. 比如当 foo 函数再次调用另外一个函数时, 这块内容会被直接覆盖掉, 用来存放另外一个函数的执行上下文.

因此, 当一个函数执行结束之后, JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文.

ESP
ESP

堆中的数据是如何回收的

上面的代码中, 当 foo 也执行完了, ESP 就会下移到全局上下文, 当在全局上下文调用其他函数后, showName 函数和 foo 函数的执行上下文就处于无效状态了, 不过保存在堆中的两个对象 b 和 d 依然占用着空间. 要回收堆中的垃圾数据, 就需要用到 JavaScript 中的垃圾回收器了.

foo 函数执行结束后的内存状态
foo 函数执行结束后的内存状态

代际假说和分代收集

在正式介绍 V8 是如何实现回收之前, 先说一下代际假说(The Generational Hypothesis)的内容, 这是垃圾回收领域中一个重要的术语, 后续垃圾回收的策略都是建立在该假说的基础之上的.

代际假说有以下两个特点:

  • 第一个是大部分对象在内存中存在的时间很短, 简单来说, 就是很多对象一经分配内存, 很快就变得不可访问;
  • 第二个是不死的对象, 会活得更久.

在 V8 中会把堆分为新生代老生代两个区域, 新生代中存放的是生存时间短的对象, 老生代中存放的生存时间久的对象. 新生区通常只支持 1 - 8M 的容量, 而老生区支持的容量就大很多了. 对于这两块区域, V8 分别使用两个不同的垃圾回收器. 副垃圾回收器负责新生代的垃圾回收; 主垃圾回收器负责老生代的垃圾回收.

垃圾回收器的工作流程

第一步通过 GC Root 标记空间中活动对象和非活动对象. 目前 V8 采用的可访问性(reachability) 算法来判断堆中的对象是否是活动对象. 具体地讲, 这个算法是将一些 GC Root 作为初始存活的对象的集合, 从 GC Roots 对象出发, 遍历 GC Root 中的所有对象:

  • 通过 GC Root 遍历到的对象, 也就是还在使用的对象, 我们就认为该对象是可访问的(reachable), 那么必须保证这些对象应该在内存中保留, 我们也称可访问的对象为活动对象;
  • 通过 GC Roots 没有遍历到的对象, 其实就是在所有的标记完成之后, 统一清理内存中所有被标记为可回收的对象, 则是不可访问的(unreachable), 那么这些不可访问的对象就可能被回收, 我们称不可访问的对象为非活动对象.

在浏览器环境中, GC Root 有很多, 通常包括了以下几种 (但是不止于这几种):

  • 全局的 window 对象(位于每个 iframe 中);
  • 文档 DOM 树, 由可以通过遍历文档到达的所有原生 DOM 节点组成;
  • 存放栈上变量.

第二步是回收非活动对象所占据的内存. 其实就是在所有的标记完成之后, 统一清理内存中所有被标记为可回收的对象.

第三步是做内存整理. 一般来说, 频繁回收对象后, 内存中就会存在大量不连续空间, 我们把这些不连续的内存空间称为内存碎片. 当内存中出现了大量的内存碎片之后, 如果需要分配较大连续内存的时候, 就有可能出现内存不足的情况. 所以最后一步需要整理这些内存碎片, 但这步其实是可选的, 因为有的垃圾回收器不会产生内存碎片, 比如接下来我们要介绍的副垃圾回收器.

副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收. 而通常情况下, 大多数小的对象都会被分配到新生区, 所以说这个区域虽然不大, 但是垃圾回收还是比较频繁的. 新生代中用 Scavenge 算法来处理. 它将把新生代空间对半划分为两个区域, 一半是对象区域, 一半是空闲区域, 如下图所示:

新生代区
新生代区

新加入的对象都会存放到对象区域, 当对象区域快被写满时, 就需要执行一次垃圾清理操作.

在垃圾回收过程中, 首先要对对象区域中的垃圾做标记; 标记完成之后, 就进入垃圾清理阶段, 副垃圾回收器会把这些存活的对象复制到空闲区域中, 同时它还会把这些对象有序地排列起来, 所以这个复制过程, 也就相当于完成了内存整理操作, 复制后空闲区域就没有内存碎片了.

完成复制后, 对象区域与空闲区域进行角色翻转, 也就是原来的对象区域变成空闲区域, 原来的空闲区域变成了对象区域. 这样就完成了垃圾对象的回收操作, 同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去.

由于新生代中采用的 Scavenge 算法, 所以每次执行清理操作时, 都需要将存活的对象从对象区域复制到空闲区域. 但复制操作需要时间成本, 如果新生区空间设置得太大了, 那么每次清理的时间就会过久, 所以为了执行效率, 一般新生区的空间会被设置得比较小.

也正是因为新生区的空间不大, 所以很容易被存活的对象装满整个区域. 为了解决这个问题, JavaScript 引擎采用了对象晋升策略, 也就是经过两次垃圾回收依然还存活的对象, 会被移动到老生区中.

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收. 除了新生区中晋升的对象, 一些大的对象会直接被分配到老生区. 因此老生区中的对象有两个特点, 一个是对象占用空间大, 另一个是对象存活时间长.

由于老生区的对象比较大, 若要在老生区中使用 Scavenge 算法进行垃圾回收, 复制这些大的对象将会花费比较多的时间, 从而导致回收执行效率不高, 同时还会浪费一半的空间. 因而, 主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的.

首先是标记过程阶段. 标记阶段就是从一组根元素开始, 递归遍历这组根元素, 在这个遍历过程中, 能到达的元素称为活动对象, 没有到达的元素就可以判断为垃圾数据.

标记过程
标记过程

比如看着面这张图, 当 showName 函数执行结束之后, ESP 向下移动, 指向了 foo 函数的执行上下文, 这时候如果遍历调用栈, 是不会找到引用 1003 地址的变量, 也就意味着 1003 这块数据为垃圾数据, 被标记为红色. 由于 1050 这块数据被变量 b 引用了, 所以这块数据会被标记为活动对象.

接下来就是垃圾的清除过程, 大致就是下图中红色区域被去掉.

标记清除
标记清除

由于标记 - 清除算法对一块内存多次执行标记 - 清除算法后, 会产生大量不连续的内存碎片. 而碎片过多会导致大对象无法分配到足够的连续内存, 于是又产生了另外一种算法, 即标记 - 整理(Mark-Compact), 这个标记过程仍然与标记 - 清除算法里的是一样的, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉端边界以外的内存.

标记整理
标记整理

全停顿

由于 JavaScript 是运行在主线程之上的, 一旦执行垃圾回收算法, 都需要将正在执行的 JavaScript 脚本暂停下来, 待垃圾回收完毕后再恢复脚本执行. 我们把这种行为叫做全停顿(Stop-The-World). 以下面的图片为例, 有 200ms 交给了垃圾回收, 这也意味着有 200ms 导致主线程停滞导致卡顿.

全停顿
全停顿

在 V8 新生代的垃圾回收中, 因其空间较小, 且存活对象较少, 所以全停顿的影响不大. 但老生代的垃圾回收就会很耗时, 如果全停顿的话, 应用的性能和响应能力都会直线下降. 为此, V8 向现有的垃圾回收器添加并行, 并发和增量等垃圾回收技术:

  • 第一, 将一个完整的垃圾回收的任务拆分成多个小的任务, 这样就消灭了单个长的垃圾回收任务;
  • 第二, 将标记对象, 移动对象等任务转移到后台线程进行, 这会大大减少主线程暂停的时间, 改善页面卡顿的问题, 让动画, 滚动和用户交互更加流畅.

并行回收

由于全停顿执行一次完整的垃圾回收过程比较耗时, 那么解决效率问题, 第一个思路就是主线程在执行垃圾回收的任务时, 引入多个辅助线程来并行处理, 同时执行同样的回收工作, 这样就会加速垃圾回收的执行速度.

采用并行回收时, 垃圾回收所消耗的时间, 等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间), 再加上一些同步开销的时间. 这种方式比较简单, 因为在执行垃圾标记的过程中, 主线程并不会同时执行 JavaScript 代码, 因此 JavaScript 代码也不会改变回收的过程.

V8 的副垃圾回收器所采用的就是并行策略, 它在执行垃圾回收的过程中, 启动了多个线程来负责新生代中的垃圾清理操作, 这些线程同时将对象空间中的数据移动到空闲区域. 由于数据的地址发生了改变, 所以还需要同步更新引用这些对象的指针.

并行回收
并行回收

增量回收

虽然并行策略能增加垃圾回收的效率, 能够很好地优化副垃圾回收器, 但是这仍然是一种全停顿的垃圾回收方式, 在主线程执行回收工作的时候才会开启辅助线程, 这依然还会存在效率问题, 因此只适合副垃圾回收器这种轻量的垃圾回收器.

而对于老生代存放的都是一些大的对象, 如 window, DOM 这种, 完整执行老生代的垃圾回收, 时间依然会很久. 这些大的对象都是主垃圾回收器的, 如果通过并行回收仍然会很慢.

因此, 为了降低老生代的垃圾回收而造成的卡顿, V8 将标记过程分为一个个的子标记过程, 每次执行的只是整个垃圾回收过程中的一小部分工作, 同时让垃圾回收标记和 JavaScript 应用逻辑(主线程)交替进行, 直到标记阶段完成, 我们把这个算法称为增量标记(Incremental Marking)算法. 使用增量标记算法, 可以把一个完整的垃圾回收任务拆分为很多小的任务, 这些小的任务执行时间比较短, 可以穿插在其他的 JavaScript 任务中间执行, 这样当执行一些动画效果时, 就不会让用户因为垃圾回收任务而感受到页面的卡顿了.

增量标记
增量标记

增量标记的算法, 比全停顿的算法要稍微复杂, 这主要是因为增量回收是并发的(concurrent), 要实现增量执行, 需要满足两点要求:

  • 垃圾回收可以被随时暂停和重启, 暂停时需要保存当时的扫描结果, 等下一波垃圾回收来了之后, 才能继续启动.
  • 在暂停期间, 被标记好的垃圾数据如果被 JavaScript 代码修改了, 那么垃圾回收器需要能够正确地处理.

我们先来看看第一点, V8 是如何实现垃圾回收器的暂停和恢复执行的.

在没有采用增量算法之前, V8 使用黑色和白色来标记数据. 在执行一次完整的垃圾回收之前, 垃圾回收器会将所有的数据设置为白色, 用来表示这些数据还没有被标记, 然后垃圾回收器在会从 GC Roots 出发, 将所有能访问到的数据标记为黑色. 遍历结束之后, 被标记为黑色的数据就是活动数据, 那些白色数据就是垃圾数据.

数据标记
数据标记

但由于执行一段 GC 后, 又会回到主线程运行时执行一段 JavaScript 代码, 这些代码就把黑白标记搞乱了. 为了解决这个问题, V8 采用了三色标记法, 除了黑色和白色, 还额外引入了灰色:

  • 黑色表示这个节点被 GC Root 引用到了, 而且该节点的子节点都已经标记完成了;
  • 灰色表示这个节点被 GC Root 引用到了, 但子节点还没被垃圾回收器标记处理, 也表明目前正在处理这个节点;
  • 白色表示这个节点没有被访问到, 如果在本轮遍历结束时还是白色, 那么这块数据就会被收回.

引入灰色标记之后, 垃圾回收器就可以依据当前内存中有没有灰色节点, 来判断整个标记是否完成, 如果没有灰色节点了, 就可以进行清理工作了. 如果还有灰色标记, 当下次恢复垃圾回收器时, 便从灰色的节点开始继续执行. 我们看下面一个例子:

window.a = Object(); window.a.b = Object(); window.a.b.c = Object();

执行到这段代码时, 垃圾回收器标记的结果如下图所示:

初次标记
初次标记

然后又执行了另外一个代码, 这段代码如下所示:

window.a.b = Object();

执行完之后, 垃圾回收器又恢复执行了增量标记过程, 由于 b 重新指向了 d 对象, 所以 b 和 c 对象的连接就断开了. 这时候代码的应用如下图所示:

再次标记
再次标记

这就说明一个问题, 当垃圾回收器将某个节点标记成了黑色, 然后这个黑色的节点被续上了一个白色节点, 那么垃圾回收器不会再次将这个白色节点标记为黑色节点了, 因为它已经走过这个路径了.

但是这个新的白色节点的确被引用了, 所以我们还是需要想办法将其标记为黑色. 为了解决这个问题, 增量垃圾回收器添加了一个约束条件: 不能让黑色节点指向白色节点.

通常我们使用写屏障(Write-barrier)机制实现这个约束条件, 也就是说, 当发生了黑色的节点引用了白色的节点, 写屏障机制会强制将被引用的白色节点变成灰色的, 这样就保证了黑色节点不能指向白色节点的约束条件. 这个方法也被称为强三色不变性, 它保证了垃圾回收器能够正确地回收数据, 因为在标记结束时的所有白色对象, 对于垃圾回收器来说, 都是不可到达的, 可以安全释放.

所以在 V8 中, 每次执行如 window.a.b = value 的写操作之后, V8 会插入写屏障代码, 强制将 value 这块内存标记为灰色.

并发回收

虽然通过三色标记法和写屏障机制可以很好地实现增量垃圾回收, 但是由于这些操作都是在主线程上执行的, 如果主线程繁忙的时候, 增量垃圾回收操作依然会增加主线程处理任务的吞吐量 (throughput). 此外, 并行回收可以将一些任务分配给辅助线程, 但是并行回收依然会阻塞主线程. 因此, 我们有了并发回收, 所谓并发回收, 是指主线程在执行 JavaScript 的过程中, 辅助线程能够在后台完成执行垃圾回收的操作.

并发回收的优势非常明显, 主线程不会被挂起, JavaScript 可以自由地执行 , 在执行的同时, 辅助线程可以执行垃圾回收操作.

但是并发回收却是这三种技术中最难的一种, 这主要由以下两个原因导致的:

  • 第一, 当主线程执行 JavaScript 时, 堆中的内容随时都有可能发生变化, 从而使得辅助线程之前做的工作完全无效;
  • 第二, 主线程和辅助线程极有可能在同一时间去更改同一个对象, 这就需要额外实现读写锁的一些功能了.

不过, 这三种技术在实际使用中, 并不是单独的存在, 通常会将其融合在一起使用, V8 的主垃圾回收器就融合了这三种机制, 来实现垃圾回收:

  • 首先主垃圾回收器主要使用并发标记, 我们可以看到, 在主线程执行 JavaScript, 辅助线程就开始执行标记操作了, 所以说标记是在辅助线程中完成的.
  • 标记完成之后, 再执行并行清理操作. 主线程在执行清理操作时, 多个辅助线程也在执行清理操作.
  • 另外, 主垃圾回收器还采用了增量标记的方式, 清理的任务会穿插在各种 JavaScript 任务之间执行.

综合
综合

几种内存问题

  • 内存泄漏 (Memory leak), 它会导致页面的性能越来越差;
  • 内存膨胀 (Memory bloat), 它会导致页面的性能会一直很差;
  • 频繁垃圾回收, 它会导致页面出现延迟或者经常暂停.

Nodejs 事件循环机制

Node 是 V8 的宿主, 它会给 V8 提供事件循环和消息队列. 在 Node 中, 事件循环是由 libuv 提供的, libuv 工作在主线程中, 它会从消息队列中取出事件, 并在主线程上执行事件. 同样, 对于一些主线程上不适合处理的事件, 比如消耗时间过久的网络资源下载, 文件读写, 设备访问等, Node 会提供很多线程来处理这些事件, 我们把这些线程称为线程池. 以 file 模块为例, 通常, 在 Node 中, 我们认为读写文件是一个非常耗时的工作, 因此主线程会将回调函数和读文件的操作一道发送给文件读写线程, 并让实际的读写操作运行在读写线程中.

Node 的体系架构
Node 的体系架构

比如当在 Node 的主线程上执行 readFile 的时候, 主线程会将 readFile 的文件名称和回调函数, 提交给文件读写线程来处理.

文件读写线程完成了文件读取之后, 会将结果和回调函数封装成新的事件, 并将其添加进消息队列中. 比如文件线程将读取的文件内容存放在内存中, 并将 data 指针指向了该内存, 然后文件读写线程会将 data 和回调函数封装成新的事件, 并将其丢进消息队列中.

等到 libuv 从消息队列中读取该事件后, 主线程就可以着手来处理该事件了. 在主线程处理该事件的过程中, 主线程调用事件中的回调函数, 并将 data 结果数据作为参数.

读取文件原理
读取文件原理

不过, 总有些人觉得异步读写文件操作过于复杂了, 如果读取的文件体积不大或者项目瓶颈不在文件读写, 那么依然使用异步调用和回调函数的模式就显得有点过度复杂了. 因此 Node 还提供了一套同步读写的 API. 第一段代码中的 readFileSync 就是同步实现的, 同步代码非常简单, 当 libuv 读取到 readFileSync 的任务后, 就直接在主线程上执行读写操作, 等待读写结束, 直接返回读写的结果, 这也是同步回调的一种应用. 当然在读写过程中, 消息队列中的其他任务是无法被执行的.

内存泄漏 (Memory leak)

本质上, 内存泄漏可以定义为当进程不再需要某些内存的时候, 这些不再被需要的内存依然没有被进程回收. 在 JavaScript 中, 造成内存泄漏的主要原因是不再需要没有作用的内存数据依然被其他对象引用着. 下面举几个例子:

不要声明没有使用 var, let, const 的变量, 看下面这段代码, temp_array 没有使用 var, let, const 声明, 在非严格模式下, 会默认将 temp_array 变成 this.temp_array, 在浏览器默认情况下, this 是指向 window 对象的, 而 window 对象是常驻内存的, 所以即便 foo 函数退出了, 但是 temp_array 依然被 window 对象引用了, 所以 temp_array 依然也会和 window 对象一样, 会常驻内存. 因为 temp_array 已经是不再被使用的对象了, 但是依然被 window 对象引用了, 这就造成了 temp_array 的泄漏. 为了解决这个问题, 我们可以在 JavaScript 文件头部加上 "use strict", 使用严格模式避免意外的全局变量, 此时上例中的 this 指向 undefined.

function foo() { temp_array = new Array(200000); }

小心闭包, 闭包会引用父级函数中定义的变量, 如果引用了不被需要的变量, 那么也会造成内存泄漏. 下面这段代码中, 由于闭包函数使用了 temp_object.x, 因此 temp_object 对象会常驻内存, 而 temp_object 有个很重的属性 array, 它也被常驻内存了, 而我们仅仅需要 temp_object.x 而已.

function foo() { var temp_object = new Object(); temp_object.x = 1; temp_object.y = 2; temp_object.array = new Array(200000); return function () { console.log(temp_object.x); }; }

为防止这个问题, 你可以把 x 单独拎出来.

function foo() { const temp_object = new Object(); temp_object.x = 1; temp_object.y = 2; temp_object.array = new Array(200000); const { x } = temp_object; return function () { console.log(x); }; }

JavaScript 引用了 DOM 节点而造成的内存泄漏的问题, 我们知道只有同时满足 DOM 树和 JavaScript 代码都不引用某个 DOM 节点, 该节点才会被作为垃圾进行回收. 如果某个节点已从 DOM 树移除, 但 JavaScript 仍然引用它, 我们称此节点为 detached, detached 是 DOM 内存泄漏的常见原因. 比如下面这段代码, ul 关联到 DOM 树上, 即便 ul 从 DOM 上被移除后, 它们并不会立即销毁, 这主要是由于 JavaScript 代码中保留了这些元素的引用, 导致这些 DOM 元素依然会待在内存中.

let detachedTree; function create() { const $ul = document.createElement("ul"); for (let i = 0; i < 100; i++) { const $li = document.createElement("li"); $ul.appendChild($li); } detachedTree = $ul; } create();

内存膨胀 (Memory bloat)

内存膨胀和内存泄漏有一些差异, 内存膨胀主要表现在程序员对内存管理的不科学, 比如只需要 50M 内存就可以搞定的, 有些程序员却花费了 500M 内存. 额外使用过多的内存有可能是没有充分地利用好缓存, 也有可能加载了一些不必要的资源. 通常表现为内存在某一段时间内快速增长, 然后达到一个平稳的峰值继续运行. 从下图我们可以看到, 内存膨胀是快速增长, 然后达到一个平衡的位置, 而内存泄漏是内存一直在缓慢增长.

内存膨胀
内存膨胀

频繁垃圾回收

上面我们讲到垃圾回收, 由于全停顿会在主线程运行导致 JavaScript 运行暂停, 如果 GC 很耗时的话, 会引起卡顿. 对应到代码上, 如果频繁使用大的临时变量, 导致了新生代空间很快被装满, 从而频繁触发垃圾回收. 频繁的垃圾回收操作会让你感觉到页面卡顿. 为了解决频繁的垃圾回收的问题, 你可以考虑将这些临时变量设置为全局变量.

总结

Q: 什么是作用域?

A: 作用域是根据名称查找变量的一套规则.

Q: 什么是作用域链?

A: 当一个块或函数嵌套在另一个块或另一个函数中时, 就发生了作用域嵌套. 因此, 在当前作用域下找不到某个变量时, 会往外层嵌套的作用域继续查找, 直到找到该变量或抵达全局作用域, 如果在全局作用域中还没找到就会报错. 这种逐级向上查找的模式就是作用域链.

Q: 什么是闭包?

A: 当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行.

最后

导致这篇文章写这么长的根本原因就是 面试 该死的 var 关键字! 它就是一个设计错误! 不要去用它!

卑微
卑微

以一道笔试题收尾: 写一个函数, 第一次调用返回 0, 之后每次调用返回比之前大 1. 这道题不难, 主要是在考察闭包和立即执行函数. 我写的答案如下, 如果你有更好的方案请在评论区分享.

const add = (() => { let num = 0; return () => num++; })();

参考

《JavaScript 高级程序设计 (第三版)》 —— Nicholas C. Zakas

《深入理解 ES6》 —— Nicholas C. Zakas

《你不知道的 JavaScript (上卷)》—— Kyle Simpson

javascript 的词法作用域

《JavaScript 闯关记》之作用域和闭包

深入理解 JavaScript 作用域和作用域链

JavaScript 编译原理, 编译器, 引擎及作用域

作用域闭包, 你真的懂了吗?


欢迎关注我的公众号: 进击的前端

Yancey_FE
Yancey_FE

也谈 JavaScript 的 this

PREVIOUS POST

也谈 JavaScript 的 this

从感性角度学习原型和原型链

NEXT POST

从感性角度学习原型和原型链

    Search by