首页 阅读DAY4 JavaScript高级程序设计 4章下 变量、作用域和内存
文章
取消

阅读DAY4 JavaScript高级程序设计 4章下 变量、作用域和内存

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

垃圾回收:

JavaScript 采用垃圾回收(Garbage Collection)机制自动管理内存

在 C/C++ 中,开发者需要手动分配和释放内存,这是很多 bug 的来源。JS 通过自动内存管理卸下了这个负担。基本思路是:确定哪个变量不再使用,释放它占用的内存。这个过程是周期性自动运行的。

但垃圾回收是近似且不完美的方案,因为“某块内存是否还有用”属于不可判定问题,没有算法能完美解决。

以函数局部变量的生命周期为例:函数执行时分配内存,执行完退出后变量不再需要,内存可以释放。这种情况很明确,但并非所有场景都这么清晰。因此垃圾回收程序必须跟踪记录哪些变量还在使用、哪些不再使用,才能正确回收内存。具体怎么标记,有不同的实现方式。

性能:

垃圾回收的时机很关键。 它周期性运行,变量多时会造成性能损失——在内存有限的移动设备上,可能明显拖慢渲染速度和帧速率。由于开发者无法控制垃圾回收何时触发,最好的做法是在写代码时就做到:让垃圾回收尽快结束工作,无论它什么时候来。

现代垃圾回收程序基于对 JS 运行时环境的探测机制决定何时运行。探测因引擎而异,但基本思路相同,都是根据已分配对象的大小和数量判断

根据V8团队2016年的一篇博文的说法:在一次完整的垃圾回收之后,V8 的堆增长策略会根据活跃对象的数量外加一些余量,来确定何时再次进行垃圾回收。

内存管理:

浏览器的内存配额比桌面软件少很多,移动端更少。这主要出于安全考虑,防止网页耗尽系统内存导致崩溃。这个限制不仅影响变量分配,也影响调用栈和单线程中能同时执行的语句数量。保持较小的内存占用就是维持更好的页面性能。

优化内存的最佳手段:只保存必要的数据。 数据不再需要时,设为 null 释放引用,即解除引用(Dereferencing)这一设计最适合对全局变量和全局对象属性使用。局部变量超出作用域后会自动解除引用,无需手动处理。

1
2
3
4
5
6
7
8
9
10
11
function createPerson(name){
  let localPerson = new Object();
  localPerson.name = name;
  return localPerson;
}

let globalPerson = createPerson("Alice");

// 解除globalPerson对值的引用

globalPerson = null;

变量globalPerson保存着createPerson()函数调用返回的值。在createPerson()内部,localPerson创建了一个对象并给它添加了一个name属性。然后,localPerson作为函数值被返回,并被赋值给globalPerson。localPerson在createPerson()执行完成、超出上下文后会自动被解除引用,不需要显式处理。而globalPerson是一个全局变量,应该在不再需要时手动解除其引用

解除对一个值的引用并不会自动导致相关内存立刻被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下一次垃圾回收时会被回收。

通过const和let声明提升性能:

因为const和let都以块(而非函数)为作用域,所以相比于使用var,使用这两个ES6新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。

隐藏类和删除操作:

首先要明确的是,这是 V8 引擎内部的优化机制,不是 JS 语言特性。

V8在将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到。

1
2
3
4
5
6
function Article() {
  this.title = 'Inauguration Ceremony Features Kazoo Band';
}

let a1 = new Article();
let a2 = new Article();

V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假设之后又添加了下面这行代码:

1
a2.author = 'Jake';

此时两个Article实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。

解决方案就是避免JavaScript的“先创建再补充”(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性:

1
2
3
4
5
6
7
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关键字会导致生成不同的隐藏类片段(或称导致隐藏类分化):

1
2
3
4
5
6
7
8
9
function Article() {
  this.title = 'Inauguration Ceremony Features Kazoo Band';
  this.author = 'Jake';
}

let a1 = new Article();
let a2 = new Article();

delete a1.author;

在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果一样。

最佳实践是把不想要的属性设置为null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果

1
2
3
4
5
6
7
8
9
function Article() {
  this.title = 'Inauguration Ceremony Features Kazoo Band';
  this.author = 'Jake';
}

let a1 = new Article();
let a2 = new Article();

a1.author = null;

内存泄漏:

JavaScript中的内存泄漏大部分是由不合理的引用导致的。

意外声明全局变量是最常见也最容易修复的内存泄漏问题:

1
2
3
function setName() {
  name = 'Jake';
}

解释器会把变量name当作window的属性来创建(相当于window.name =’Jake’)。可想而知,在window对象上创建的属性,只要window本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前头加上var、let或const关键字即可,这样变量就会在函数执行完毕后离开作用域。

定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:

1
2
3
4
let name = 'Jake';
setInterval(() => {
  console.log(name);
}, 100);

只要定时器一直运行,回调函数中引用的name就会一直占用内存。

使用JavaScript闭包很容易在不知不觉间造成内存泄漏:

1
2
3
4
5
6
let outer = function() {
  let name = 'Jake';
  return function() {
    return name;
  };
};

调用outer()会导致分配给name的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在,即被引用传递(例如被全局变量、DOM、事件监听器、定时器等引用),就不能清理name,因为闭包一直在引用着name变量。假如name的内容很大(不止是一个小字符串),那可能就是个大问题了。

静态分配与对象池:

为了提升JavaScript性能,最后要考虑的一点往往就是压榨浏览器了。减少浏览器执行的垃圾回收次数也是优化 JS 性能的关键一环

开发者无法直接控制垃圾回收何时触发,但可以间接控制触发条件。合理使用内存、避免多余回收,就能保住因释放内存而损失的性能。

浏览器调度垃圾回收的一个标准是对象更替速度。如果大量对象被初始化,然后一下子都超出作用域,浏览器会采用更激进的方式调度垃圾回收,这也会拖慢性能。

1
2
3
4
5
6
function addVector(a, b) {
  let resultant = new Vector();
  resultant.x = a.x + b.x;
  resultant.y = a.y + b.y;
  return resultant;
}

调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个向量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。

假如这个向量加法函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排垃圾回收。

该问题的解决方案是不要动态创建向量对象,比如可以修改上面的函数,让它使用一个已有的向量对象:

1
2
3
4
5
function addVector(a, b, resultant) {
  resultant.x = a.x + b.x;
  resultant.y = a.y + b.y;
  return resultant;
}

这需要在其他地方实例化向量参数resultant,但这个函数的行为没有变。那么在哪里创建向量可以不让垃圾回收调度程序盯上呢?一个策略是使用对象池

在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。

由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。下面是一个对象池的伪实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vectorPool是已有的对象池
let v1 = vectorPool.allocate();
let v2 = vectorPool.allocate();
let v3 = vectorPool.allocate();

v1.x = 10;
v1.y = 5;
v2.x = -3;
v2.y = -6;

addVector(v1, v2, v3);

console.log([v3.x, v3.y]); // [7, -1]

vectorPool.free(v1);
vectorPool.free(v2);
vectorPool.free(v3);

// 如果对象有属性引用了其他对象
// 则这里也需要把这些属性设置为null
v1 = null;
v2 = null;
v3 = null;

如果对象池只按需分配向量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,内存单调增长但保持静态。维护池子用数组较好,但要避免因更新对象池而触发的额外垃圾回收

1
2
3
let vectorList = new Array(100);
let vector = new Vector();
vectorList.push(vector); // 数组从100扩到200,引擎先删后建,垃圾回收程序可能介入

由于JavaScript数组的大小是动态可变的,引擎会删除大小为100的数组,再创建一个新的大小为200的数组。垃圾回收程序会看到这个删除操作,说不定因此很快就会跑来回收一次垃圾。

要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组(静态分配),避免动态扩容带来的删除-重建操作。但需事先估算好容量。

但静态分配是优化的极端形式。只有垃圾回收严重拖慢性能时才考虑。大多数情况下属于过早优化,不必使用。

本文由作者按照 CC BY 4.0 进行授权

阅读DAY3 JavaScript高级程序设计 4章上 变量、作用域和内存

阅读DAY5 JavaScript高级程序设计 5章上 基本引用类型