JS中由闭包引发内存泄露的深思

目录

  • 一个存在内存泄露的闭包实例
    • 什么是内存泄露
    • JS的垃圾回收机制
    • 什么是闭包
    • 什么原因导致了内存泄露
  • 参考

1.一个存在内存泄露的闭包实例

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的垃圾回收机制?什么是闭包?


(1)什么是内存泄露

应用程序不再用到的内存,由于某些原因,没有及时释放,就叫做内存泄漏。


(2)JS的垃圾回收机制

不同的编程语言管理内存的方式各不相同。一些高级编程语言的解释器或运行时嵌入了“垃圾回收器”,通过算法可自动的进行内存的分配与释放管理(比如 JavaScript、Java、C# 等)。另一些则寄希望于开发者自己手动地进行内存的分配与释放管理(比如 C/C++ 等)。

而JavaScript 是通过垃圾回收器来进行内存管理,其实现是基于标记-清除算法。而这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。其假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。在标记过程,垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。标记完成后就进行清除过程。(可达内存被标记,其余的被当作垃圾回收。)


(3)什么是闭包

开发人员经常错误将闭包简化理解成从父上下文中返回内部函数,或则简单归纳为能够读取其他函数内部变量的函数。

实际上,根据 ECMAScript,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

  2. 从实践角度:以下函数才算是闭包:

  • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)

  • 在代码中引用了自由变量


(4)什么原因导致了内存泄露

我们这里主要以实践角度来理解我们所讨论的闭包。

这里需要弄明白一个问题:为什么创建闭包函数的函数的上下文已经被销毁了(常规理解就是函数调用栈释放,函数内的临时变量被回收等),闭包函数依旧可以读取创建它的函数的内部变量?

从结果倒推,唯一能解释这一点的就是:虽然创建闭包函数的函数的上下文已经被销毁了,但被闭包函数所引用的变量没有被回收。那具体是如何实现的呢?

为了深入理解这个问题,这里就需要简单的谈一下函数中的作用域链:

当前函数的作用域链[[Scope]] = VO / AO + 父级函数的作用域链[[Scope]] 

补充说明:VO 和 AO 分别表示变量对象和活动对象,而变量对象可以理解为保存了当前上下文数据(变量、函数声明、函数参数)的一个对象,而活动对象是特殊的变量对象,简单理解就是函数的变量对象我们一般称之为活动对象,而在全局上下文里,全局对象自身就是变量对象。点击查看详细解释

在JS内部实现中,每个函数都会有一个 [[Scope]] 属性,表示当前函数的可以访问的作用域链。其实质上就是一个对象数组,包含了函数能够访问到的所有标识符(变量、函数等),用以查找函数所使用的到的标识符。而数组中从左到右的对象依次对应了由内到外的其他函数(或全局)的活动(变量)对象。另外,在 ECMAScript 中,同一个父上下文中创建的闭包是共用一个 [[Scope]] 属性的。换句话说,同一个函数内部的所有闭包共用这个函数的 [[Scope]] 属性。

对于闭包函数来说,为了实现其所引用的变量不会被回收,会保留它的作用域链(即 [[Scope]] 属性),不会被垃圾回收器回收。


那么上面的示例中,闭包函数 unused 与 someMethod 的作用域链如下图所示(函数和对象名加了数字后缀,用以区分replaceThing 函数多次调用而产生的同名函数与对象)

(1)replaceThing 函数第一次调用:

第一次调用的作用域链

如上图,在 replaceThing 函数第一次调用完,通过全局变量 theThing,可以访问到闭包函数 someMehtod1,因此其作用域链也会被保留,即 replaceThing1.[[Scope]] 将被保留,所以闭包函数 unused1就算没有被使用,也不会被回收。(全局变量直到程序运行结束前都不会被回收)


(2)replaceThing 函数第二次调用:

第二次调用的作用域链

如上图,在 replaceThing 函数第二次调用完,通过全局变量 theThing,可以访问到闭包函数 someMehtod2,因此其作用域链也会被保留,即 replaceThing2.[[Scope]] 将被保留,所以闭包函数 unused2 与对象 originalThing2 也将被保留,不会被回收。由于 originalThing2 可以访问到闭包函数 someMehtod1,因此之前第一次被保留的作用域链仍将继续被保留。

当 replaceThing 函数继续重复调用时,相当于上图中虚线框中的内容不断重复,而且相互之间类似形成一个链表,通过 全局变量 theThing 可以顺着链表到查找到第一次调用产生的对象 [Object1],这也就导致了垃圾回收器无法回收每次产生的新对象(里面包含一个大数组和一个闭包),造成严重的内存泄漏。


2.参考

内存管理- JavaScript | MDN

Chrome 浏览器垃圾回收机制与内存泄漏分析

4类 JavaScript 内存泄漏及如何避免

深入理解JavaScript系列(12):变量对象(Variable Object)

深入理解JavaScript系列(14):作用域链(Scope Chain)

深入理解JavaScript系列(16):闭包(Closures)

posted @ 2020-05-04 23:45  OneForCheng  阅读(3371)  评论(1编辑  收藏  举报