Skip to content

为什么闭包不会被垃圾回收清除 #3

@raxxarr

Description

@raxxarr

在阅读关于 JavaScript 引擎垃圾回收的资料时,我们经常会看到一则提示,说垃圾回收机制不会回收闭包占用的内存,并可能产生内存泄露。那么,垃圾回收到底为何无法回收闭包呢?

首先,我要说,这个问题是个伪问题,不具有特异性。
因为闭包的产生是因为一个函数被当前函数作用域外部的变量引用了,除非外部的变量被释放,否则闭包当然不会被回收。不只是闭包,只要是仍处于被引用状态的堆内存数据,都不会被垃圾回收清除,根本没必要单独拿出闭包来说一下嘛。闭包所保存的,无非是一些存放在堆上的数据而已。有用就不会被清除,没用自然会清除,GC 对闭包做的,跟对其它内存做的事情没什么两样。

但是,我们还是要讨论一下闭包的产生和垃圾回收。

从作用域链说起

几个基本概念

关于函数的定义和执行,JS 引擎做的事情比较复杂,有几个经常提到的概念先解释一下:

关键词 English 描述
词法作用域 Lexical Scoping 作用域定义了变量的查找方法和权限,即查找路径。而词法作用域正如字面意思,它是在词法解析阶段定义的作用域,是一种静态作用域。换言之,在函数定义的时候就规定了该函数的作用域是什么。JavaScript 使用的是词法作用域。
变量对象 Variable Object A.K.A VO 一些作用域中的变量的集合。包括局部变量对象和全局变量对象。
活动对象 Activation Object A.K.A AO 当前执行作用域中的变量集合。其实就是激活的(活动的)变量对象。
作用域链 Scope Chain 函数有权访问的所有包含函数的局部变量对象(活动对象)和全局变量对象。
执行上下文 Execution Context 一段可执行代码的 VO、作用域链以及 this。即跟当前执行代码相关的所有环境上下文。
包含函数 这不是个术语,只是一种说法,指的是当前函数的父级、父级的父级…… 所有函数。

作用域链

当一个函数定义的时候,会创建一个包含全局变量对象和 所有 包含函数(的)活动对象的 作用域链,并将它保存在这个函数对象的 [[Scope]] 内部属性上(函数也是一个对象,包含函数体和其他一些可访问或不可访问的属性,比如 name、length 等)。还记得前面说的 词法作用域 吗?

当这个函数执行的时候,会创建一个 执行上下文,将 [[Scope]] 复制到执行上下文中的作用域链。然后,创建当前的 活动对象,推到作用域链顶端。此刻,函数执行过程中可访问的作用域是:AO + Scope Chain。

注意:

  1. 作用域链是一个指向各级变量对象的指针列表,作用域链不清除,各级变量对象也不会清除。
  2. 无论子函数是否引用了外部作用域的变量,都会包含完整的作用域链,都存在内存占用问题。只是要不要把未引用外部变量的函数称作闭包,就见仁见智了,我们稍后会讨论闭包的定义问题。

为什么闭包不会被垃圾回收清除?

准确的说,是闭包所包含的整个作用域链所引用的 变量对象 中的值不会被清除。

这里我们暂时按照「高程」的定义把可以访问其他函数作用域的内部函数叫做闭包。
如下代码:

    function fn () {
        let a = 1;
        let b = 2;

        return function () {
            console.log(a); 
        }
    }

    let foo = fn();
    foo();

fn() 返回了一个匿名函数,被全局变量 foo 引用。那么 foo 所应用的匿名函数就是一个闭包。

如果闭包函数被外部变量接收,那么这个存在于堆内存中的闭包函数对象就一直存在着(因为它被外部引用着,只要外部作用域不退出,就不会被垃圾回收清除)。那么这个函数对象上的 [[Scope]] 属性(即作用域链)自然就不会被清除,那么作用域链引用的所有层级包含函数的活动对象就不会被清除。

这也就意味着,所有返回函数的函数被接收之后,都有占用内存的闭包问题。

我们需要厘清的是,无论闭包的定义如何(是内部函数,还是被引用的外部函数),按照语言解释器的机制,无法被垃圾清除的是函数创建时的作用域链。

关于闭包的定义

闭包的定义不同的出处有所不同,其实,只要理解了函数定义和运行的机制,尤其是作用域链,概念上的问题无关宏旨。

「JS高级程序和设计」中把闭包定义为「可以访问外部作用域的函数」,即,只要某函数定义在另一个函数内,它就是闭包,无论它是否使用了外部函数作用域中的变量

然而,通过观察 Chrome 开发者工具中的内容发现,只有 B 持有 A 的变量时,才会产生闭包,且 A(包含函数)是闭包,而不是被创建的函数。且闭包中只有被 B 持有的变量,不被持有的变量不在闭包中。

在 Safari 中,闭包的定义和 Chrome 类似,都是将被持有作用域的函数称作闭包。不同的是,无论 B 是否引用了 A 中的变量,A 都是闭包,且闭包中具有 A 的全部活动对象(AO)。

比如以下代码:

    function fn () {
        let a = 1;
        let b = 2;

        return function () {
            console.log('hello');
        }
    }

    let foo = fn();
    foo();

理论上,以上代码已经形成了一个无法被回收的作用域链。fn() 返回的函数被全局变量 foo 接收,它身上的 [[Scope]] 永远不会被回收(除非 foo 被回收)。

在 Chrome 中,这不会称为闭包,因为 fn() 返回的函数并未引用任何外部作用域的变量。
在 Safari 中,函数 fn 是个闭包,且闭包中含有 a、b 两个变量。

我们把代码稍微修改一下:

   function fn () {
        let a = 1;
        let b = 2;

        return function () {
            console.log(a); // 这一行不同
        }
    }

    let foo = fn();
    foo();

在 Chrome 中,函数 fn 是个闭包,闭包中只包含被引用的 a 变量。
在 Safari 中,函数 fn 同样是闭包,闭包中包含全部 a、b 变量。

补充:JS 的内存模型和垃圾回收

JS 的堆内存 & 栈内存

对于 JS 不同类型的数据来说,基本数据类型(string/number/boolean/undefined/null)的值存储在 栈内存 中,引用数据类型(object)的值存储在 堆内存 中,程序代码存储在 文本段 中。(以上只是简单理解,并不尽然,不同引擎的实现和优化并不相同)

注意,前面的用词**「的值」**,比如 a = { id: 1 },存储在堆内存中的是对象 { id: 1 },而 变量标识符 a 则存储在栈内存中。

即,栈中存储的是 a=0x0f00000,堆中的地址 0x0f00000 存储的是 { id: 1 }

注意:对象中如果有的 key 对应的 value 是基本类型,如 string,它也是存在堆中的。

什么是垃圾

要说垃圾回收,首先要理解哪些内存是垃圾,有用的内存什么时候变成的垃圾。

function fn () {
    let  a = { id: 1 }; 
};
fn();

我们知道,函数调用存储于栈内存中,由系统(运行时环境)自动分配和回收内存。因此,对于函数 fn ,它执行于栈内存中。

函数中的局部变量也存储在栈内存中,于是,变量标识符 a 和它的内容也存储在栈内存中。

然而,a 的内容(值)是一个引用数据类型,它会被存储到堆内存中,而把内存虚拟地址当做值赋给 a。

当函数执行结束后,根据栈内存管理规则,该函数的执行环境会被从栈内存顶部弹出,于是 a=0x0f00000 被从栈内存清除,接着 fn 从栈内存清除。

至此程序执行完毕(我们假设该进程还未退出),此时栈内存是干净的(我们可以理解为它是实报实销的,里面的东西用完就扔),但是堆内存中 0x0f00000 位置上还存着一个对象呢,这个对象在之后的程序中永远都不会被访问到,因为这个对象在堆中分配内存时,只把地址告诉了 fn 中的 a,而 a 是函数的局部变量,它并未在 fn 的执行周期中把这个堆地址传给外面,于是 fn 执行完毕后就被永久性清除了,也就是说世界上再也没有人知道 { id: 1 } 这个数据住在哪里,那么这个对象对于当前执行的程序来说,就是没有意义的,但是却占用了程序内存空间,于是被看做了垃圾。(就像 count 为 0 的 i-node 一样)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions