Javascript高级程序设计-垃圾回收与性能调优

877 阅读16分钟

垃圾回收

JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存,在C和C++语言中,跟踪内存使用对开发者来说是一个很大的负担。也是很多问题的来源。JavaScript为开发者卸下了这个负担。通过自动管理内存实现内存分配和闲置资源回收,这个过程基本是周期性的。即垃圾回收每过一段时间,就会自动执行。垃圾回收的过程是一个近似不完美的解决方案。

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

标记清理

JavaScript最常用的就是标记清理。当变量进入上下文,比如在函数内部声明一个变量时。这个变量会被加上存在于上下文中的标记。而在上下文中的变量。逻辑上讲,永远不应该释放他们的内存。当变量离开上下文时。也会被加上离开上下文的标记。

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

垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后。他会将所有上下文中的变量。以及被在上下文中引用的变量的标记去掉。再次之后被加上标记的变量都是等待被删除了的。随后垃圾回收程序做一次内存清理。销毁带标记的所有的值并且回收他们的内存。

引用计数

另一种没有那么常用的垃圾回收机制就是引用计数。其思路是堆每个值都记录他被引用的次数。声明变量并给他赋值一个引用的时候。这个值的引用次数为1.如果一个值又被赋值给另外一个变量。这个值的引用又加1.类似的,如果保存对该值的引用被覆盖了。那么引用就减一。当一个值的引用次数为0时,那么就无法访问这个值了。垃圾回收程序下次运行的时候就释放所有引用为0的值的内存。

引用计数存在的问题

引用计数最早是由Netscape Navigator 3.0采用。但是很快就遇到了严重的问题。循环引用。所谓循环引用。就是值对象有一个值A指针指向了B。而B对象也指向了A。比如:

function problem(){
    let objectA=new Object()
    let objectB=new Object()
    objectA.someOtherObject=objectB
    objectB.anotherObject=objectA
}

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

COM(Component Object Model)对象

在IE8和更早版本的IE中,并非所有对象都是原生的JavaScript对象。BOM和DOM中的对象都是C++实现的组件对象模型COM(Component Object Model)对象,而COM对象使用引用计数的垃圾回收。因此,即使这些版本的IE的JavaScript引擎使用标记清理。JavaScript存取的COM对象依旧在使用引用计数。换句话说,只要涉及了COM对象,就无法避开循环引用问题。下面这个例子简单展示了涉及COM独享的循环引用问题。

let element=document.getElementById("some_element")
let myObject=new Object()
myObject.element=element
element.someObject=myObject

这个例子在一个DOM对象和一个原生的JavaScript对象之间制造了循环引用。myObject变量有一个名为element的属性指向DOM对象element。而element对象有一个someObject属性指向myObject对象,由于存在循环引用。因此DOM元素的内存永远不会被回收。即使它已经被从页面上删除了也是如此。

为了避免类似的更多的循环引用问题。应该在确保不适用的情况下切断原生JavaScript对象与DOM对象之间的链接。比如,通过以下代码可以清除前面例子建立的循环引用。

myObject.element=null
element.someObject=null

把变量设置为null实际上会切断变量与齐之间引用值的关系。当下次垃圾回收程序运行之时。这些值就会被删除掉。内存也会被回收。

为了补救这一点,IE9吧BOM和DOM对象都改成了JavaScript对象。这同时也避免了由于存在两套垃圾回收算法而导致的问题。还消除了常见的内存泄漏现象。

性能调优

垃圾回收程序会周期性运行。如果内存分配中出现了很多变量。则可能造成性能损失。因此垃圾回收的时间调度非常的重要。由其是在内存有限的移动设备上,垃圾回收可能会明显拖慢渲染速度和帧速率。开发者不知道什么时候运行时会收集垃圾。因此最好的办法就是要做到,无论什么时候收集垃圾,都能让它尽快停止工作。

现代垃圾回收程序会基于对JavaScript对象运行时的环境来探测决定何时运行垃圾回收程序。探测机制由引擎而定。但是基本上都是更具已分配对象的大小和数量来进行判断的。比如。根据V8团队2016年的一篇博客中说:"在一次完整的垃圾回收之后。V8的堆增长机制会根据活跃对象的数量外加一些余量来确定何时进行垃圾回收"

由于调度垃圾回收方面的问题会导致性能下降。IE曾经饱受诟病。他的策略是更具分配数。比如分配了256个变量。4096个对象/数字字面量和数组槽位。或者64KB字符串。只要满足其中某个条件。垃圾回收程序就会运行。这样实现的问题在于。分配那么多变量的脚本。很可能在其整个声明周期内始终需要这么多变量。结果就会导致垃圾回收程序过于频繁的运行。IE7最终更新了垃圾回收程序。

IE7发布之后。JavaScript引擎的垃圾回收程序被调优为改变分配变量、字面量或者数组槽位等会触发垃圾回收机制的阈值。IE7的起始阈值斗都与IE6的相同。如果垃圾回收机制的的内存不到分配的15%,这些变量,字面量或者数组槽位的阈值就会翻倍。如果有一次回收的内存已经达到了分配的85%。则阈值重置为默认值。 这么一个简单的修改。极大的提升了重度依赖JavaScript的网页在浏览器中的性能。

警告: 在某些浏览器中是有可能主动触发垃圾回收机制的。在IE7中window.CollectGarbage()方法会立即触发垃圾回收。在Opera7和更高的版本中,调用window.opera.collect()也会启动垃圾回收。

内存管理

在使用垃圾回收的编程环境里面。开发者通常无需关心内存的管理。不过JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少的多。分配给移动浏览器的就更少了。这更多的是考虑安全而不是别的。就是为了避免运行大量的JavaScript的网页耗尽资源导致操作系统崩溃。这个内存限制不仅影响变量分配。也影响调用栈以及能够同时在一个线程里面执行的语句数量。

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

function createPerson(name){
    let localPerson=new Object()
    localPerson.name=name
    return localPerson
}
let globalPerson =createPerson("Nicolas")
// 解除引用
globalPerson=null

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

不过要注意。解除一个对象的引用并不代表这个对象会被相关的内存回收。解除引用的关键在于确保相关的值已经不在上下文里面了。因此他在下一次垃圾回收的时候会被回收。

01.👩🏼‍🦰通过const和let声明提升性能

ES6新增这两个关键词不仅有助于改善代码风格。而且有助于改进垃圾回收的进程。因为const和let都以快为作用域。所以相比于var。使用这两个新关键词嫩巩固更早的让垃圾回收程序介入。尽可能早的回收应该回收的内存。周期块作用域比函数作用域更早终止的情况下。这就有可能发生。

02.👨🏼‍🦰隐藏类和删除操作

更具JavaScript所在的运行环境。有时候需要更具浏览器使用的JavaScript引擎来获取不同的性能优化策略。截至2017年。Chrome是最流行的浏览器。使用V8javascipt引擎。V8将在解释后的JavaScript代码编译为实际的机器码时会使用隐藏类。如果你的代码非常注重性能。那么这一点可能对你非常重要。

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

function Article(){
    this.title="Insfsrwadasasdasda"
}
let a1=new Article()
let a2=new Article()

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

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

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

function Article(opt_author){
    this.title='xxxxx'
    this.author=opt_author
}
let a1=new Article()
let a2=new Article('java')

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

function Article(opt_author){
    this.title='xxx'
    this.authro=opt_author

}
let article1=new Article()
let article2=new Article('java')
delete article1.author

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

function Article(){
    this.title='xxx'
    this.author='xxx'
}
let a1=new Article()
let a2=new Article()
a1.author=null

内存泄漏

写的不好的JavaScript程序可能会出现难以发现并且有害的内存泄漏问题。在内存有限的设备上.或者在函数会被调用很多次的情况下。.JavaScript程序中的内存泄漏大部分是因为不合理的引用造成的。

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

function setName(){
    name='java'
}

此时,解释器会把变啊零的name作为window的属性来创建,相当于window.name='Java'.可想而知。在window上创建的属性,只要window本身不被清理。就不会消失。这个问题很容易解决。只要在变量声明前加上val,let和const关键字就好了。这样变量会在函数执行完毕之后自动离开作用域。

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

let name='java'
setInterval(()=>{
console.log(name)
},100)

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

使用JavaScript的闭包很容易在不知不觉间造成内存泄漏。请看下面的例子:

let outer=function(){
    let name='java'
    return function(){
        return name
    }
}

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

静态分配与对象池

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

function addVerctor(a,b){
    let resultant=new Vector()
    resultant.x=a.x+b.x
    resultant.y=a.y+b.y
    return resultant
}

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

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

function addVerctor(a,b,resultant){
    resultant.x=a.x+b,x
    resultant.y=a.y+b.y
    return resultant
}

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

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

let v1=verctorPool.allocate()
let v2=verctorPool.allocate()
let v3=verctorPool.allocate()

v1.x=10
v1.y=5
v2.x=-3
v2.y=-5
addVector(v1,v2,v3)
vectorPool.free(v1)
vectorPool.free(v2)
vectorPool.free(v3)
v1=null
v2=null
v3=null

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

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

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

要避免这种动态分配。可以初始化的时候就创建一个大小够用的数组。从而避免上述先删除再创建的操作。不过,必须事先想好这个数组有多大。

注意 静态分配是优化的一种极端方式。如果你的应用程序被垃圾回收严重影响了性能。可以利用它来提高性能。但这种情况并不多见。这都属于过早优化。因此不用考虑。