作为前端开发人员,我们都知道 JavaScript 会在创建变量(对象、字符串)时自动分配内存,并在这些变量不被使用时自动释放内存,这个过程被称为垃圾回收。这个”自动”释放资源的特性有时会给我们带来一些困惑,让 JavaScript 开发者误以为可以不关心内存管理。说实话我一开始也不是很关心内存管理方面的细节,这是一个很不好的习惯,于是后面我找了一些资料,系统的总结了一下 JS 内存泄漏的知识和规避方法。
下面主要总结了一些概念的理解,因为一次性看的文章很多而且很杂,所以写的内容连贯性不是很强,但是很细,涵盖了关于内存的很多知识和概念,方便我下次可以迅速查找相关知识点。
一、关于内存的一些概念
内存生命周期
无论你用哪一种编程语言,内存生命周期几乎总是一样的,大致可总结为下面三步:
- 分配内存:内存是被操作系统分配,这允许程序使用它。在低级语言中(例如C),这是一个作为开发者需要处理的显式操作。在高级语言中,系统代替开发者对这些操作都进行了处理。
- 使用内存:实际使用之前分配的内存,通过在代码操作变量对内在进行读和写。
- 释放内存:不用的时候,就可以释放内存,以便重新分配。与分配内存操作一样,释放内存在低级语言中也需要显式操作。
什么是内存
在直接探讨Javascript中的内存之前,我们先简要的讨论一下什么是内存、内存大概是怎么样工作的。
在硬件中,电脑的内存包含了大量的触发电路,每一个触发电路都包含一些能够储存1位数据的晶体管。触发器通过唯一标识符来寻址,从而可以读取和覆盖它们。因此,从概念上来讲,可以认为电脑内存是一个巨大的可读写阵列。
人类不善于把我们所有的思想和算术用位运算来表示,我们把这些小东西组织成一个大家伙,这些大家伙可以用来表现数字:8位是一个字节。字节之上是字(16位、32位)。
许多东西被存储在内存中:
- 所有的变量和程序中用到的数据;
- 程序的代码,包括操作系统的代码。
编译器和操作系统共同工作帮助开发者完成大部分的内存管理,但是我们还是需要了解一下底层到底发生了什么。
编译代码的时候,编译器会解析原始数据类型,提前计算出它们需要多大的内存空间。然后将所需的数量分配在栈空间中。之所以称为栈空间,是因在函数被调用的时候,他们的内存被添加在现有内存之上(就是会在栈的最上面添加一个栈帧来指向存储函数内部变量的空间)。终止的时候,以LIFO(
Last In First Out 后进先出)的顺序移除这些调用。例如:
1 |
|
编译器马上知道需要内存 4 + 4 × 4 + 8 = 28字节,且编译器会插入与操作系统交互的代码,来请求栈中必要大小的字节来储存变量。
在上面的例子总,如果我们尝试访问x[4]的内存(开始声明的x[4]是长度为4的数组,x[4]表示第五个元素),我们会访问m的数据。那是因为我们正在访问一个数组里不存在的元素,m比数组中实际分配内存的最后一个元素x[3]要远4个字节,可能最后的结果是读取(或者覆盖)了m的一些位。这肯定会对其他程序产生不希望产生的结果。
所以,实际上当函数调用其他函数的时候,每一个函数被调用的时候都会获得自己的栈块。在自己的栈块里会保存函数内所有的变量,还有一个程序计数器会记录变量执行时所在的位置。当函数执行完之后,会释放它的内存以作他用。也正是因为这种机制才会有 JS 中的闭包问题。
动态分配
但内存的分配并没有上面说的那么简单,因为在编译的时候我们并不知道一个变量将会需要多少内存。假设我们做了下面这样的事:
1 |
|
编译器不知道这个数组需要多少内存,因为数组大小取决于用户提供的值。因此,此时不能在栈上分配空间。程序必须在运行时向操作系统请求够用的空间。此时内存从堆空间中被分配。
理解动态内存是如何分配的,最关键的一个知识点是指针,这块我也还没理解透,后面再补充哈~~
JavaScript 中的内存分配、使用和释放
分配:
JavaScript在开发者声明值的时候自动分配内存。
1 |
|
使用:
在JavaScript中使用被分配的内存,本质上就是对内在的读和写。
比如,读、写变量的值或者对象的属性,抑或向一个函数传递参数。
释放:内存不在被需要时释放内存
大部分的内存管理问题都在这个阶段出现。这里最难的任务是找出这些被分配的内存什么时候不再被需要。这常常要求开发者去决定程序中的一段内存不在被需要而且释放它。
高级语言嵌入了一个叫垃圾回收的软件,它的工作是跟踪内存的分配和使用,以便于发现一些内存在一些情况下不再被需要,它将会自动地释放这些内存。不幸的是,这个过程是一个近似的过程,因为一般关于知道内存是否是被需要的问题是不可判断的(不能用一个算法解决)。大部分的垃圾回收器会收集不再被访问的内存,例如指向它的所有变量都在作用域之外。然而,这是一组可以收集的内存空间的近似值。因为在任何时候,一个内存地址可能还有一个在作用域里的变量指向它,但是它将不会被再次访问。
垃圾收集:由于找到一些内存是否是”不再被需要的”这个事实是不可判定的,垃圾回收的实现存在局限性。
内存引用
垃圾回收算法依赖的主要概念是引用。在内存管理的语境下,一个对象只要显式或隐式访问另一个对象,就可以说它引用了另一个对象。例如,JavaScript对象引用其Prototype(隐式引用),或者引用prototype对象的属性值(显式引用)。
在这种情况下,”对象”的概念扩展到比普通JavaScript对象更广的范围,并且还包含函数作用域。(或者global词法作用域)
词法作用域定义变量的名字在嵌套的函数中如何被解析:内部的函数包含了父级函数的作用域,即使父级函数已经返回。
引用计数垃圾回收
这是最简单的垃圾回收算法。 一个对象在没有其他的引用指向它的时候就被认为”可被回收的”。
看一下下面的代码:
1 |
|
循环引用创造麻烦
在涉及循环引用的时候有一个限制。在下面的例子中,两个对象被创建了,而且相互引用,这样创建了一个循环引用。它们会在函数调用后超出作用域,应该可以释放。然而引用计数算法考虑到2个对象中的每一个至少被引用了一次,因此都不可以被回收。
1 |
|
标记清除算法
为了决定一个对象是否被需要,这个算法用于确定是否可以找到某个对象。
- 垃圾回收器生成一个根列表。根通常是将引用保存在代码中的全局变量。在JavaScript中,window对象是一个可以作为根的全局变量。
- 所有的根都被检查和标记成活跃的(不是垃圾),所有的子变量也被递归检查。所有可能从根元素到达的都不被认为是垃圾。
- 所有没有被标记成活跃的内存都被认为是垃圾。垃圾回收器就可以释放内存并且把内存还给操作系统。
这个算法就比之前的(引用计算)要好些,因为”一个对象没有被引用”导致这个对象不能被访问。相反,正如我们在循环引用的示例中看到的,对象不能被访问到,不一定不存在引用。
2012年起,所有浏览器都内置了标记清除垃圾回收器。在过去几年中,JavaScript垃圾回收领域中的所有改进(代/增量/并行/并行垃圾收集)都是由这个算法(标记清除法)改进实现的,但并不是对垃圾收集算法本身的改进,也没有改变它确定对象是否可达这个目标。
循环引用不再是问题
在上面的例子中(循环引用的那个),在函数执行完之后,这个2个对象没有被任何可以到达的全局对象所引用。因此,他们将会被垃圾回收器发现为不可到达的。
尽管在这两个对象之间有相互引用,但是他们不能从全局对象上到达。
垃圾回收器的反常行为
尽管垃圾回收器很方便,但是他们有一套自己的方案。其中之一就是不确定性。换句话说,Garbage Collection 是不可预测的。你不可能知道一个回收器什么时候会被执行。这意味着程序在某些情况下会使用比实际需求还要多的内存。在其他情况下,在特别敏感的应用程序中,可能会出现短停顿。尽管不确定意味着不能确定回收工作何时执行,但大多数GC实现都会在分配内存的期间启动收集例程。如果没有内存分配,大部分垃圾回收就保持空闲。参考下面的情况:
- 执行相当大的一组分配。
- 这些元素中的大部分(或者所有的)都被标记为不可到达的(假设我们清空了一个指向我们不再需要的缓存的引用。)
- 没有更多的分配被执行。
在这种情况下,大多数垃圾回收实现都不会做进一步的回收。换句话说,尽管这里有不可达的引用变量可供回收,回收器也不会管。严格讲,这不是泄露,但结果却会占用比通常情况下更多的内存。
什么是内存泄漏
内存泄漏就是不再被应用需要的内存,由于某种原因,没有被归还给操作系统或者进入可用内存池。
编程语言喜欢不同的管理内存方式。然而,一段确定的内存是否被使用是一个不可判断的问题。换句话说,只有开发者才能弄清楚,是否一段内存可以被还给操作系统。
某些编程语言为开发者提供了释放内存功能。另一些则期待开发者清楚的知道一段内存什么时候是没用的。
二、4 种常见的 JavaScript 内存泄漏
全局变量
JavaScript用一个有趣的方式管理未被声明的变量:对未声明的变量的引用在全局对象里创建一个新的变量。在浏览器的情况下,这个全局对象是window。换句话说:
1 |
|
如果bar被假定只在foo函数的作用域里引用变量,但是你忘记了使用var去声明它,一个意外的全局变量就被声明了。
在这个例子里,泄漏一个简单的字符串不会造成很大的伤害,但是它确实有可能变得更糟。
另外一个意外创建全局变量的方法是通过this:
1 |
|
被遗忘的定时器或者回调
在JavaScript中使用setInterval是十分常见的。
大多数库,特别是提供观察器或其他接收回调的实用函数的,都会在自己的实例无法访问前把这些回调也设置为无法访问。但涉及setInterval时,下面这样的代码十分常见:
1 |
|
定时器可能会导致对不需要的节点或者数据的引用。
renderer对象在将来有可能被移除,让interval处理器内部的整个块都变得没有用。但由于interval仍然起作用,处理程序并不能被回收(除非interval停止)。如果interval不能被回收,它的依赖也不可能被回收。这就意味着serverData,大概保存了大量的数据,也不可能被回收。
在观察者的情况下,在他们不再被需要(或相关对象需要设置成不能到达)的时候明确的调用移除是非常重要的。
在过去,这一点尤其重要,因为某些浏览器(旧的IE6)不能很好的管理循环引用(更多信息见下文)。如今,大部分的浏览器都能而且会在对象变得不可到达的时候回收观察处理器,即使监听器没有被明确的移除掉。然而,在对象被处理之前,要显式地删除这些观察者仍然是值得提倡的做法。例如:
1 |
|
如今的浏览器(包括IE和Edge)使用现代的垃圾回收算法,可以立即发现并处理这些循环引用。换句话说,先调用removeEventListener再删节点并非严格必要。
jQuery等框架和插件会在丢弃节点前删除监听器。这都是它们内部处理,以保证不会产生内存泄漏,甚至是在有问题的浏览器(没错,IE6)上也不会。
闭包
闭包是 JavaScript 开发的一个关键方面:一个内部函数使用了外部(封闭)函数的变量。由于JavaScript运行时实现的不同,它可能以下面的方式造成内存泄漏:
1 |
|
这段代码做了一件事:每次ReplaceThing被调用,theThing获得一个包含大数组和新的闭包(someMethod)的对象。同时,变量unused保持了一个引用originalThing(theThing是上次调用replaceThing生成的值)的闭包。已经有点困惑了吧?最重要的事情是一旦为同一父域中的作用域产生闭包,则该作用域是共享的。
这里,作用域产生了闭包,someMethod和unused共享这个闭包中的内存。unused引用了originalThing。尽管unused不会被使用,someMethod可以通过theThing来使用replaceThing作用域外的变量(例如某些全局的)。而且someMethod和unused有共同的闭包作用域,unused对originalThing的引用强制oriiginalThing保持激活状态(两个闭包共享整个作用域)。这阻止了它的回收。
当这段代码重复执行,可以观察到被使用的内存在持续增加。垃圾回收运行的时候也不会变小。从本质上来说,闭包的连接列表已经创建了(以theThing变量为根),这些闭包每个作用域都间接引用了大数组,导致大量的内存泄漏。
Meteor 的博文 解释了如何修复此种问题。在 replaceThing 的最后添加 originalThing = null 。
DOM 外引用
有的时候在数据结构里存储DOM节点是非常有用的,比如你想要快速更新一个表格几行的内容。此时存储每一行的DOM节点的引用在一个字典或者数组里是有意义的。此时一个DOM节点有两个引用:一个在dom树中,另外一个在字典中。如果在未来的某个时候你想要去移除这些排,你需要确保两个引用都不可到达。
1 |
|
当涉及DOM树内部或子节点时,需要考虑额外的考虑因素。例如,你在JavaScript中保持对某个表的特定单元格的引用。有一天你决定从DOM中移除表格但是保留了对单元格的引用。人们也许会认为除了单元格其他的都会被回收。实际并不是这样的:单元格是表格的一个子节点,子节点保持了对父节点的引用。确切的说,JS代码中对单元格的引用造成了整个表格被留在内存中了,所以在移除有被引用的节点时候要当心。
参考文章:
- 阮一峰:JavaScript 内存泄漏教程
- Alon’s Blog:4类 JavaScript 内存泄漏及如何避免
- michael8512的博客:JS哪些操作会造成内存泄露
- 脚本之家:js内存泄露的几种情况详细探讨
- 本文作者: Alvin
- 本文链接: https://alvinyw.github.io/2018/05/12/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!