回顾上节课我们讲到了,浏览器作为 V8 的宿主环境提供了在执行JavaScript代码时所需的运行环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。
V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行易于人类理解的 JavaScript 代码。
准备好运行时环境之后,V8 才可以执行 JavaScript 代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。
那么 V8 又是怎么执行 JavaScript 代码的呢?
处理器不能直接识别由高级语言所编写的代码通常,有两种方式来执行这些代码。第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。具体流程如下图所示:
第二种是编译执行。采用这种方式时,也需要先将源代码转换为中间代码,然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。
解释器的利弊
解释器启动和执行的更快。你不需要等待整个编译过程完成就可以运行你的代码。从第一行开始翻译,就可以依次继续执行了。
正是因为这个原因,解释器看起来更加适合 JavaScript。对于一个 Web 开发人员来讲,能够快速执行代码并看到结果是非常重要的。
这就是为什么最开始的浏览器都是用 JavaScript 解释器的原因。
可是当你运行同样的代码一次以上的时候,解释器的弊处就显现出来了。比如你执行一个循环,那解释器就不得不一次又一次的进行翻译,这是一种效率低下的表现。
编译器的利弊
编译器的问题则恰好相反。
它需要花一些时间对整个源代码进行编译,然后生成目标文件才能在机器上执行。对于有循环的代码执行的很快,因为它不需要重复的去翻译每一次循环。
另外一个不同是,编译器可以用更多的时间对代码进行优化,以使的代码执行的更快。而解释器是在 runtime 时进行这一步骤的,这就决定了它不可能在翻译的时候用很多时间进行优化。
V8 并没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为 JIT(Just In Time)技术。
这是一种权衡策略,因为这两种方法都各自有各自的优缺点,解释执行的启动速度快,但是执行时的速度慢,而编译执行的启动速度慢,但是执行时的速度快。你可以参考下面完整的 V8 执行 JavaScript 的流程图:
我们先看上图中的最左边的部分,在 V8 启动执行 JavaScript 之前,它还需要准备执行 JavaScript 时所需要的一些基础环境,这些基础环境包括了”堆空间” “栈空间” “全局执行上下文” “全局作用域” “消息循环系统” “内置函数” 等,这些内容都是在执行 JavaScript 过程中需要使用到的。基础环境准备好之后,接下来就可以向 V8 提交要执行的 JavaScript 代码了。
首先 V8 会接收到要执行的 JavaScript 源代码,不过这对 V8 来说只是一堆字符串,V8 并不能直接理解这段字符串的含义,它需要结构化这段字符串。
结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。
V8 源代码的结构化之后,就生成了抽象语法树 (AST),我们称为 AST,AST 是便于 V8 理解的结构, 在生成 AST 的同时,V8 还会生成相关的作用域,作用域中存放相关变量。
有了 AST 和作用域之后,接下来就可以生成字节码了,字节码是介于 AST 和机器代码的中间代码。解释器按照顺序解释执行字节码,并输出执行结果。
接下来看图上的机器人,其实是一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息。如果同一行代码运行了几次,这个代码段就被标记成了 “warm”,如果运行了很多次,则被标记成 “hot”。
如果一段代码变成了 “warm”,那么 JIT 就把它送到编译器去编译,并且把编译结果存储起来。
代码段的每一行都会被编译成一个 stub code ,同时给这个stub分配一个以”行号 + 变量类型”的索引。如果监视器监视到了执行同样的代码和同样的变量类型,那么就直接把这个已编译的版本 push 出来。
反优化
如果一个代码段变得 “very hot”,监视器会把它发送到优化编译器中。V8 就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时,那么 V8 会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升。
不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行,并且把优化代码丢掉。
这一过程叫做去优化。
惰性解析的过程
值得注意的是:在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码。
其主要有两个方面的原因:
- 如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度增加用户的等待时间;
- 解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存资源。
基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码。
关于惰性解析,我们可以结合下面这个例子来分析下:
function foo(a,b) {
var d = 100;
var f = 10;
return a+b+d+f;
}
当把这段代码交给 V8 处理时,V8 会至上而下解析这段代码,在解析过程中首先会遇到 foo 函数,由于这只是一个函数声明语句,V8 在这个阶段只需要将该函数转换为函数对象,如下图所示:
注意,这里只是将该函数声明转换为函数对象,但是并没有解析和编译函数内部的代码,所以也不会为 foo 函数的内部代码生成抽象语法树。
上去是不是很简单,不过在 V8 实现惰性解析的过程中,需要支持 JavaScript 中的闭包特性,这会使得 V8 的解析过程变得异常复杂。
闭包给惰性解析带来的问题
我们来了解下 JavaScript 函数特征你就知道了。
第一,JavaScript 语言允许在函数内部定义新的函数,代码如下所示:
function fun(){
function wait(){
}
}
这和其他的流行语言有点差异,在其他的大部分语言中,函数只能声明在顶层代码中,而 JavaScript 中之所以可以在函数中声明另外一个函数,主要是因为 JavaScript 中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。
第二,可以在内部函数中访问父函数中定义的变量,代码如下所示:
function fun(){
var a = 1;
function wait(){
return a+1;
}
}
可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量,注意上面代码中的 wait 函数和 fun 函数,wait 是在 fun 函数内部定义的,我们就称 wait 函数是 fun 函数的子函数,fun函数是 wait 函数的父函数。
这里的父子关系是针对词法作用域而言的,因为词法作用域在函数声明时就决定了。词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。
每个函数有自己的词法作用域,该函数中定义的变量都存在于该作用域中,然后 V8 会将这些作用域按照词法的位置,也就是代码位置关系,将这些作用域串成一个链,这就是词法作用域链,查找变量的时候会沿着词法作用域链的途径来查找。
第三,因为函数是一等公民,所以函数可以作为返回值,我们可以看下面这段代码:
function fun(){
var a = 1;
return function wait(){
return a+1;
}
}
var ref = fun();
调用 fun 函数返回值(函数)给了全局变量 ref,接下来就可以在外部像调用 fun 函数一样调用ref了。
这也是 JavaScript 过于灵活的一个原因,比如在 C/C++ 中,你就不可以在一个函数中定义另外一个函数,所以也就没了内部函数访问外部函数中变量的问题了。
了解了 JavaScript 的这三个特性之后,下面我们就来使用这三个特性组装的一段经典的闭包代码:
function fun(){
var a = 1;
return function wait(){
return a+1;
}
}
var ref = fun();
观察上面上面这段代码,我们在 fun 函数中定义了 wait 函数,并返回 wait 函数,同时在 wait 函数中访问了 foo 函数中的变量 a。
我们可以分析下上面这段代码的执行过程:
- 当调用 fun 函数时,fun 函数会将它的内部函数 wait 返回给全局变量 ref;
- 然后 fun 函数执行结束,执行上下文被 V8 销毁;
- 虽然 fun 函数的执行上下文被销毁了,但是依然存活的 wait 函数引用了 fun 函数作用域中的变量 a。
按照通用的做法,a 已经被 v8 销毁了,但是由于存活的函数 wait 依然引用了 fun 函数中的变量 a,这样就会带来两个问题:
- 当 fun 执行结束时,变量 a 该不该被销毁?如果不应该被销毁,那么应该采用什么策略?
- 如果采用了惰性解析,那么当执行到 fun 函数时,V8 只会解析 fun 函数,并不会解析内部的 wait 函数,那么这时候 V8 就不知道 wait 函数中是否引用了 fun函数的变量 a。
我们知道JavaScript 是一门基于堆和栈的语言,接下就模拟上面这段代码的执行流程观测堆栈的变化。
如下图所示:
从上图可以看出来,在执行全局代码时,V8 会将全局执行上下文压入到调用栈中,然后进入执行 fun 函数的调用过程,这时候 V8 会为 fun 函数创建执行上下文,执行上下文中包括了变量 a,然后将 fun 函数的执行上下文压入栈中,fun 函数执行结束之后,fun 函数执行上下文从栈中弹出,这时候 fun 执行上下文中的变量 a 也随之被销毁。
但是这时候,由于 wait 函数被保存到全局变量中了,所以 wait 函数依然存在,最关键的地方在于 wait 函数使用了 fun 函数中的变量 a,按照正常执行流程,变量 a 在 fun 函数执行结束之后就被销毁了。
所以正常的处理方式应该是 fun 函数的执行上下文虽然被销毁了,但是 wait 函数引用的 fun 函数中的变量却不能被销毁,那么 V8 就需要为这种情况做特殊处理,需要保证即便 fun 函数执行结束,但是 fun 函数中的 a 变量依然保持在内a存中,不能随着 fun 函数的执行上下文被销毁掉。
那么怎么处理呢?在执行 fun 函数的阶段,虽然采取了惰性解析,不会解析和执行 fun 函数中的 wait 函数,但是 V8 还是需要判断 wait 函数是否引用了 fun 函数中的变量,负责处理这个任务的模块叫做预解析器。
预解析器如何解决了什么问题?
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。
- 一,是判断当前函数是不是存在一些语法上的错误。
- 二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用。
由于 JavaScript 是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,下次执行到该函数的时候,直接使用堆中的引用。
本章节到此就要结束了 希望大家对闭包有所了解!回归到最初的几个问题。
闭包会造成内存泄漏吗?
答案是不会:内存泄漏本质是不在需要的内存依然没有被释放。 如果下次有人问你什么是闭包?
你可以这么回复他:”闭包是绑定了执行环境的函数”。
执行环境的组成部分:
- 词法环境
词法环境就是指查找作用域的顺序是按照函数定义时的位置来决定的。即函数中查找变量,其查找顺序都是按照当前函数作用域 => 全局作用域这个路径来的。
- 变量环境
在ES6前声明变量都是通过var关键词声明的,在ES6中则提倡使用let和const来声明变量,为了兼容var的写法,于是使用变量环境来存储var声明的变量。 变量环境本质上仍是词法环境,但它只存储var声明的变量,这样在初始化变量时可以赋值undefined。
- this
为什么会流传闭包造成内存泄漏这种说法?
在低版本的IE浏览器中,由于BOM和DOM中的对象是使用C++以COM对象的方式实现的,而COM对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。
扩展:
当内存不再需要使用时释放,在这里最艰难的任务是找到哪些被分配的内存确实已经不再需要了。
怎么确定不在需要?明确的说垃圾回收器是无法判断的。但是早期做法依赖了“引用”的概念来判断。
把 “内存是否不再需要” 转化为 “对象是否不再需要” 在简化定义为“**对象有没有其他地方引用到它**”。如果没有引用指向该对象(零引用) 对象将被垃圾回收机制回收,这就是引用计数。
这也是IE浏览器早期的垃圾回收机制,但是有 bug,IE 在我们使用完闭包之后,依然回收不了闭包里面引用的变量。这是 IE 的问题,不是闭包的问题,这也是流传闭包会导致内存泄露的主要原因。
举个栗子:
function fun(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
}
fun();
o, o2两个对象被创建,并互相引用,形成了一个循环。引用计数都为1,当调用 fun 执行完毕 v8销毁执行上下文但是o、o2 这两个它们不会被回收。
改进:
所有现代浏览器都使用了标记-清除垃圾回收算法,算法假定设置一个叫做根Root的对象。
垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。如上述代码函数调用返回之后,两个对象从全局对象出发无法获取因此,他们将会被垃圾回收器回收。
最后强调:闭包不会造成内存泄漏,程序写错了才会造成内存泄漏。