感谢点赞、关注和收藏!
这一篇讲内存相关,主要是垃圾回收机制。
垃圾回收
JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在 C 和 C++等语言中,内存如何管理是开发者来决定的。JavaScript通过自动内存管理实现内存分配和闲置资源回收,让开发者免去管理内存的痛苦。基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存 。这个过程是周期性 的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数 。
标记清理
JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep) 。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。当变量离开上下文时,也会被加上离开上下文的标记。
给变量加标记的方式有很多种,但关键是策略。 垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。每个浏览器都有自己的实现,效率和频率都不太一样。
引用计数
另一种没那么常用的垃圾回收策略是引用计数(reference counting) 。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。
这个引用计数有一个很大的问题就是当有循环引用 的时候,比如对象A有一个指针指向了对象B,而恰巧对象B也有一个指针指向了对象A,这两个对象“左脚踩右脚”,引用计数永远不会为0。
在 IE8 及更早版本的 IE 中,并非所有对象都是原生 JavaScript 对象。BOM 和 DOM 中的对象是 C++ 实现的组件对象模型(COM,Component Object Model)对象,而 COM 对象使用引用计数实现垃圾回收。换句话说,只要涉及 COM 对象,就无法避开循环引用问题。为了补救这一点,IE9 把 BOM 和 DOM 对象都改成了 JavaScript 对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象。
性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失 ,因此垃圾回收的时间调度很重要 。最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作 。
由于调度垃圾回收程序方面的问题会导致性能下降,IE 曾饱受诟病。它的策略是根据分配数,比如分配了 256 个变量、4096 个对象/数组字面量和数组槽位(slot),或者 64KB 字符串。只要满足其中某个条件,垃圾回收程序就会运行。结果可想而知,垃圾回收频繁触发,严重影响性能。
内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过,JavaScript 运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这除了是为了安全考虑之外,还是为了避免运行大量JavaScript 的网页耗尽系统内存而导致操作系统崩溃。
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫作解除引用 。
下面我们来看一下,平时编程在内存管理这块有哪些需要优化,或者避免的事情。
通过 const 和 let 声明提升性能
因为 const 和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
隐藏类和删除操作
根据 JavaScript 所在的运行环境,有时候需要根据浏览器使用的 JavaScript 引擎来采取不同的性能优化策略。截至 2017 年,Chrome 是最流行的浏览器,使用 V8 JavaScript 引擎。V8 在将解释后的 JavaScript 代码编译为实际的机器码时会利用“隐藏类 ”。什么是隐藏类呢,下面举个例子:
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band';
}
let a1 = new Article();
let a2 = new Article();
V8 会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原
型。假设之后又添加了下面这行代码:
a2.author = 'Jake';
此时两个 Article 实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有
可能对性能产生明显影响。当然,解决方案就是避免 JavaScript 的“先创建再补充”(ready-fire-aim)式的动态属性赋值 ,并在构造函数中一次性声明所有属性,如下所示:
function Article(opt_author) {
this.title = 'Inauguration Ceremony Features Kazoo Band';
this.author = opt_author;
}
let a1 = new Article();
let a2 = new Article('Jake');
这样,两个实例基本上就一样了(不考虑 hasOwnProperty 的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升。不过要记住,使用 delete 关键字会导致生成相同的隐藏类片段 。
最佳实践是把不想要的属性设置为 null。 这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。
内存泄漏
写得不好的 JavaScript 可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下 ,内存泄漏可能是个大问题。JavaScript 中的内存泄漏大部分是由不合理的引用导致的。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题 。下面的代码没有使用任何关键字声明变量:
function setName() {
name = 'Jake';
}
之前说过这相当于省略了var关键字,所以是声明了一个全局变量,只要window对象还在,这个变量就一直存在。 除此之外,定时器也可能会悄悄地导致内存泄漏。
let name = 'Jake';
setInterval(() => {
console.log(name);
}, 100);
只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。还有一个情况,使用 JavaScript 闭包 也很容易在不知不觉间造成内存泄漏。
let outer = function() {
let name = 'Jake';
return function() {
return name;
};
};
只要返回的函数存在就一直不会清理name变量。
静态分配与对象池
为了提升 JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收 ,那就可以保住因释放内存而损失的性能。
这里是一个函数的两种写法,我们来看看区别:
// 方案一
function addVector(a, b) {
let resultant = new Vector();
resultant.x = a.x + b.x;
resultant.y = a.y + b.y;
return resultant;
}
// 方案二
function addVector(a, b, resultant) {
resultant.x = a.x + b.x;
resultant.y = a.y + b.y;
return resultant;
}
可以看到唯一的区别就是一个使用了新的变量resultant,一个使用现有的resultant,那么在这个函数被频繁调用的前提下,使用新变量的函数会被认为在频繁的创建和需要释放变量,于是会有更多的垃圾回收动作,这是因为浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度 。
那如果想要继续优化应该怎么做呢?对象池是其中一种策略,这里就不展开介绍了,其实想要代码性能好,基本上就越来越接近写C++,需要我们自己来维护内存的管理机制,减少自动垃圾回收。
那么书的第四章-变量、作用域与内存,到这里就结束了,下一篇是第五章-基本引用类型。