从内存的角度理解 JS 闭包 💼

934 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

自打第一次听说“闭包”这个词开始,几年时间里,我一直云里雾里,好像懂,又知道似懂非懂其实不是真懂。光从字面意思本身看,“闭”字我懂,“包”字嘛虽然意义可能有多种,像钱包、豆沙包,但结合语境,一般也好懂,但“闭包”到底是什么鬼?最近学习了个新的课程,里面从内存的角度对闭包进行了讲解,感获颇丰,遂结合之前的理解,做个总结,有不足之处,还望各位斧正~

闭包理解

MDN 对闭包的解释是:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。

乍看 MDN 的解释,可能依旧不知所云,比如不知道什么是词法环境?但我们至少可以初步形成一个认识 —— 闭包它不是一个单一的存在,而是一个组合,里面包括了一个函数,以及这个函数所对应的词法环境。接下来我们定义一个高阶函数 singer,通过运行它,看看内存中发生了什么,来理解闭包。

function singer() {
  var name = 'Jay'
  function makeAlbum() {
    console.log(name + '新专辑明年就出啦~')
  }
  return makeAlbum
}
var joke = singer()
joke()

高阶函数

先解释下什么叫高阶函数吧。在 js 中,函数是作为“一等公民”的存在,即所谓一等函数(First-Class Functions)。函数可以作为参数传递给另一个函数,也可以作为另一个函数的返回值。因为在 js 中,函数归根结底也是一种特殊类型的对象。如果一个函数可以接收另一个函数作为参数,或者该函数的返回值是一个函数,那么我们就称该函数是一个高阶函数。上面定义的函数 singer,就返回了函数 makeAlbum,所以 singer 就是一个高阶函数。

内存模型

现在我们来看看运行上面的代码时(通过 Chrome),内存中发生了什么,是一种什么样的表现。

在代码的编译阶段,V8 引擎会在堆内存中生成全局变量(GO),里面存放 window 等全局属性和方法,如下图:
image.png
所有的代码都需要在栈内存中的 执行上下文调用栈(ECStack)内执行,想要执行全局代码,就会在调用栈中创建全局执行上下文(GEC)。根据 ECMA 规范,执行上下文都需要关联一个变量对象(VO),对于GEC 而言,这个 VO 可以看成指向的是 GO。我们定义的函数 singer 以及变量 joke,就会在 V8 引擎解析全局代码时作为属性添加到 GO 中,此时代码还没执行,所以 singer 的值为指向 singer 函数对象的内存地址,joke 的值为 undefined,此时内存的表现如下图所示:
image.png 因为 singer 是一个函数声明,V8 解析到它时会在堆内存中开辟一块区域保存 singer 的函数体和父级作用域等信息,singer 是一个全局函数,所以它的父级作用域指向的就是 GO。

当 GEC 关联 VO 完成后,开始执行全局代码。1 - 7 行为函数定义,直接执行第 8 行,即 var joke = singer(),需要执行 singer 函数。先在 ECStack 中创建函数执行上下文(FEC),关联 VO,此时这个 VO 指向的是 singer 执行前会创建的 AO,里面有属性 name,值为 undefined,还有属性 makeAlbum,值是指向 makeAlbum 函数对象的内存地址。创建完 AO 开始执行 singername 赋值为 Jay,返回 makeAlbum 函数,其实就是将 makeAlbum 函数对象的内存地址 0xb00 赋值给了栈内存中的 joke,同时 GO 中的 joke 的值也指向了 0xb00。此时内存的表现如下图:
image.png singer 执行完后,对应的 FEC 就会出栈并销毁,相当于 FEC 中的 VO 指向 AO 的那条连接线也就不存在了。一般情况下,被指向的 AO 也应该随即销毁。但注意,此时,makeAlbum 函数对象的父级作用域依旧指向着 singer 对应的 AO,而在 GO 对象中,joke 则指向着 makeAlbum 函数对象,也就是说从 GO(可以看成是 V8 引擎 GC 的根节点) 出发,依旧有着一条指向 AO(singer)的路径,所以在 V8 的垃圾回收算法下,AO(singer)并不会被销毁。图示如下:
image.png 接下来执行第 9 行代码,执行 joke(),实际上就是执行 makeAlbum 函数,同样会在 ECStack 中创建 makeAlbum 的 FEC,里面的 VO 指向的是 makeAlbum 的 AO,因为 makeAlbum内仅仅做了个打印,所以可以认为其对应的 AO 中什么也没有。之后执行 makeAlbum 函数体,输出打印,需要用到变量 name,先在本身上下文中的 VO(AO)里寻找,没找到,则沿着作用域链(scope chain)在其父级作用域 singer 对应的 AO 中寻找 name,得到结果为 Jay。图示如下:
image.png 函数 makeAlbum 也就是 joke() 执行完毕,FEC 出栈销毁,同时指向 AO(makeAlbum)的连接线消失。此时 AO(makeAlbum)没有被任何对象引用,自然从 GO 出发也找不到它,就会被垃圾回收机制销毁。到此,代码全部执行完毕。

闭包的产生

现在,我们回头看看 MDN 的阐述,就好理解多了,“一个函数”,上例中指的就是函数 makeAlbum,“对其周围状态的引用”,可以看成就是 singer 对应的 AO,它们共同组成了闭包。注意,闭包在函数的定义执行完后就产生了,而不是在调用时。可以认为在准备执行 singer 前,生成 AO(singer)和 makeAlbum 的函数对象时,闭包就产生了。而不是等到 makeAlbum 被执行,也就是 joke() 执行的时候。 从广义的角度来看,每当有函数创建时,必然有与之对应的父级作用域(尽管可能里面没有变量或参数等内容),所以 MDN 里还有一句话“每当创建一个函数,闭包就会在函数创建的同时被创建出来”。

闭包的缺点及解决

闭包可以使函数内部的变量(上例中 singer 内的 name)在函数执行完后,仍然存活在内存中,这就延长了局部变量的生命周期。闭包还可以让函数外部能读写函数内部的数据,比如 joke 函数的执行。可以看到闭包是很有用的,但同时也有可能产生问题,比如下面说到的内存泄露。

内存泄露与溢出

在上例中,当所有代码执行完成后,内存中的表现如下图:
image.png 可以看到,本该销毁的 AO 和 makeAlbum 函数对象依然存在于内存中没有被销毁,占用的内存没有及时释放,导致内存泄露。如果内存泄露过多,剩余的内存不够程序运行需要时,就会抛出内存溢出的错误,这就是闭包的缺点。

通过浏览器调试工具查看

现有如下代码,定义一个 testClosure 函数,里面有个变量 largeVariable,赋值为一个长度为 1024 * 1024 的数组,数组每个元素都为数字 8;还有个函数 innerFn 用于获取打印 largeVariable 变量 ,并最终将 innerFn 返回出去。innerFn 函数和变量 largeVariable 就会产生一个闭包。我们还定义了一个 for 循环,循环 100 次,每次都执行 testClosure 函数并将结果添加到另个一全局数组 globalArr 中。这样,每当 testClosure 执行完一次后, 由于闭包的存在,对应的 largeVariable 并不会被释放而是留在了内存中。我们可以通过浏览器运行在开发者调试工具中来看看内存占用情况。

function testClosure() {
  var largeVariable = new Array(1024 * 1024).fill(8)
  function innerFn() {
    console.log(largeVariable)
  }
  return innerFn
}
var globalArr = []
for (var i = 0; i < 100; i++) {
  globalArr.push(testClosure())
}

实际测试

结果如下图,可以看到,运行期间虽然有多次运行了 GC,但是当代码运行完后,内存大小稳定在了 456M 左右:

2022-02-20_112709.png

测试结果符合预期吗?这就需要知道在 js 中,8 这个数字到底占几个字节了。

js 中 1 个数字占几个字节

在 js 中,虽然数字类型采用 IEEE754 标准定义的 64 位浮点数表示,照理来说每个 8 应该占 8 字节(这也是我在网上搜到的大多数回答),但是 V8 引擎内部有个机制,会在数字进行位运算时将比较小的数字类型(小整数,smi)转换成 32 位有符号整型,所以每个 8 事实上占 4 字节(如果是 8.8 这种浮点数那么依然是占 8 字节)。这样在调试之前我们就可以先确定下理论上 100 个 largeVariable 应该占用的内存大小:1 个 largeVariable 的 长度为 1024 * 1024,每个元素 8 占 4 字节,那么就是 100 * 1024 * 1024 * 4byte = 400M。与我们实测得到的 456 M 接近,可见上图的测试结果是符合预期的。

解决方法

解决闭包导致的内存泄露的方法很简单,关键就是让闭包中的函数对象成为垃圾对象即可。比如最初的例子中,我们只需在代码的最后执行 joke = null。这样,GO 对象中的 joke 属性的值也就变为了 null,那么指向 makeAlbum 函数对象的连接线就不复存在了。此时虽然 makeAlbum 函数对象和 AO(singer)之间还有在互相引用,但是它们都无法从 GO 出发被找到,就会被回收销毁。testClosure这个例子中,也只需最后将 globalArr = null 即可,这样在调试时看到的结果如下图:

image.png 可以看出,当所有代码运行完后,执行了一次 Major GC 后,内存占用垂直减少,基本回到了代码执行前的状态。

One More Thing

如果调试时使用 image.png,是调试工具自动刷新页面并自动在某个时间停止录制,此时垃圾回收可能还没彻底执行,有可能得不到理想的曲线。可以直接点击下图中那个实心圆,手动开始录制:

image.png

再马上手动刷新页面,在打开调试工具的情况下,右键刷新按钮还可以选择清空缓存刷新:

image.png

然后录制个十几秒停止录制。

感谢.gif 点赞.png