重读《Javascript高级程序设计》- 垃圾收集

146 阅读8分钟

背景

我们都知道,javascript是一门具有自动垃圾收集机制额编程语言,开发人员不必关心内存的分配和回收的问题。但是作为一个合格的开发人员,我们还是需要去理解javascript内部是如何去实现内存的分配和回收的机制的。同时在《javascript高级设计》这本书的书的第四章节有关于这个问题的描述,所以在这里也当作是一次整理和加深自己对于垃圾回收的理解。

关于内存的生命周期

首先,我们需要知道,对于所有语言,内存的生命周期基本上是一致的。 image.png

内存分配

对于Javascript来说,用户在定义一个变量的时候,就已经完成了内存的分配。 image.png

使用内存

使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

image.png

内存释放

当分配的内存不再使用时,去自动释放当前的内存。

image.png

但是,什么样的内存会被判度成一个不被需要的内存呢?

在书中的小结有这样一句话:离开作用域的值将被自动标记为可以回收,因此将在垃圾收集期间被删除。

垃圾收集的原理

刚才也说过,离开作用域的值才会被回收。但是我们是如何定义离开作用域的呢。

执行环境是什么

在javascript中有一个非常重要的概念:执行环境(Execution context)

执行环境定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境中都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。

当执行环境中的所有代码执行完后,该环境被销毁,保存在其中的所有变量和函数定义也会被随之销毁。

var color = 'blue'

function changeColor() {
    var anotherColor = 'red'
    
    function swapColors() {
        var tempColor = anotherColor
        anotherColor = color
        color = tempColor
        
        // 这里可以访问color、anotherColor、tempColor
    }
    // 这里可以访问color、anotherColor。但是不能访问tempColor
    swapColors()
}
// 这里只可以访问color
changeColor()

image.png

在这里函数中局部变量的生命周期大致分为几个步骤:

  1. 局部变量只在函数执行的过程中存在。
  2. 在函数执行的过程中,会为局部变量在栈(或堆)内存上分配相应的空间,以便存储它们的值。
  3. 在函数中使用这些变量,直至函数执行结束。
  4. 此时可以判断局部变量没有村子的必要,因此可以释放他们的内存以供将来使用。

在全局环境中,只有应用程序退出,如关闭网页或浏览球,才会被销毁。

但是,并非所有的情况都是这额容易就能得出是否需要销毁的结论的。垃圾收集齐必须跟踪哪个变量有用,那个变量没用,对于不再有用的变量打上标记,以备将来回收其占用的内存。对于标识无用变量的策略因实现而异,但是具体到浏览器中的实现,大致分为两种:引用计数标记清除

引用计数

引用计数的思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。

引用计数存在循环引用的问题

循环引用是指就是对象A有一个指针指向对象B,而对象B也引用了对象A。

functon problem() {
    var objA = new Object()
    var objB = new Object()
    
    objA.some = objB
    objB.some = objA
}

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

var el = document.getElementById('id')
var obj = new Object()
obj.el = el
el.obj = obj

为了解决上述问题,IE9把BOM和DOM都转换成了真正的Javascript对象。这样就避免了两种垃圾并存导致的问题,也消除了常见的泄漏现象。

标记清除(mark-and-sweep)

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

标记清除不存在循环引用的问题

在上面的第一个示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。

在V8中的标记清除

我们知道系统分配给Web浏览器的可用内存数量通常比分配给桌面应用程序要少。在默认配置下,64位系统约为1.4G,32位系统约为0.7G。同时在V8引擎中,会将内存分为新生代老生代两个部分。同时新生代会进一步分为nursery(from-space)子代intermediate(to-space)子代两个区域。

image.png

新生代的Scavenge算法

Scavenge算法把新生代空间对半分为对象区域和空闲区域,新加入的对象会放到对象区域,当新生代区域快要被写满的时候就会执行一次垃圾清理的操作。

过程

检查 From 空间的存活对象,若对象存活,则检查对象是否符合晋升条件,若符合条件则晋升到老生代,否则将对象从 From 空间复制到 To 空间。若对象不存活,则释放不存活对象的空间。完成复制后,将 From 空间与 To 空间进行翻转。

image.png

晋升条件的条件
  1. 对象是否经历过Scavenge回收。对象从 From 空间复制 To 空间时,会检查对象的内存地址来判断对象是否已经经过一次Scavenge回收。若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。
  2. To 空间的内存使用占比是否超过限制。当对象从From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。设置为25%的比例的原因是,当完成 Scavenge 回收后,To 空间将翻转成From 空间,继续进行对象内存的分配。若占比过大,将影响后续内存分配。

老生代的标记清除算法

由于老生区的对象比较大,若要在老生区中使用 Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高, 同时还会浪费一半的空间。因而,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。

image.png

内存管理

上述也提到过,系统分配给Web浏览器的可用内存通常比桌面程序要少。这样做对的目的主要是出于安全方面的考虑,目的是为了防止运行javascript的网页耗尽全部系统内存导致系统崩溃。然而内存限制不仅会影响给变量分配内存,同时还会影响调用栈以及有一个线程中能够同时执行的语句数量。

因此,确保占用最少内存可以让页面获取更好的性能。而优化内存占用的最佳方式,就是执行中的代码只保存必要数据。一旦数据不可用,最好将其值设置成null来释放其引用 - 解除引用。这一做法适用于大多数全局变量全局对象的属性

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

var globalPerson = createPerson('Corry')

// 手工解除引用
globalPerson = null

值得注意的是,解除引用不意味着自动回收该值所占用的内存。解除引用的作用是让值脱离执行环境,以便于垃圾收集器下次运行时将其回收。

结束语

2022.3.6

木更