JavaScript中减少垃圾收集器活动的最佳实践

117

我有一个相当复杂的Javascript应用程序,其主循环每秒调用60次。根据Chrome开发工具中的内存时间轴输出显示,似乎有很多垃圾回收正在进行,这经常影响应用程序的性能。

因此,我正在尝试研究减少垃圾收集器所需工作量的最佳实践。(我在网上找到的大多数信息涉及避免内存泄漏,这是一个略微不同的问题——我的内存被释放了,只是垃圾收集太多了。) 我假设这主要归结于尽可能多地重用对象,但是细节决定成败。

该应用程序以“类”为结构,类似于John Resig的Simple JavaScript Inheritance

我认为其中一个问题是一些函数可以每秒调用数千次(因为它们在主循环的每个迭代中使用数百次),也许这些函数中的本地工作变量(字符串、数组等)可能是问题所在。

我知道针对较大或较重对象的对象池技术(我们在一定程度上使用了这种技术),但我正在寻找可以应用于所有情况的技术,特别是与在紧密循环中被频繁调用的函数有关的技术。

有哪些技术可以减少垃圾回收器的工作量?

或许还有一些方法可以用来识别哪些对象被垃圾回收最多?(代码库相当大,因此比较堆快照并没有太大帮助)


2
你有可以展示给我们的代码示例吗?如果有的话,问题会更容易回答(但也可能不够通用,所以我在这里不确定)。 - John Dvorak
2
停止每秒运行数千次的函数怎么样?这真的是唯一的方法吗?这个问题似乎是一个 XY 问题。你正在描述 X,但你真正需要的是解决 Y 的方案。 - Travis J
3
@TravisJ:他每秒只运行60次,这是相当常见的动画速率。他并不要求做更少的工作,而是要如何更有效地进行垃圾回收。 - Bergi
1
@Bergi - "一些函数可能每秒被调用数千次"。这意味着每毫秒一次(可能更糟!)。这并不常见。每秒60次不应该是问题。这个问题过于模糊,只会产生意见或猜测。 - Travis J
4
在游戏框架中,这种情况非常普遍。 - UpTheCreek
显示剩余3条评论
4个回答

160

许多减少GC运行时的方法在其他大多数情况下都不符合JS惯用法,请在评估我给出建议时,注意上下文。

现代解释器中会发生分配:

  1. 使用new或文字语法[...]{}创建对象时。
  2. 连接字符串时。
  3. 进入包含函数声明的作用域时。
  4. 执行触发异常的操作时。
  5. 评估函数表达式:(function (...) { ... })时。
  6. 执行强制转换为对象的操作,如Object(myNumber)Number.prototype.toString.call(42)
  7. 调用秘密做任何这些事情的内置函数,如Array.prototype.slice
  8. 使用arguments反射参数列表时。
  9. 拆分字符串或使用正则表达式匹配时。

避免使用以上方法,并在可能的情况下池化和重复使用对象。

具体来说,要寻找机会:

  1. 将没有或仅有少量依赖于封闭状态的内部函数拉到更高的、更长寿命的范围内。(一些代码缩小工具,如Closure compiler,可以内联内部函数,并可能提高GC性能。)
  • 避免使用字符串来表示结构化数据或用于动态寻址。特别是要避免重复使用 split 或正则表达式匹配进行解析,因为每次操作都需要多个对象分配。这经常发生在查找表和动态DOM节点ID的键入中。例如,lookupTable['foo-' + x]document.getElementById('foo-' + x) 都涉及到字符串拼接的分配。通常可以将键附加到长期存在的对象上,而不是重新连接。根据需要支持的浏览器,您可能可以使用Map来直接将对象用作键。
  • 避免在正常代码路径上捕获异常。不要使用 try { op(x) } catch (e) { ... },而是使用 if (!opCouldFailOn(x)) { op(x); } else { ... }.
  • 当无法避免创建字符串时,例如将消息传递给服务器时,请使用内置工具如 JSON.stringify,该工具使用内部本机缓冲区来累积内容,而不是分配多个对象。
  • 避免在高频事件中使用回调函数,并且在可以的情况下,将一个长期存在的函数(参见1)作为回调传递,该函数从消息内容中重新创建状态。
  • 避免使用 arguments,因为使用它的函数在被调用时必须创建类似数组的对象。
  • 我建议使用JSON.stringify来创建发送到网络的消息。解析输入消息使用JSON.parse显然涉及分配,对于大型消息会有大量分配。如果您可以将传入的消息表示为原始数组,则可以节省很多分配。唯一可以构建不分配解析器的内置函数是String.prototype.charCodeAt。但如果只使用它来解析复杂格式,则可能会阅读非常困难。


    你不觉得JSON.parse后的对象分配的空间比消息字符串少(或相等)吗? - Bergi
    @Bergi,这取决于属性名称是否需要单独分配,但生成事件而不是解析树的解析器不会进行多余的分配。 - Mike Samuel
    非常棒的答案,谢谢!非常抱歉赏金已过期 - 我当时正在旅行,由于某种原因我无法在手机上使用我的 Gmail 帐户登录 SO.... :/ - UpTheCreek
    1
    为了弥补我之前赏金发布的不当时机,我额外添加了一个赏金来补偿(200是我能给的最低数额;)- 不过由于某种原因,它要求我等待24小时才能授予它(即使我选择了“奖励现有答案”)。明天将会是你的... - UpTheCreek
    @UpTheCreek,不用担心。我很高兴你觉得它有用。 - Mike Samuel
    显示剩余7条评论

    14

    Chrome开发者工具有一个非常好的功能,可以追踪内存分配。它被称为内存时间轴。本文描述了一些细节。我想这就是你所说的“锯齿形”吧?对于大多数GC运行时来说,这是正常的行为。分配会继续进行,直到达到使用阈值触发收集。通常在不同的阈值下有不同类型的收集。

    Memory Timeline in Chrome

    垃圾回收会随着跟踪事件列表一起包含,并显示其持续时间。在我的旧笔记本电脑上,短暂的回收大约为4Mb并需要30ms。这相当于您60Hz循环迭代中的2个。如果这是动画,则30ms的回收可能会导致卡顿。您应该从这里开始查看环境的情况:回收阈值在哪里以及回收所需的时间有多长。这为您提供了一个参考点来评估优化。但是,通过减少卡顿频率,减缓分配速率,延长回收间隔,您可能不会做得更好。
    下一步是使用“Profiles | Record Heap Allocations”功能生成按记录类型分类的分配目录。这将快速显示跟踪期间消耗最多内存的对象类型,这相当于分配速率。按下降顺序依次关注这些内容。
    这些技术并不高深。如果可以使用非装箱对象,请避免使用装箱对象。使用全局变量来保存和重用单个装箱对象,而不是在每次迭代中分配新的对象。在空闲列表中汇集常见对象类型,而不是放弃它们。缓存字符串连接结果,以便将来的迭代中可能会重新使用。通过在封闭作用域中设置变量来避免仅为返回函数结果而进行分配。您将不得不考虑每种对象类型的上下文,以找到最佳策略。如果需要具体帮助,请发布一篇说明所面临挑战细节的编辑。
    我建议不要在整个应用程序中扭曲您的正常编码风格,以试图生成更少的垃圾。这与过早优化速度的原因相同。大多数努力加上代码的复杂性和晦涩无比将是毫无意义的。

    没错,我所说的就是锯齿形状。我知道总会有某种形式的锯齿图案,但我的担忧是,对于我的应用程序来说,锯齿频率和“悬崖”相当高。有趣的是,GC事件在我的时间轴上并没有显示出来 - 在“记录”窗格(中间的窗格)中出现的唯一事件是:request animation frameanimation frame firedcomposite layers。我不知道为什么我没有像你那样看到GC Event(这是在最新版本的Chrome和Canary上)。 - UpTheCreek
    4
    我尝试过使用“记录堆分配”的分析器,但到目前为止并没有发现它非常有用。也许这是因为我不知道如何正确地使用它。它似乎充满了对我毫无意义的引用,例如@342342code relocation info - UpTheCreek
    2
    关于“过早优化是万恶之源”的问题:理解它,不要盲目跟从。在某些情况下,比如游戏和多媒体编程,性能至关重要,你会有很多“热点”代码。所以,是的,你需要调整你的编程风格。 - snarf

    9
    作为一个通用原则,您希望尽可能地缓存并在每次循环运行时尽可能少地创建和销毁。
    我首先想到的是减少主循环中匿名函数的使用(如果有的话)。此外,容易陷入创建和销毁传递给其他函数的对象的陷阱。虽然我不是JavaScript专家,但我认为以下代码可能更好:
    var options = {var1: value1, var2: value2, ChangingVariable: value3};
    function loopfunc()
    {
        //do something
    }
    
    while(true)
    {
        $.each(listofthings, loopfunc);
    
        options.ChangingVariable = newvalue;
        someOtherFunction(options);
    }
    

    将会比这个运行得更快:

    while(true)
    {
        $.each(listofthings, function(){
            //do something on the list
        });
    
        someOtherFunction({
            var1: value1,
            var2: value2,
            ChangingVariable: newvalue
        });
    }
    

    你的程序会有停机时间吗?也许你需要它能够平稳地运行一两秒钟(例如用于动画),之后再有更多时间来处理?如果是这种情况,我可以看到在动画期间通常会进行垃圾回收的对象,并将其保留在某个全局对象中。然后当动画结束时,您可以清除所有引用,让垃圾收集器完成其工作。
    如果相比你已经尝试和考虑的事情,这一切都有些琐碎,那我很抱歉。

    这种情况经常被滥用,会消耗大量内存并且很容易被忽略:在其他函数中(不是IIFE)提到的加法也是如此。 - Esailija
    谢谢Chris!不过我可没有任何闲暇时间 :/ - UpTheCreek

    4
    我会在全局作用域中创建一个或多个对象(确保垃圾回收器无法接触),然后尝试重构解决方案以使用这些对象完成任务,而不是使用局部变量。
    当然,并非在代码的每个地方都可以这样做,但通常这是我避免垃圾回收的方式。
    附注:这可能会使代码的特定部分稍微难以维护。

    垃圾回收器一直清除我的全局作用域变量。 - VectorVortec

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接