V8引擎如何实现闭包?为了解答这个问题,我们需要先了解函数即对象, 惰性解析函数调用栈垃圾回收这些概念。当V8遇到闭包,一个本应被销毁的变量,因为还被其他地方引用,V8如何保留这个变量?这个变量会被放到哪里?

1. 什么是闭包?

2. 内存泄露

1.3 内存泄露

这些不再被需要的内存,由于某种原因,无法被释放,就是内存泄露。常见的内存泄露案例

  • 意外的全局变量
  • 被遗忘的计时器或回调函数
  • DOM引用
  • 闭包
1.3.1 意外创建全局变量

this被指向了全局变量window,意外地创建了全局变量。还有一些明确定义的全局变量,用来暂存大量数据,记得在使用后,对其重新赋值为null。或在javascript文件头部加上use strict

1
2
3
4
5
6
function fn() {
this.name = '123'
// name = '123'
}
fn()
console.log(name); // "123"

1.3.2 未销毁的定时器

没有回收定时器,整个定时器依然有效,不但定时器无法被内存回收,定时器函数的依赖也无法回收。

1.3.3 DOM引用

当删除button的DOM节点时,变量button仍保存在内存中。

1
2
3
4
5
6
var btn = document.getElementById('myBtn');
btn.onclick = function() {
btn.innerHTML = 'hello'
}
document.body.removeChild(btn);
btn = null;

1.3.4 闭包

闭包是指读取了其他函数内部变量的函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数。

1
2
3
4
5
6
7
8
function createComparisonFunction(propertyName) {
return function(object1, object2) {
var value1 = object1[propertyName];
var value2 = object2[propertyName];

return value1 - value2;
}
}

模拟私有方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var makeCounter = (function() {
var privateCounter = 0;
function change(val) {
privateCounte += val;
}

return {
increment: function() {
change(1)
},
decrement: function() {
change(-1)
},
value: function() {
return privateCounter;
}
}
})()
var counter1 = makeCounter();
counter1.increment();
console.log(counter1.value())

使用匿名闭包
使用匿名闭包,使得循环中被创建的方法,不会共享同一个词法作用域。或者,使用let而不是var,就不需要增加额外的闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}

function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];

for (var i = 0; i < helpText.length; i++) {
// (function() {
// var item = helpText[i];
// document.getElementById(item.id).onfocus = function() {
// showHelp(item.help);
// }
// })()
let item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}

setupHelp();

2. 函数即对象

首先我们要理解,函数是一个可执行的对象。它既可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。

我们定义一个函数function foo() {},接着设置foo.myName = 1。从控制台输出结果,可以得知,我们既可以给foo添加属性,也可以实现调用foo

1
2
3
4
5
6
function foo () {
console.log('foo')
}
foo.myName = 1
console.log(foo.myName) // 1
foo() // foo

因为在V8内部,会为函数对象添加两个隐藏属性namecodename是函数名,code的值是函数代码,当执行函数调用语句时,V8会从函数对象中取出code属性值,然后执行这段函数。

2. 函数调用栈 + 堆

定义好函数后,当我们执行这个函数时,其内部的临时变量会按照执行顺序被压入到栈中。

栈的结构和非常适合函数调用过程。在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。

虽然操作速度非常快,但是栈也是有缺点的,其中最大的缺点也是它的优点所造成的,那就是栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,

因此栈空间是有限的。因为栈空间是有限的,这就导致我们在编写程序的时候,经常一不小心就会导致栈溢出,比如函数循环嵌套层次太多,或者在栈上分配的数据过大,都会导致栈溢出,基于栈不方便存放大的数据,因此我们使用了另外一种数据结构用来保存一些大数据,这就是堆。

我们可以理解为,主线程的操作是一直是在调用栈上运作,而具体执行某个函数时,会为函数创建一个堆,函数的内部变量都会被放在堆上。

3. 当V8碰到闭包代码

我们知道了函数实际是一个可执行的对象,函数的变量会被存放在堆上。当V8碰到闭包代码时,会怎么操作?

3.1 惰性解析

首先,V8不会一次性将所有的JS解析为中间代码。

  • 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码都有 10 多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;

  • 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。

基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

3.2 预解析器

这样带来两个问题:

  • foo执行结束时,变量d该不该被销毁?
  • 如果采用惰性解析,执行到foo函数时,V8只会解析foo函数,不会解析内部的inner函数,那么V8就不知道inner中引用了变量d

正确的处理方式应该是:

  • foo函数的执行上下文被销毁
  • inner函数引用的foo函数中的变量d不能被销毁。
1
2
3
4
5
6
7
8
9
function foo () {
// 虽然 foo 函数的执行上下文被销毁了
// 但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d
const d = 200
return function inner (a, b) {
return a + b + d
}
}
const f = foo()

为了实现这一效果:
此时V8就要对此做特殊处理,负责处理这个任务的模块叫做预解析器

V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个

  • 判断是否存在语法错误
  • 检查函数内部是否引用了外部变量,如果引用了,就将该变量从栈中赋值到堆中。下次执行该函数时,直接使用堆中的引用,这样,这个外部变量(闭包)就被保留在函数的堆中。
1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
// Q: 变量a会被分配到栈上?还是堆上?
// A: 堆上。预解析器阶段在堆中复制了一个一样的a,调用foo函数使foo出栈栈中的a被销毁,只剩下堆中的a
var a = 0
return function inner() {
return a++
}
}
const t1 = foo()
const t2 = foo()
t1() // 0
t1() // 1
t2() // 0

当函数执行完后,函数的执行上下文被销毁,V8又是如何保证闭包不被销毁?这里就涉及到垃圾回收机制

4. 垃圾回收

垃圾回收的流程大致如下:

  1. V8从GC Roots对象出发,遍历所有GC Root中的所有对象,遍历到的对象,是可访问的,反之则是不可访问的。在浏览器环境中,GC Root有很多,包含下面几种:
    • 全局的window对象(位于每个iframe中)
    • 文档DOM(原生DOM节点)
    • 存放在栈上的变量
  2. 回收非活动对象所占据的内存
  3. 内存整理

针对长期存在,存活时间很短的两类对象,V8 采用了两个垃圾回收器来回收:主垃圾回收器 和 副垃圾回收器。

4.1 主垃圾回收器: 标记 + 清理

用来回收不死的、活得更久的对象,如全局的window、DOM、Web API等对象。回收步骤如下:

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

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

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。

4.2 副垃圾回收器:计数 + 清理

用来回收“朝生夕死”的对象。大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问。

V8把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。(内存分配快满时,将存活的对象从对象区域拷贝到空闲区域)

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

5. 结论

有了上面一系列的操作后,当 V8 解析一个函数的时候,判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那会将该变量存放到堆中,即便当前函数执行结束之后,该变量也不会被释放。