垃圾回收

更新时间: 2021-02-19 16:17:02

JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在C和C++等语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。JavaScript为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再使用,然后释放它们占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。

我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈(或堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用。这种情况显然不再需要局部变量了,但并不是所有时候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器发展史上,用到过两种主要的标记策略:标记清理和引用计数。

# 一.标记清理

JavaScript最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现并不重要,关键是策略。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并回收它们的内存。

# 二.引用计数

另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。 引用计数最早由Netspace Navigator3.0采用,但很快就遇到了严重的问题:循环引用。所谓循环引用,就是对象A有个指针指向对象B,而对象B也引用了对象A。比如:

funciton problem() {
  let objectA = new Object()
  let objectB = new Object()

  objectA.someOtherObject = objectB
  objectB.anotherObject = objectA
}
1
2
3
4
5
6
7

在这个例子中,objectAobjectB通过各自的属性相互引用,意味着它们的引用数都是2。在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域种。而在引用计数策略下,objectAobjectB在函数结束后还会存在,因为它们的引用数永远不会变成0。如果函数被多次调用,则会导致大量内存永远不会被释放。为此,Netspace在4.0版放弃了引用计数,转而采用标记清理。

# 三.内存管理

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手动就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为null,从而释放其引用。这也可以叫做解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,如下面的例子所示:

function createPerson(name){
  let localPerson = new Object()
  localPerson.name = name
  return localPerson
}

let globalPerson = createPerson("Nicholas")
//解除globalPerson对值的引用
globalPerson = null
1
2
3
4
5
6
7
8
9

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

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

# 1.通过const和let声明提升性能

ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为constlet都以块(而非函数)为作用域,所以相对于var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。

# 2.隐藏类和删除操作

根据JavaScript所在的运行环境,有时候需要根据浏览器使用的JavaScript引擎来采取不同的性能优化策略。截至2017年,Chrome是最流行的浏览器,使用V8 JavaScript引擎。V8将在解释后的JavaScript代码编译为实际的机器代码时利用“隐藏类”。

运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定能够做到。比如下面的代码:

function Article(){
  this.title = 'Inauguration Ceremony Features Kazoo Band';
}

let a1 = new Article()
let a2 = new Article()
1
2
3
4
5
6

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

a2.author = 'Jake'
1

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

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

function Article(opt_author) {
  this.title = 'Inaguration Cremony Features Kazoo Band';
  this.author = opt_author;
}

let a1 = new Article()
let a2 = new Article('Jake')
1
2
3
4
5
6
7

这样,两个实例基本上就一样了(不考虑hasOwnProperty的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升。不过要记住,使用delete关键字会导致生成相同的隐藏类片段。看一下这个例子:

function Article(){
  this.title = 'Inaguration Cremony Features Kazoo Band';
  this.author = opt_author;
}

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

delete a1.author;
1
2
3
4
5
6
7
8
9

在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果一样。最佳实践就是把不想要的属性设置为null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。比如:

function Article(){
  this.title = 'Inaguration Cremony Features Kazoo Band';
  this.author = opt_author;
}

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

a1.author = null
1
2
3
4
5
6
7
8
9

# 3.内存泄漏

写得不好的JavaScript可能会出现难以察觉而且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript中的内存泄漏大部分是由不合理的引用导致的。

意外声明全局变量是最常见但也最容易修复的内存泄漏问题。下面代码没有使用任何关键字声明变量:

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

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

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

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

只要定时器一直运行,回调函数中引用的name就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。

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

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

调用outer()会导致分配给name的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理name,因为闭包一直在引用着它。假如name的内容很大,那可能就是一个大问题了。

# 4.静态分配与对象池

为了提升JavaScript性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。

浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影响性能。下面是一个计算二维矢量加法的函数:

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

调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。假如这个矢量加法被频繁调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁的安排垃圾回收。

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

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

当然,这需要在其他地方实例化矢量参数resultant,但这个函数的行为没有变。那么在哪里创建矢量可以不让垃圾回收调度程序盯上呢?

一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后再操作完成后再把它还给对象池。由于没有发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。下面是一个对象池的伪实现:

//verctorPool是一个已有的对象池
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
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的一种选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。

let vectorList = new Array(100)
let vector = new Vector()
vectorList.push(vector)
1
2
3

由于JavaScript数组的大小是动态可变的,引擎会删除大小为100的数组,再创建一个新的大小为200的数组。垃圾回收程序看到这个删除操作,说不定因此很快就跑来收一次垃圾。要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除在创建的操作。不过,必须事先想好这个数组有多大。

注意

静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。