闭包,内存泄露
V8引擎如何实现闭包?为了解答这个问题,我们需要先了解函数即对象, 惰性解析、函数调用栈、垃圾回收这些概念。当V8遇到闭包,一个本应被销毁的变量,因为还被其他地方引用,V8如何保留这个变量?这个变量会被放到哪里?
1. 什么是闭包?
2. 内存泄露
1.3 内存泄露
这些不再被需要的内存,由于某种原因,无法被释放,就是内存泄露。常见的内存泄露案例
- 意外的全局变量
- 被遗忘的计时器或回调函数
- DOM引用
- 闭包
1.3.1 意外创建全局变量
this被指向了全局变量window,意外地创建了全局变量。还有一些明确定义的全局变量,用来暂存大量数据,记得在使用后,对其重新赋值为null。或在javascript文件头部加上use strict
。1
2
3
4
5
6function 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
6var 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
8function 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
21var 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
26function 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
6function foo () {
console.log('foo')
}
foo.myName = 1
console.log(foo.myName) // 1
foo() // foo
因为在V8内部,会为函数对象添加两个隐藏属性name
和code
。name
是函数名,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 | function foo () { |
为了实现这一效果:
此时V8就要对此做特殊处理,负责处理这个任务的模块叫做预解析器
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个
- 判断是否存在语法错误
- 检查函数内部是否引用了外部变量,如果引用了,就将该变量从栈中赋值到堆中。下次执行该函数时,直接使用堆中的引用,这样,这个外部变量(闭包)就被保留在函数的堆中。
1 | function foo() { |
当函数执行完后,函数的执行上下文被销毁,V8又是如何保证闭包不被销毁?这里就涉及到垃圾回收机制。
4. 垃圾回收
垃圾回收的流程大致如下:
- V8从GC Roots对象出发,遍历所有GC Root中的所有对象,遍历到的对象,是可访问的,反之则是不可访问的。在浏览器环境中,GC Root有很多,包含下面几种:
- 全局的window对象(位于每个iframe中)
- 文档DOM(原生DOM节点)
- 存放在栈上的变量
- 回收非活动对象所占据的内存
- 内存整理
针对长期存在,存活时间很短的两类对象,V8 采用了两个垃圾回收器来回收:主垃圾回收器 和 副垃圾回收器。
4.1 主垃圾回收器: 标记 + 清理
用来回收不死的、活得更久的对象,如全局的window、DOM、Web API等对象。回收步骤如下:
由于老生代的对象比较大,若要在老生代中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。
首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。
4.2 副垃圾回收器:计数 + 清理
用来回收“朝生夕死”的对象。大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问。
V8把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。(内存分配快满时,将存活的对象从对象区域拷贝到空闲区域)
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
5. 结论
有了上面一系列的操作后,当 V8 解析一个函数的时候,判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那会将该变量存放到堆中,即便当前函数执行结束之后,该变量也不会被释放。