theme: qklhk-chocolate highlight: agate
JavaScript 代码的执行流程
一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。执行阶段则是指解释器解释执行字节码,或者是CPU直接执行二进制机器代码的阶段。
编译阶段
这个阶段,会将通过var声明的变量和函数声明进行提升。并且生成两个部分:执行上下文和可执行代码。
执行上下文是 JavaScript 执行一段代码时的运行环境。并且变量提升的内容就保存在这个执行上下文的变量环境的对象中。
执行阶段
就是分析可执行代码,并对变量环境中提升的变量做赋值操作。执行阶段是从上向下执行的,遇到相同的变量名和函数,会进行相互覆盖的。
function showName() { console.log('极客邦'); } showName(); // function showName() { // console.log('极客时间'); // } var showName = "zh"; showName(); // showName is not a function
请分析一下这段代码执行结果?
showName() function showName() { console.log(1) } var showName = function() { console.log(2) }
首先,showName会被提升并赋值为undefined。
然后函数声明也会进行提升,并且会覆盖变量的声明提升。
最后到了执行阶段,执行showName,将输出1。
调用栈
调用栈就是用来管理函数调用关系的一种数据结构。、
在执行js代码时,会创建很多的执行上下文,每创建一个执行上下文,他将被压入调用栈中。
当我们在编译阶段,就创建了全局执行上下文,并将其压入栈底。但是只有当函数调用的时候,才会创建函数执行上下文,并将其压入到栈中。
可以通过浏览器开发者工具中通过打断点来查看调用栈(call stack)。也可以通过console.trace()
打印出当前调用栈信息。
栈溢出
调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。
如何改变下面的代码,让其不会出现栈溢出?
function runStack (n) { if (n === 0) return 100; return runStack( n- 2); } runStack(50000)
通过定时器。
function runStack (n) { if (n === 0) return 100; return setTimeout(function(){runStack( n- 2)},0);}runStack(50000)
蹦床函数
function runStack (n) { if (n === 0) return 100; return setTimeout(function(){runStack( n- 2)},0);}console.log(runStack(50000))function runStack (n) { if (n === 0) return 100; return runStack.bind(null, n- 2); // 返回自身的一个版本}// 蹦床函数,避免递归function trampoline(f) { while (f && f instanceof Function) { f = f(); } return f;}console.log(trampoline(runStack(1000000)))
通过while循环
function runStack(n) { while(true) { if(n == 1 || n == 0) { return 100; } n -= 2; } }console.log(runStack(50000))
计算机是如何执行二进制代码的
我们知道,高级语言都需要被编译成字节码或者二进制代码才能被二进制代码执行。
程序的执行,本质上就是 CPU 按照顺序执行这一大堆指令的过程。
我们先来了解一下计算机的硬件构成。 首先,在程序执行之前,我们的程序需要被装进内存。内存是一个临时存储数据的设备,之所以是临时的存储器,是因为断电之后,内存中的数据都会消失。
CPU 可以通过指定内存地址,从内存中读取数据,或者往内存中写入数据,有了内存地址,CPU 和内存就可以有序地交互。
当二进制代码被加载到内存中后,那么内存中的每条二进制代码便都有了自己对应的地址。CPU 便可以从内存中取出一条指令(对应的二进制代码),然后分析该指令,最后执行该指令。
我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期。cpu将一直处于这些阶段之间进行切换。直到所有指令执行完成。
PC 寄存器,它保存了将要执行的指令地址。当二进制代码被装载进了内存之后,系统会将二进制代码中的第一条指令的地址写入到 PC 寄存器中,到了下一个时钟周期时,CPU 便会根据 PC 寄存器中的地址,从内存中取出指令。这样就能加速 CPU 的执行速度。因为直接读写内存,那么会严重影响程序的执行性能。
递归,宏任务,微任务执行特点
递归调用
function foo() { foo() // 是否存在堆栈溢出错误? } foo()
- 通过setTimeout调用```jsfunction foo() { setTimeout(foo, 0) // 是否存在堆栈溢出错误?}
通过Promise调用
function foo() { return Promise.resolve().then(foo) } foo()
由于调用栈有大小限制,所以递归调用会出现栈溢出。通过setTimeout调用,他不会使栈溢出,因为他执行的事件不会加入到栈中,而是交给浏览器事件循环队列完成。**每次执行完异步函数时,中间都可能会执行其他的事情,所以可不会造成页面卡死。**通过Promise调用呢?他也不会造成栈溢出,因为他执行的事件不会加入到栈中,而是交给浏览器事件循环队列完成。**但是每次在该队列中有任务的时候,是不会去执行其他的事情的,所以会造成页面卡死。**## 延迟解析和预解析在编译 JavaScript 代码的过程中,**V8 并不会一次性将所有的 JavaScript 解析为中间代码**,这主要是基于以下两点:- 如果一次解析和编译所有的 JavaScript 代码,过多的代码会**增加编译时间**,这会严重影响到首次执行 JavaScript 代码的速度,让**用户感觉到卡顿**。- 解析完成的字节码和编译之后的机器代码**都会存放在内存中**,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存。所有主流的 JavaScript 虚拟机都实现了**惰性解析**。所谓惰性解析是指解析器在解析的过程中,**如果遇到函数声明,那么会跳过函数内部的代码**,并不会为其生成 AST 和字节码,而**仅仅生成顶层代码的 AST 和字节码**。**所以,只有当执行函数的时候,才回去编译函数,将其转化为抽象语法树和字节码,然后再解释执行。**如果这样的话,怎么解释闭包呢?如果内部函数使用了外部函数中的变量,在未调用内部函数的时候是不会解析内部函数的,所以在外部函数执行完毕后,执行上下文应该被销毁了,并且内部保存的变量也应该被销毁啊。但是并没有,V8 通过**预解析器**来判断内部函数是否访问了外部函数的变量。V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么**预解析器并不会直接跳过该函数**,而是对该函数做一次快速的预解析,其主要目的有两个。- **判断当前函数是不是存在一些语法上的错误**。如果有错误,将会向 V8 抛出语法错误。- **检查函数内部是否引用了外部变量**,如果引用了外部的变量,**预解析器会将栈中的变量复制到堆中**。分析一下下面这段代码?a变量如何保存?```jsfunction foo() { var a = 0 return function inner() { return a++ }}
变量a同时在栈和堆上,当解析foo函数的时候,预解析有发现内部函数引用外部变量 a ,这时候就会把 a 复制 到堆上,当父函数执行到 a 的赋值语句时,会同时修改 栈和堆上的变量a的值, 父函数销毁的时候也只会销毁栈上的变量a,堆上的变量 a 保留。 最后当内部函数执行完后,堆上的变量a就没有再被引用,就会被垃圾回收掉.
为什么要引入字节码
早期的 V8 为了提升代码的执行速度,通过基线编译器直接将 JavaScript 源代码编译成了没有优化的二进制的机器代码,如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。
不过随着移动设备的普及,V8 团队逐渐发现将 JavaScript 源码直接编译成二进制代码存在两个致命的问题
时间问题:编译时间过久,影响代码启动速度。
空间问题:缓存编译后的二进制代码占用更多的内存。 如是就引入了字节码。有了字节码,无论是解释器的解释执行,还是优化编译器的编译执行,都可以直接针对字节来进行操作。
字节码如何提升代码启动速度? 解释器可以快速生成字节码,但字节码通常效率不高。 相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码,这正是 V8 在使用的模型。它的解释器叫 Ignition,(就原始字节码执行速度而言)是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan,最终由它生成高度优化的机器码。
字节码如何降低代码的复杂度?
早期的 V8 代码,无论是基线编译器还是优化编译器,它们都是基于 AST 抽象语法树来将代码转换为机器码的。这意味着基线编译器和优化编译器要针对不同的体系的 CPU 编写不同的代码,这会大大增加代码量。
引入了字节码,就可以统一将字节码转换为不同平台的二进制代码。
为什么静态语言的效率更高
因为静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。
所以V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的。并且为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
对象中所包含的所有的属性;
每个属性相对于对象的偏移量。 有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率。
在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类。 隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量。
多个对象共用一个隐藏类
现在我们知道了在 V8 中,每个对象都有一个 map 属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类,这样有两个好处:
减少隐藏类的创建次数,也间接加速了代码的执行速度;
减少了隐藏类的存储空间。 什么情况下两个对象的形状是相同的,要满足以下两点:
相同的属性名称;
相等的属性个数。
重新构建隐藏类
那些操作会让v8重新构建对象的隐藏类呢?
为对象添加新的属性
删除对象中的属性
改变对象中的属性类型。 所以以后使用对象需要注意以下几点:
使用字面量初始化对象时,要保证属性的顺序是一致的。如果创建的两个对象属性相同,那么他们的属性的顺序应该让其一样。
尽量使用字面量一次性初始化完整对象属性。
尽量避免使用 delete 方法。
回调函数
回调函数区别于普通函数,在于它的调用方式。只有当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,我们才称为回调函数。
回调函数有两种不同的形式,同步回调和异步回调。同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。
对于定时器,为了保证回调函数能在指定时间内执行,浏览器会将定时器的回调函数加入到另一个消息队列中(延迟队列)。
异步函数都会加入到浏览器的消息队列中,当当前主线程没有要执行的代码时,就会去消息队列中取出排队的回调函数,然后交给对应的进程执行。(比如如果是网络请求,那么它将交给网络进程处理)
主线程继续执行下面的任务。网络线程在执行下载的过程中,会将一些中间信息和回调函数封装成新的消息,并将其添加进消息队列中,然后主线程从消息队列中取出回调事件,并执行回调函数。
宏任务和微任务
宏任务
宏任务很简单,就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化(每个宏任务都在维护着自己作用域的任务)
最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。
微任务
微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
JavaScript 中之所以要引入微任务
主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,那么微任务可以在实时性和效率之间做一个有效的权衡。
另外使用微任务,可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用。
V8 会为每个宏任务维护一个微任务队列(就是每个宏任务中如果有微任务,那么将加入到宏任务自己的微任务队列中,这点对代码输出题判断很重要)。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。
下面我们来分析一下这段代码的执行过程
showName() function showName() { console.log(1) } var showName = function() { console.log(2) }0
v8的垃圾回收
垃圾回收算法
第一步,通过 GC Root 标记空间中活动对象和非活动对象。
通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象。
通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable)。不可访问的对象为非活动对象。 在浏览器环境中,GC Root 有很多:
全局的 window 对象(位于每个 iframe 中);
文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;
存放栈上变量。 第二步,回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
第三步,做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。
在 V8 中,会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。
所以对于上面的对象,v8采用了两种的垃圾回收器。
副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收。
主垃圾回收器 -Major GC,主要负责老生代的垃圾回收。
副垃圾回收器采用了Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。
但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作, 采用标记 - 清除(Mark-Sweep)的算法。在清除过程中可能会产生大量的碎片,不利于以后大对象的存储,所以就需要使用标记 - 整理(Mark-Compact)算法,来对内存碎片进行整合。,会经历标记、清除和整理过程。
我们知道垃圾回收是运行在js主线程上的,所以当回收量较大时,js将会阻塞过长的时间。
为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。
老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。
那些方式会引发内存泄漏
在函数中定义一个未声明的变量。
showName() function showName() { console.log(1) } var showName = function() { console.log(2) }1
他等价于下面这段代码
showName() function showName() { console.log(1) } var showName = function() { console.log(2) }2
该函数会被加入到window对象中,一直保存在内存中。所以应该避免使用。
闭包的使用。
showName() function showName() { console.log(1) } var showName = function() { console.log(2) }3
由上面代码可以看出,我们只是使用了temp_object中的x属性,但是foo执行上下文销毁时,并不会回收temp_object对象,从而抑制保存在内存中。
JavaScript 引用了 DOM 节点。当该dom元素在页面中删除时,但是js有变量引用了这个dom,他也不会销毁,依旧保存在内存中。
一些思考题
CPU 是怎么执行一段二进制机器代码的吗?
二进制代码装载进内存,系统会将第一条指令的地址写入到 PC 寄存器中。
读取指令:根据pc寄存器中地址,读取到第一条指令,并将pc寄存器中内容更新成下一条指令地址。
分析指令:并识别出不同的类型的指令,以及各种获取操作数的方法。
执行指令:由于cpu访问内存花费时间较长,因此cpu内部提供了通用寄存器,用来保存关键变量,临时数据等。指令包括加载指令,存储指令,更新指令,跳转指令。如果涉及加减运算,会额外让ALU进行运算。
指令完成后,通过pc寄存器取出下一条指令地址,并更新pc寄存器中内容,再重复以上步骤。
你认为 V8 虚拟机中的机器代码和字节码有哪些异同? 字节码是平台无关的,机器码针对不同的平台都是不一样的。
该文章总结自极客时间李兵老师的图解 Google V8课程。time.geekbang.org/column