4.3 垃圾回收
JavaScript通过自动内存管理实现内存分配和闲置资源回收。
基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。
这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
两种主要的标记策略:标记清理和引用计数。
4.3.1 标记清理
JavaScript最常用的垃圾回收策略是标记清理(mark-and-sweep)。
当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。
而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。
当变量离开上下文时,也会被加上离开上下文的标记。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
4.3.2 引用计数
另一种没那么常用的垃圾回收策略是引用计数(reference counting)。
其思路是:对每个值都记录它被引用的次数。
声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。
类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。
当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。
垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。
引用计数最早由Netscape Navigator 3.0采用,但很快就遇到了严重的问题:循环引用。所谓循环引用,就是对象A有一个指针指向对象B,而对象B也引用了对象A。比如:
objectA和objectB通过各自的属性相互引用,意味着它们的引用数都是2。
在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。
而在引用计数策略下,objectA和objectB在函数结束后还会存在,因为它们的引用数永远不会变成0。
如果函数被多次调用,则会导致大量内存永远不会被释放。
只要涉及COM(组件对象模型(COM, Component Object Model))对象,就无法避开循环引用问题
为避免类似的循环引用问题,应该在确保不使用的情况下切断原生JavaScript对象与DOM元素之间的连接。比如,通过以下代码可以清除前面的例子中建立的循环引用:
把变量设置为null实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。
4.3.3 性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
由于调度垃圾回收程序方面的问题会导致性能下降,IE曾饱受诟病。它的策略是根据分配数。
比如分配了256个变量、4096个对象/数组字面量和数组槽位(slot),或者64KB字符串。只要满足其中某个条件,垃圾回收程序就会运行。
这样实现的问题在于,分配那么多变量的脚本,很可能在其整个生命周期内始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行。
由于对性能的严重影响,IE7最终更新了垃圾回收程序。
IE7发布后,JavaScript引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触发垃圾回收的阈值。IE7的起始阈值都与IE6的相同。
如果垃圾回收程序回收的内存不到已分配的15%,这些变量、字面量或数组槽位的阈值就会翻倍。
如果有一次回收的内存达到已分配的85%,则阈值重置为默认值。
这么一个简单的修改,极大地提升了重度依赖JavaScript的网页在浏览器中的性能。
警告:在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的。在IE中,window.CollectGarbage()方法会立即触发垃圾回收。在Opera 7及更高版本中,调用window.opera.collect()也会启动垃圾回收程序。
4.3.4 内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。
不过,JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多出于安全考虑而不是别的,就是为了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃。
这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。
将内存占用量保持在一个较小的值可以让页面性能更好。
优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。
如果数据不再必要,那么把它设置为null,从而释放其引用。这也可以叫作解除引用。
这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用
注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
1.通过const和let声明提升性能
ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。
因为const和let都以块(而非函数)为作用域,所以相比于使用var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。
在块作用域比函数作用域更早终止的情况下,这就有可能发生。
2.隐藏类和删除操作
V8在将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要。运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到。
比如:
V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。
假设之后又添加了下面这行代码:
此时两个Article实例就会对应两个不同的隐藏类。
根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。
当然,解决方案就是避免JavaScript的“先创建再补充”(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性
例子:
这样,两个实例基本上就一样了(不考虑hasOwnProperty的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升
不过要记住,使用delete关键字会导致生成相同的隐藏类片段。
最佳实践是把不想要的属性设置为null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果
3.内存泄漏
写得不好的JavaScript可能出现难以察觉且有害的内存泄漏问题。
在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。
JavaScript中的内存泄漏大部分是由不合理的引用导致的。
-1. 意外声明全局变量是最常见但也最容易修复的内存泄漏问题。
-2. 定时器也可能会悄悄地导致内存泄漏。
定时器的回调通过闭包引用了外部变量:
只要定时器一直运行,回调函数中引用的name就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。
-3. 使用JavaScript闭包很容易在不知不觉间造成内存泄漏
调用outer()会导致分配给name的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理name,因为闭包一直在引用着它。假如name的内容很大(不止是一个小字符串),那可能就是个大问题了。
4.静态分配与对象池
为了提升JavaScript性能,最后要考虑的一点往往就是压榨浏览器了。
此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。
理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。
如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影响性能。
对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。
函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排垃圾回收。
在哪里创建矢量可以不让垃圾回收调度程序盯上呢?
一个策略是使用对象池。
在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。
由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。
如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。
这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。
可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。不过,必须事先想好这个数组有多大。
注: 静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。
