JavaScript内存管理和闭包
- JavaScript内存管理
- 垃圾回收机制算法
- 常见的GC算法-标记清除
- 闭包
- 闭包的概念理解
- 闭包的形成过程
- 闭包的内存泄露
JavaScript内存管理
JavaScript会在定义数据时为我们分配内存:
- JS对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配;
- JS对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;
垃圾回收机制算法
因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。
在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如free函数:
- 但是这种管理的方式其实
非常的低效
,影响我们编写逻辑的代码的效率
; - 并且这种方式
对开发者的要求也很高
,并且一不小心就会产生内存泄露
;
所以大部分现代的编程语言都是有自己的垃圾回收机制:
垃圾回收
的英文是Garbage Collection,简称GC
;- 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
- 而我们的语言运行环境,比如Java的运行环境JVM,JavaScript的运行环境js引擎都会内存垃圾回收器;
垃圾回收器
我们也会简称为GC
,所以在很多地方你看到GC其实指的是垃圾回收器;
常见的GC算法-标记清除
JS引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于V8引擎为了进行更好的优化
,它在算法的实现细节上也会结合一些其他的算法
。
-
标记整理(Mark-Compact)和“标记–清除”相似;
-
不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;
-
分代收集(Generational collection)—―对象被分成两组:“新的”和“旧的”。
-
许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
-
那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
-
增量收集(lncrementalcollection)
-
如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
-
所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
-
闲时收集(ldle-time collection)
-
垃圾收集器只会在CPU空闲时尝试运行,以减少可能对代码执行的影响。
闭包
闭包的概念理解
闭包的定义,分成两个:在计算机科学中
和在JavaScript中
。在计算机科学中对闭包的定义(维基百科)∶
-
闭包(英语: Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures) ;
-
是在支持头等函数的编程语言中,实现词法绑定的一种技术;
-
闭包在实现上是一个
结构体
,它存储了一个函数
和一个关联的环境
(相当于一个符号查找表); -
闭包跟函数最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行;
闭包的概念出现于60年代,最早实现闭包的程序是Scheme,那么我们就可以理解为什么JavaScript中有闭包:因为JavaScript中有大量的设计是来源于Scheme的;
MDN对JavaScript闭包的解释:
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起
(或者说函数被引用包围),这样的组合就是闭包
(closure);- 也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域;
- 在JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来;
总结:
- 一个普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数和周围环境就是一个闭包;
从广义的角度来说:JavaScript中的函数都是闭包;
从狭义的角度来说: JavaScript中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;
闭包的形成过程
JavaScript三大特性,而闭包产生的原因也正是因为这些特性:
- 可以在JavaScript函数内部定义新的函数;
- 内部函数中访问函数中的定义;
- 在JavaScript中,函数是一等公民,所以函数中既可以传入一个函数又可以作为参数返回一个函数。
function foo() {
var moment = 18;
var test = 111;
function bar() {
const may = moment + 777;
return may;
}
console.log(test);
return bar;
}
var baz = foo();
baz(); // 这就是闭包
- 当调用 foo 函数时,foo 函数会将它的内部函数 bar 返回给全局变量 baz;
- 等到 foo 函数执行结束时,执行上下文会被 V8 销毁;
- 按照正常的情况来说,变量 moment 已经被 V8 销毁了,因为我们知道 V8
引擎有垃圾回收期用来释放不再使用的内存空间,但是由于存活的函数 bar 依然引用了 foo 函数作用域中的变量moment,这样就会带来两个问题:
- 当 foo 执行结束时,变量 moment 该不该被销毁?如果不应该被销毁,那么他应该在什么时候销毁,而又应该采用什么策略?
- 我们都知道 V8 引擎采用的是惰性解析的方案,那么当执行到 foo 函数时, V8 只会解析 foo 函数,并不会解析内部的 bar函数,仅仅知识对 bar函数进行了标记,在这时 V8 引擎并不知道 bar 函数中是否引用了 外层函数作用域中的变量 moment;
-
由于 JavaScript 是一门基于堆和栈的语言。在执行全局代码时当执行,V8 会将全局执行上下文压入到调用栈中,然后进入执行 foo
函数的调用过程。 -
这时候 V8 引擎会为 foo 函数创建执行上下文,执行上下文中包括了变量 moment,然后将 foo 函数的执行上下文压入栈中,foo函数执行结束之后,foo 函数执行上下文从栈中弹出,这时候 foo 执行上下文中的变量 moment 也随之被销毁。
-
正常的处理方式应该是 foo 函数的执行上下文被销毁了,但是 bar 函数引用的 foo 函数中的变量却不能被销毁。
-
在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的的 bar 函数中的 bar 函数,但是 V8还是需要判断
bar 函数是否引用了 foo 函数中的变量。 -
V8 引擎引入了预解析器,当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该书做一次快速的预解析,其中主要的目的主要有两个:
- 判断当前函数是否是不是存在一些语法上的错误,如果发现语法错误,那么就会向 V8 抛出语法错误;
- 判断 foo 函数是否有被 bar 函数引用的变量,如果有,就会把该变量复制一份到堆内存中,同时 bar 函数本身也是一个对象,也会被存放到内存当中,这样即使 foo 函数即使执行完成,内存被释放以后,bar 函数在执行的时候,依然可以从堆内存中访问复制过来的变量;
闭包的内存泄露
内存泄漏(Memory Leak):是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。通俗点就是指由于疏忽或者错误造成程序未能释放已经不再使用的内存,不再用到的内存却没有及时释放,从而造成内存上的浪费。
内存泄露的几种原因:
- 定义全局变量:在局部作用域中,等函数执行完毕,变量就没有存在的必要了,垃圾回收机制很亏地做出判断并且回收,但是对于全局变量,很难判断什么时候不用这些变量,无法正常回收;所以,尽量少使用全局变量。
- 在使用闭包的时候,就会造成严重的内存泄漏,
因为闭包中的局部变量,会一直保存在内存中。
所以我们可以在退出函数之前将不使用的局部变量进行删除。 - 定时器:定时器setInterval或者setTimeout在不需要使用的时候,没有被clear,导致定时器的回调函数及其内部依赖的变量都不能被回收,这就会造成内存泄漏。
解决方式:当不需要interval或者timeout的时候,调用clearInterval或者clearTimeout
- 事件监听 DOM.addEventListener(“click”, callback)垃圾回收机制不好判断该事件是否需要被解除,导致 callback 不能被释放,此时需要手动解除绑定:DOM.removeEventListener(callback)
针对上述中闭包的内存泄露,再举例详细说明下:
(一个内存泄露的闭包实例)
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
上面代码片段做了一件事情:每隔1秒后调用 replaceThing 函数,全局变量 theThing 得到一个包含一个大数组和一个新闭包(someMethod)的新对象。同时,变量 unused 是一个引用 originalThing 的闭包。
初看之下,感觉应该不存在什么内存泄露问题。replaceThing 函数在每次调用完之后,应该就会释放或销毁 originalThing 和 unused 变量,毕竟这两个变量只在函数内部声明使用了,不能够在 replaceThing 函数外面被使用。而留在内存中的就只剩每次新分配给全局变量 theThing 的新对象。
但实际上面的直观感受是错误,为了弄清楚上面的代码为什么存在内存泄露,我们首先需要弄清楚几个概念与原理:什么是内存泄露?JS的垃圾回收机制?什么是闭包?
我们这里主要以实践角度来理解我们所讨论的闭包。
这里需要弄明白一个问题:为什么创建闭包函数的函数的上下文已经被销毁了(常规理解就是函数调用栈释放,函数内的临时变量被回收等),闭包函数依旧可以读取创建它的函数的内部变量?
从结果倒推,唯一能解释这一点的就是:虽然创建闭包函数的函数的上下文已经被销毁了,但被闭包函数所引用的变量没有被回收。那具体是如何实现的呢?
为了深入理解这个问题,这里需要再回顾上篇提到的函数中的作用域链:
- 当前函数的作用域链[[Scope]] = VO / AO + 父级函数的作用域链[[Scope]] 补充说明:VO 和 AO 分别表示变量对象和活动对象,而变量对象可以理解为保存了当前上下文数据(变量、函数声明、函数参数)的一个对象,而活动对象是特殊的变量对象,简单理解就是函数的变量对象我们一般称之为活动对象,而在全局上下文里,全局对象自身就是变量对象。
- 在JS内部实现中,每个函数都会有一个 [[Scope]] 属性,表示当前函数的可以访问的作用域链。其实质上就是一个对象数组,包含了函数能够访问到的所有标识符(变量、函数等),用以查找函数所使用的到的标识符。而数组中从左到右的对象依次对应了由内到外的其他函数(或全局)的活动(变量)对象。另外,在ECMAScript 中,同一个父上下文中创建的闭包是共用一个 [[Scope]]属性的。换句话说,同一个函数内部的所有闭包共用这个函数的 [[Scope]] 属性。
- 对于闭包函数来说,为了实现其所引用的变量不会被回收,会保留它的作用域链(即 [[Scope]] 属性),不会被垃圾回收器回收。
那么上面的示例中,闭包函数 unused 与 someMethod 的作用域链如下图所示(函数和对象名加了数字后缀,用以区分replaceThing 函数多次调用而产生的同名函数与对象)
如上图,在 replaceThing 函数第一次调用完,通过全局变量 theThing,可以访问到闭包函数 someMehtod1,因此其作用域链也会被保留,即 replaceThing1.[[Scope]] 将被保留,所以闭包函数 unused1就算没有被使用,也不会被回收。(全局变量直到程序运行结束前都不会被回收)
如上图,在 replaceThing 函数第二次调用完,通过全局变量 theThing,可以访问到闭包函数 someMehtod2,因此其作用域链也会被保留,即replaceThing2.[[Scope]] 将被保留,所以闭包函数 unused2 与对象 originalThing2 也将被保留,不会被回收。由于 originalThing2 可以访问到闭包函数 someMehtod1,因此之前第一次被保留的作用域链仍将继续被保留。
当 replaceThing 函数继续重复调用时,相当于上图中虚线框中的内容不断重复,而且相互之间类似形成一个链表,通过 全局变量 theThing 可以顺着链表到查找到第一次调用产生的对象 [Object1],这也就导致了垃圾回收器无法回收每次产生的新对象(里面包含一个大数组和一个闭包),造成严重的内存泄漏。