深入理解 JavaScript 运行时

1,640 阅读14分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第22篇文章,点击查看活动详情

前言

凡是可以用 JavaScript 来写的应用,最终都会用 JavaScript 来写。——Atwood定律(Jeff Atwood在2007年提出)

为何要理解 JavaScript 运行时

你是如何分析 JavaScript 的作用域(链)、原型(链)的?框架的呢

随着 javascript 越来越流行,JavaScript 被应用到前端、后台、hybrid 应用、嵌入式等各种领域并衍生出了各种对应的技术框架;由于JavaScript还在高速发展中,新的技术框架层出不穷,旧的技术框架也在不断迭代优化。面对层出不穷的新框架和不断变化的技术,我们如何该如何面对呢?

不管技术框架如何演变,只要是 JavaScript 代码最终都是要运行的。在经过编译打包后放到 js 引擎上执行过程是基本不变的,只要抓住了这一点,同时构建一套合理的底层运作机制知识图谱,我想不管 JavaScript 技术如何演变我们都可以非常快速的掌握。

本文通过详解 JavaScript 特性,如声明提升、数据类型、内存管理(堆、栈、GC)、作用域(链)、闭包、this指向等来深入理解 JavaScript 运行时

声明提升

在 JavaScript 代码被 JavaScript 引擎加载执行过程中,首先遇到的就是:声明提升

声明提升分为 var 提升和 function 提升;就是将var变量和 函数的声明提升至文档最顶部。

看看如下两个例子

var name = "window";

function log() {
  console.log(name);
}
log(); // window
log(); // undefined
var name = "window";

function log() {
  console.log(name);
}

第一个例子打印 name 为 window,第二个例子打印 name 为 undefined

原因是这两段代码被 JavaScript 引擎解析后重新做了如下排列

var name;
function log() {
  console.log(name);
}
name = "window";
log();
var name;
function log() {
  console.log(name);
}
log();
name = "window";

重新整理后的两段代码和原版相比都是将声明语句提升至顶部,而执行语句的顺序不变。

了解完提升这个特性后,我们来看看 js 执行时是如何管理内存的。

内存管理

JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理

具体可以查看 MDN 上的内容:内存管理-JavaScript | MDN

虽然 JavaScript 的内存是由其执行引擎自动管理的我们无需关心,但是对其原理的深入理解可以提高我们的工作效率,避免掉入一些陷阱,或者是用作排查问题、分析复杂程序的参考等,也可以指导我们写出更合理的代码,毕竟内存的性能对计算机执行效率影响非常大。

那么 JavaScript 执行过程中,内存空间是如何管理的呢?

答案是使用栈和堆来管理内存空间。

在讲解栈和堆之前先来了解 JavaScript 的数据类型

数据类型

JavaScript 的数据类型可以按照以下两大类区分:

按值引用类型: String、Number、Boolean、null、undefined、Symbol;所谓按值引用就是内容比较小可以直接存放在栈中的数据

按地址引用类型: Object,包含 function、Array、Date、Regex等;所谓按地址引用就是栈中存放的是指针(可以理解为指向其在堆中内存的地址),取值时根据栈中指针找到堆中的内存地址再读取具体内容

虽然理论上是这么分的,但是在js执行过程中,按值引用类型 也可能会被移动到堆中存放,比如占用内存超大的字符串(会被特殊处理使其仍有按值引用特性)、闭包等

了解了 JavaScript 不同数据类型的内存管理方式,我们再来看看对应的管理工具:栈、堆

栈的特点是内存小且连续,类似数组结构,遵循 LIFO 后进先出的规则。

在 JavaScript 执行过程中,会有以下场景使用到栈

  1. 按值引用类型 的临时变量
  2. 函数调用栈(call stack)。解释器用来存储函数的调用流程顺序的,我们有时候会碰到 Maximum call stack size exceeded报错就是超出栈的调用上限了爆栈了。
  3. 函数执行本身会得到一个栈帧(Stack Frame)。解释器用来存储当前执行函数的相关信息,包括参数、函数变量、执行上下文

栈的特性使其在处理需要快速操作、内存空间占用小且内存连续的场景有很大优势,只需要移动指针即可,同时栈的 LIFO 后进先出规则也非常适合用来管理函数调用关系(调用栈)以及函数本身执行(栈帧)。

当数据结构复杂的时候如果使用栈,那么在取值时必定需要按顺序遍历查找,这是不划算的,尤其是在使用空间很大时需要查找非常久,想想在一串数组中查找某个值,同时分配的数据过大会造成栈溢出问题

所以我们需要堆来存储大数据或者结构复杂的数据,这种数据结构需要满足以下要求:

  • 可以存大数据或结构复杂的数据
  • 数据查找速度快

以 v8引擎为例堆的使用主要有以下场景:

  1. 按地址引用类型 的变量
  2. 新生代,老生代
  3. 闭包导致 按值引用类型 迁移到老生代
  4. 大对象区
  5. 代码区
  • V8 内存架构

js内存.png

GC

了解了内存的分配,接着我们来看看内存的回收

为什么需要回收内存?原因有如下几点:

  1. 按地址引用类型在使用时会动态分配内存,在变量不再使用后需要释放,否则会内存耗尽而造成崩溃
  2. 堆中内存在代码执行过程中,会被使用的坑坑洼洼的,内存不连续,这对内存数据存取操作并不友好,如果这时需要一块很大的连续的内存可能也无法分配出来,所以需要垃圾回收并重新整理内存使其规范有序
  3. 闭包也可能会产生新的按地址引用类型变量,这些变量如果一直不清除很可能造成内存泄漏
  4. 根据上述几个问题我们知道内存回收很有必要,但是 JavaScript 语言特性使开发者卸下了内存维护的负担,也就是说需要引擎去实现相关功能

以V8引擎的分代回收法标记清除法来简单讲讲垃圾回收

  • 新生代

新生代内存在64位系统中为32M,在32位系统中为16M,新生代内存有两个区(From space 和 To space),各占一半内存

运行过程中只使用 From space 内存,当其内存快满时开始垃圾回收,回收过程采用Scavenge 算法,将不可达对象都打上标记,将的未标记对象复制到To space(可以做到内存连续),然后清空 From space,此时To space 和 From space 反转,第二次回收时,经历了From space -> To space 的迁移至老生代

此外,分配的内存较大时直接分配给老生代

新生代好处是,不需要等待特定时间进行回收操作,只要内存不够了就可以进行垃圾回收,可以快速解决小范围的内存问题

  • 老生代

老生代内存在32位系统中约为700M,在64位系统中约为1.4G

老生代采用mark-sweep标记清除和mark-compact标记整理

  • 标记清除

先遍历所有对象并标记活动对象(即可达的对象),然后再次遍历所有对象,同时清除未标记的对象,去掉被标记对象的标记,最后进行空间回收。

标记清除的优点是可以回收循环对象的引用,同时也带来个问题就是造成空间碎片化,因为他只负责空间回收不做整理,于是才需要标记整理

  • 标记整理

标记整理可一看作是标记清除的一个增强,同标记清除一样,先遍历所有对象,并标记所有活动对象;然后清除未标记的变量,只是在清除之前要移动将要被清除变量的位置,以便于释放出连续的空间,从而避免标记清除的缺点,就是相当于不仅做空间回收还负责把空间整理。

作用域与作用域链

  • 作用域(scope)

作用域大小分为程序级文件级函数级块级

javascript 的作用域分为全局作用域函数作用域块级作用域(ES6)

作用域就是变量与函数的可访问的范围,即作用域控制着变量与函数的可见性生命周期

  • 作用域链

在 javascript 中,由于函数也是对象,和对象一样拥有可以通过代码访问的属性和一系列仅供 javascript 引擎访问的内部属性。其中一个内部属性是 [[Scope]],由ECMA-262标准第三版定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问

从内存的角度理解作用域

在上文 小节中,我们了解到函数的管理是由栈来完成的,而且由于其 LIFO 后进先出的规则,你有没有发现:栈很好的管理了函数的参数函数变量执行上下文

执行上下文 包含:变量环境、词法环境

词法环境是由内部 JavaScript 引擎构造,用来保存标识符变量的映射关系。同时,它还保存对父级词法环境的引用。这里的标识符就是指变量或者函数的名称,变量是对实际对象(包括函数类型对象)或者原始值的引用。每当 JavaScript 引擎创建执行上下文来执行函数或者全局代码时,就会创建一个新的词法环境,以存储在该函数执行期间在该函数中定义的变量。

词法环境有两个组成部分:

  • 环境记录:存储变量和函数声明的实际位置
  • 对外部环境的引用:实际上就是对外部或者说是父级词法环境的引用

也就是说词法环境就是在JavaScript 引擎创建一个执行上下文时,创建了用来存储变量和函数声明的环境,它可以使代码在执行期间,访问到存储在其内部的变量和函数,同时又可以访问父级词法环境,而在代码执行完毕之后,从内存中释放掉其内部的变量和函数和父级词法环境的引用

闭包

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

简单讲就是劫持了外部环境的 词法环境以及作用域

变量(即使是按值引用类型)被闭包时数据会被迁移到堆中,这是引擎在遇到多个作用域内部变量使用另一个作用域内的同一个变量时所产生的内存地址迁移,就是闭包的具体过程

this指向

在 javascrippt 中,this 指向函数执行时的当前对象。

这个当前对象可以理解为上下文的一部分(因为上下文还包含外部引用),主要有以下几种情况:

  1. 函数直接调用this默认指向全局上下文
  2. 点语法调用的函数,
  3. call apply bind:改变函数执行上下文
  4. new 关键字,新分配作用域上下文执行
  5. 箭头函数,拥有词法作用域的 this

点语法

function logThis() {
  console.log(this);
}

logThis(); // window

var obj = {
  logThis,
  log() {
    console.log(this); // obj
    logThis(); // window
  },
};
obj.log(); 
obj.logThis(); // obj

可以看到函数直接调用默认都是全局上下文,点语法把调用的函数的this指向了调用对象。

call、apply、bind和点语法功能相似,只是用法是直接传入需要this指向的对象,比较好理解就不举例了。

new 的this指向

function logThis() {
  console.log(this);
}

logThis(); // window

var newLog = new logThis(); // logThis {} 即new的实例
console.log(newLog); // logThis {}

new 的this指向为其new出来的实例

箭头函数

箭头函数比较特殊,它的this指向函数声明时的上下文,且不可改变

var obj = {
  log() {
    console.log(this); // obj
    var logThis = () => {
      console.log(this); // obj
    };
    logThis(); // obj
    logThis.apply(null); // obj
    logThis.call(null); // obj
    logThis.bind(null)(); // obj
  },
};
obj.log();

可能有人有疑问,例子中 obj 里的 log 声明时 this 是全局上下文,那么 logThis 声明时 this 也应该是全局上下文啊,怎么会是obj呢?

这是因为 javascript 引擎解析到函数时不会立即解析其内容,只有调用时才正式解析,而本例中 log 是通过 obj.log() 方式调用的,此时 log 内部this已经指向了 obj,所以其内部声明的箭头函数继承了它的上下文。

想要让 logThis 的 this 指向 window 可以把 log 做成箭头函数,如下

var obj = {
  log: () => {
    console.log(this); // window
    var logThis = () => {
      console.log(this); // window
    };
    logThis(); // window
    logThis.apply(null); // window
    logThis.call(null); // window
    logThis.bind(null)(); // window
  },
};
obj.log();

可以看到箭头函数的指向在声明时一经确认就不可改变。

原型与原型链

原型:在JavaScript 中,每定义一个对象时,对象中都会包含一些预定义的属性。其中函数对象的一个属性就是原型对象 prototype。普通对象没有 prototype 属性,但是有 __proto__ 属性

原型链:每个函数都有 prototype 属性,prototype 对象内有一个 countructor 属性,默认指向函数本身;每个对象都有 __proto__ 属性,该属性指向其父类的 prototype 属性,通过__proto__ 属性链接的关系可以称呼为原型链

一句话概括原型看 prototype,原型链看 __proto____proto__ 最终指向 null

事件循环(Event Loop)

什么是 Event Loop ?

程序运行以后叫做"进程"(process),一般情况下,一个进程一次只能执行一个任务。

如果有很多任务需要执行,那么有以下三种处理方式:

  • 排队;使用任务队列来调度
  • 新建进程执行,但进程太耗费资源
  • 新建线程来执行,进程中可以包含多个线程

为什么需要 Event Loop ?

我们知道负责处理 JavaScript 运行的是主线程,而主线程并不能处理所有事情,比如用户交互事件、网络请求、IO等,这些分属合成线程、网络线程、IO线程执行,执行完成后会给 主线程的消息队列注册回调任务,而合理安排这些任务的执行就是 Event Loop

宏任务(macro-task)

宏任务很简单,就是指消息队列中的等待被主线程执行的任务,每个宏任务在执行时,JavaScript 引擎都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化(可参看上文的内存管理),最当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行消息队列中下一个任务

注:V8 在宏任务之间的空闲时间会插入很小 GC 任务,这样可以更好了利用CPU资源

每个宏任务都包含一个微任务队列,当主任务执行完后,会从微任务队列中取出微任务执行直到当前宏任务下的微任务执行完再开始下一轮的 Event Loop 调度

可以注册宏任务的方式:scipt(整体代码)、事件监听、setTimeout、setInterval、setImmediate、I/O、requestAnimationFrame、网络请求

微任务(micro-task)

由于宏任务的时间颗粒度太粗了,微任务是用来实现实时性相对可靠的异步任务的

可以理解为:微任务是一个需要异步执行的任务,执行时机是宏任务主任务执行完后,且宏任务结束前

可以注册微任务的方式:process.nextTick、Promise(仅原生)、Object.observe、MutationObserver

最后

简单理解 JavaScript 运行时之后就可以愉快的 CRUD 了