JavaScript 是一种弱类型的、动态的语言。这意味着我们不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,且可以使用同一个变量保存不同类型的数据。
如图,JavaScript 一共有 8 种数据类型,它们可以分为两大类——原始类型(前7种)和引用类型(最后1种)。其中,原始类型的数据是存放在栈中,引用类型的数据是存放在堆中的。堆中的数据是通过引用和变量关联起来的。也就是说,JavaScript 的变量是没有数据类型的,值才有数据类型,变量可以随时持有任何类型的数据。
为什么一定要分“堆”和“栈”两个存储空间呢?因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据。
从内存模型的角度再来谈谈闭包:
function foo() {
var myName = "极客时间"
let test1 = 1
const test2 = 2
var innerBar = {
setName:function(newName){
myName = newName
},
getName:function(){
console.log(test1)
return myName
}
}
return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())
当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。
可以画出执行到return innerBar语句时的调用栈状态:
从上图可以清晰地看出,当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“clourse(foo)”对象,所以即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。
垃圾数据回收分为手动回收(C/C++,由代码控制)和自动回收(JavaScript,Java等,由垃圾回收器来释放)两种策略。
function foo(){
var a = 1
var b = {name:"极客邦"}
function showName(){
var c = 2
var d = {name:"极客时间"}
}
showName()
}
foo()
当一个函数执行结束之后,JavaScript 引擎通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。如图为从栈中回收 showName 执行上下文:
从图中可以看出,当 showName 函数执行结束之后,ESP 向下移动到 foo 函数的执行上下文中,上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当 foo 函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。
在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。副垃圾回收器,主要负责新生代的垃圾回收。主垃圾回收器,主要负责老生代的垃圾回收。不论什么类型的垃圾回收器,它们都有一套共同的执行流程:标记空间中活动对象和非活动对象->回收非活动对象所占据的内存->内存整理(频繁回收对象后,内存中会存在大量不连续空间,即内存碎片)
算法:Scavenge 算法
原理:
1、把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。
2、新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
3、先对对象区域中的垃圾做标记,标记完成之后,把这些存活的对象复制到空闲区域中
4、完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。
对象晋升策略:经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
(1)算法:标记 - 清除(Mark-Sweep)算法
原理:
1、标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象(比如某块数据被一个变量b引用了,那么这块数据会被标记为活动对象),没有到达的元素就可以判断为垃圾数据。
2、清除:将垃圾数据进行清除。
碎片:对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存。
(2)算法:标记 - 整理(Mark-Compact)算法
标记过程仍然与标记 - 清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
(3)优化算法:增量标记(Incremental Marking)算法
原理:
1、为了降低老生代的垃圾回收而造成的卡顿
2、V8把一个完整的垃圾回收任务拆分为很多小的任务
3、让垃圾回收标记和 JavaScript 应用逻辑交替进行
(1)生成抽象语法树(AST)和执行上下文
执行上下文主要是代码在执行过程中的环境信息,而AST可以看成是代码结构化的表示。AST的生成过程,先分词(词法分析),再解析(语法分析)。
补充:AST是一种非常重要的数据结构,有着广泛的应用。如Babel (可以将 ES6 代码转为 ES5 代码)的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码;ESLint (检查 JavaScript 编写规范的插件)的检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。
(2)生成字节码(解释器)
字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。
(3)执行代码
解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
补充:Ignition->点火器,TurboFan->螺旋增压,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高。
对于优化 JavaScript 执行效率,应该将优化的中心聚焦在单次脚本的执行时间和脚本的网络下载上,主要关注以下三点内容:
(1)提升单次脚本的执行速度,避免 JavaScript 的长任务霸占主线程,这样可以使得页面快速响应交互;
(2)避免大的内联脚本,因为在解析 HTML 的过程中,解析和编译也会占用主线程;
(3)减少 JavaScript 文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。