• 垃圾回收-《JavaScript 高级程序设计》阅读笔记


    垃圾回收

    目录

    JavaScript 垃圾回收的基本思路

    JavaScript 是使用垃圾回收的语言,即执行环境负责在代码执行时管理内存

    JavaScript 通过自动内存管理实现内存分配和闲置资源回收,基本思路为:确定哪个变量不再使用,然后释放它占用的内存

    垃圾回收的过程是周期性的,垃圾回收程序每隔一定时间(或者说在代码执行过程中执行某个预定的收集时间)就会自动运行。

    垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于不可判定问题,意味着依靠算法无法解决。

    函数中局部变量的不可判定问题示例

    以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。栈(或堆)内存会分配空间以保存相应的值。

    函数在内部使用局部变量,在函数退出函数执行栈时,不再需要局部变量,此时局部变量占用的内存可以释放,供后续代码继续使用。

    上述情况对于局部变量的内存管理十分清晰简单,但并不是所有时候都如此明显。

    垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存

    在浏览器的发展史上用到过两种主要的标记未使用变量的实现方式:标记清理和引用计数。


    标记清理

    JavaScript 最常用的垃圾回收策略就是标记清理(mark-end-sweep)。

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

    变量添加标记的方式

    当变量进入上下文时,反转某一位;

    维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。

    当然,标记过程的实现并不重要,关键在于策略。

    标记清理的策略

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

    到 2008 年,IE、Firefox、Opera、Chrome 和 Safari 均在自己的 JavaScript 实现中采用标记清理(或其变体),只是在运行垃圾回收的频率上有所差异。


    引用计数

    引用计数(reference counting)垃圾回收策略并不常用。

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

    引用计数的问题 - 循环引用

    所谓循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A。

    function problem() {
        let objectA = new Object();
        let objectB = new Object();
    
        objectA.someOtherObject = objectB;
        objectB.anotherObject = objectA;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在上述例子中,objectA 和 objectB 通过各自的属性相互引用,意味着它们各自的引用数为 2。

    在标记清理策略下,在函数结束后,两个对象都不存在作用域中,其占用内存会被清理。

    而在引用计数策略下,由于它们互相引用,其引用数永远不会变为 0,故而在函数结束后仍会存在。如果函数被多次调用,则会导致大量内存永远不会被释放。


    性能

    垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调用很重要。

    现代垃圾回收程序会基于对 JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和属性进行判断


    内存管理

    出于安全考虑,避免运行大量 JavaScript 代码的的网页耗尽系统内存而导致操作系统崩溃。故而分配给浏览器的内存通常比分配给桌面软件的内存要少很多,分配给移动浏览器的内存更少。

    上述内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量

    将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用,即解除引用

    上述建议最适合全局变量和全局对象的属性,因为局部变量在超出其作用域后会被自动接触引用。

    function createPerson(name) {
        let localPerson = new Object();
        localPerson.name = name;
        return localPerson;
    }
    let globalPerson = createPerson("huaqi");
    
    // ...
    
    // 解除 globalPerson 对值的引用
    globalPerson = null;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    在上述代码中,变量 localPerson 会在 createPerson() 函数执行完毕后超出执行上下文然后自动被解除引用,不需要显式处理。但对于全局变量 globalPerson,应该在不需要时手动解除其引用。

    :解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在当前上下文中,使其在下一次垃圾回收时被回收

    通过 const 和 let 声明提升性能

    关键字 const 和 let 不仅有助于改善代码风格,同样有助于改进垃圾回收的过程

    因为 const 和 let 都使用块作用域(非函数作用域),相比于使用 var,在块作用域比函数作用域更早终止的情况下,它们会更早地让垃圾回收程序介入,尽早回收应该回收的内存。

    隐藏类和删除操作

    根据 JavaScript 所在的运行环境,有时候需要根据浏览器使用 JavaScript 引擎以采取不同的性能优化策略。

    V8 JavsScript 引擎会将解释后的 JavaScript 代码编译为实际的机器码时会利用隐藏类

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

    function Article() {
        this.title = "huaqi";
    }
    
    let a1 = new Article();
    let a2 = new Article();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    V8 会在后台进行配置,使上述两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。

    若在上述代码下添加:

    a2.author = "huaqi";
    
    • 1

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

    上述问题的解决方案:避免 JavaScript 进行先创建再补充式(ready-fire-aim)的动态属性赋值,在构造函数中一次声明所有需要的属性。

    funciton Article(opt_author) {
        this.title = "huaqi";
        this.author = opt_author;
    }
    
    let a1 = new Article();
    let a2 = new Article("huaqi");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    上述代码中的两个 Article 实例基本上一致(不考虑 hasOwnProperty 的返回值),共享一个隐藏类,从而带来潜在的性能提升。但是,使用 delete 关键字会导致再生成相同的隐藏类片段

    funciton Article(opt_author) {
        this.title = "huaqi";
        this.author = opt_author;
    }
    
    let a1 = new Article();
    let a2 = new Article("huaqi");
    
    delete a1.author;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    V8 解释完上述代码后,尽管两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果一样

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

    funciton Article(opt_author) {
        this.title = "huaqi";
        this.author = opt_author;
    }
    
    let a1 = new Article();
    let a2 = new Article("huaqi");
    
    a1.author = null;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    内存泄漏

    不好的 JavaScript 代码可能出现难以察觉且有害的内存泄露问题。

    在内存有限的设备上或在函数会被调用很多次的情况下,内存泄露可能是个大问题,JavaScript 中的内存泄露大部分是由于不合理的引用导致的

    意外声明全局变量

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

    function setName() {
        name = "huaqi";
    }
    
    • 1
    • 2
    • 3

    针对上述代码,解释器会将变量 name 作为全局对象 window 的属性进行创建(window.name = “huaqi”)。而在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。

    解决上述问题,只需要在变量声明前添加 var、let 或 const 关键字即可,这样变量就会在函数执行完毕后离开作用域,进而被清除。

    定时器引用外部变量

    定时器也可能会悄悄地导致内存泄露,下述代码中,定时器的回调通过闭包引用了外部变量,只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。因而垃圾回收程序就不会清理外部变量 name。

    let name = "huaqi";
    setInterval(() => {
        console.log(name);
    }, 100);
    
    • 1
    • 2
    • 3
    • 4

    闭包导致内存泄露

    let outer = function() {
        let name = "huaqi";
        return function() {
            return name;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    调用函数 outer() 会导致分配给 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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    调用上述 addVector() 函数时,会在堆上创建一个新对象,然后修改它,最后再将其返回给调用者。

    如果这个矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。

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

    上述问题的解决方案:不再动态创建矢量对象,使用已有的矢量对象。

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

    当然,上述代码需要在其他地方实例化矢量类 Vector。

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

    由于对象池的存在,没有进行频繁的对象初始化,垃圾回收探测不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。

    对象池的伪实现:

    // 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
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

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

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

    由于 Javascript 数组的大小是动态可变的,在执行 vectorList.push(vector) 代码时,引擎会删除大小为 100 的数组,再创建新的大小为 200 的数组。

    当垃圾回收程序检测到上述的删除操作,可能会很快地进行一次垃圾回收。所以,要避免上述这种动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除原有数组,再创建新的数组的操作。

    静态属性分配优化是一种极端形式。如果应用程序被垃圾回收严重降低性能,可以利用其提升性能。但这种情况并不多见。大多数情况下,都属于过早优化。

  • 相关阅读:
    matplotlib图表的样式
    伺服驱动器的位置控制模块电路原理是什么?
    Java实现ATM架构设计
    [附源码]java毕业设计企业物资信息管理系统
    vue项目纵向撑满屏幕不出现滚动条
    UML类图
    电脑文件一团乱?试试这个高效率的管理软件
    Vue 路由
    初创公司用“豆包”大模型,日均tokens两个月内增长357倍
    Python之写文件操作(二十九)
  • 原文地址:https://blog.csdn.net/huaqi_/article/details/126004054