使用对象池的静态内存 JavaScript

Colt McAnlis
Colt McAnlis

简介

假设您收到一封电子邮件,其中指出您的 Web 游戏 / Web 应用在运行一段时间后性能不佳。您仔细检查了代码,但没有发现任何明显的问题,直到您打开 Chrome 的内存性能工具,看到以下内容:

内存时间轴的快照

您的一位同事笑了,因为他们意识到您遇到了与内存相关的性能问题。

在内存图表视图中,这种锯齿状模式非常能说明可能存在的严重性能问题。随着内存用量的增加,您会在时间轴截图中看到图表区域也会随之增大。如果图表突然下降,则表示垃圾回收器已运行并清理了您引用的内存对象。

锯齿形图标的含义

在这样的图表中,您可以看到发生了大量垃圾回收事件,这可能会对您的 Web 应用的性能造成不利影响。本文将介绍如何控制内存用量,从而减少对性能的影响。

垃圾回收和性能开销

JavaScript 的内存模型基于一种称为“垃圾回收器”的技术构建而成。在许多语言中,程序员直接负责从系统的内存堆分配和释放内存。不过,垃圾回收器系统会代表程序员管理此任务,这意味着对象不会在程序员解引用时直接从内存中释放,而是在垃圾回收器的启发词语确定这样做有益时再释放。此决策过程要求垃圾回收器对活动对象和非活动对象执行一些统计分析,这需要一段时间才能完成。

垃圾回收通常被描述为与手动内存管理相反,后者需要程序员指定要取消分配哪些对象并返回给内存系统

GC 回收内存的过程并非免费的,它通常会占用一段时间来执行其工作,从而降低可用性能;此外,系统本身会决定何时运行。您无法控制此操作,GC 脉冲可能会在代码执行期间随时发生,并且会阻止代码执行,直到完成为止。您通常不知道此脉冲的持续时间;它需要运行一段时间,具体取决于您的程序在任何给定时间点内存的使用方式。

高性能应用依赖于一致的性能边界,以确保为用户提供流畅的体验。垃圾回收器系统可能会破坏这一目标,因为它们可能会在随机时间运行并持续随机时长,从而侵占应用满足其性能目标所需的可用时间。

减少内存流失,减少垃圾回收开销

如前所述,当一组启发词语确定有足够的非活跃对象且进行脉冲有益时,就会发生 GC 脉冲。因此,减少垃圾回收器占用应用时间的关键在于尽可能消除过多对象创建和释放的情况。这种频繁创建/释放对象的过程称为“内存抖动”。如果您能够在应用生命周期内减少内存抖动,还可以缩短 GC 在执行过程中所花的时间。这意味着,您需要移除 / 减少创建和销毁的对象数量,实际上,您必须停止分配内存。

此过程会将内存图表从以下位置移至:

内存时间轴的快照

更改为:

静态内存 JavaScript

在此模型中,您可以看到图表不再呈现锯齿状模式,而是在开始时增长幅度很大,然后随着时间的推移而缓慢增加。如果您因内存抖动而遇到性能问题,则需要创建此类图表。

朝着静态内存 JavaScript 迈进

静态内存 JavaScript 是一种技术,涉及在应用启动时预分配其生命周期所需的所有内存,并在执行期间管理不再需要的对象的内存。我们可以通过以下几个简单的步骤实现这一目标:

  1. 对应用进行插桩,以确定各种使用场景所需的实时内存对象(按类型)的数量上限
  2. 重新实现代码以预分配该上限,然后手动提取/释放它们,而不是进入主内存。

实际上,要实现第 1 点,我们需要完成一些第 2 点的工作,因此我们先从第 2 点开始。

对象池

简单来说,对象池是指保留一组共享类型的未使用的对象的过程。当您的代码需要新对象时,您可以从池中回收一个未使用的对象,而不是从系统内存堆分配一个新对象。外部代码使用完对象后,会将其返回到池中,而不是将其释放到主内存。由于系统从未从代码中解引用(也称为删除)该对象,因此不会对其进行垃圾回收。利用对象池可将内存控制权重新交还给程序员,从而减少垃圾回收器对性能的影响。

由于应用会维护一组异构的对象类型,因此若要正确使用对象池,您需要为应用运行时内出现高流失率的每种类型创建一个池。

var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};

//..... do some stuff with the object that we need to do

gEntityObjectPool.free(newEntity); //free the object when we're done
newEntity = null; //free this object reference

对于大多数应用,您最终会在需要分配新对象时达到某个水平。在多次运行应用后,您应该能够很好地了解此上限,并可以在应用启动时预分配该数量的对象。

预分配对象

将对象池实现到您的项目中,可让您在应用运行时获得理论上所需的对象数量上限。在网站通过各种测试场景运行后,您可以很好地了解所需的内存要求类型,并可以在某个位置对这些数据进行编目,然后对其进行分析,以了解应用的内存要求上限。

然后,在应用的正式版中,您可以将初始化阶段设置为将所有对象池预填充到指定数量。此操作会将所有对象初始化推送到应用前面,并减少执行期间发生的动态分配量。

function init() {
  //preallocate all our pools. 
  //Note that we keep each pool homogeneous wrt object types
  gEntityObjectPool.preAllocate(256);
  gDomObjectPool.preAllocate(888);
}

您选择的数量与应用的行为密切相关;有时,理论上限并不是最佳选择。例如,选择平均上限可能会使非高级用户的内存占用量更小。

远非万灵丹

在某些类型的应用中,静态内存增长模式可以发挥作用。不过,正如 Chrome DevRel 同事 Renato Mangini 指出的那样,这种方法也存在一些缺点。

总结

JavaScript 非常适合 Web 开发,这其中的一个原因在于,它是一门上手快、有趣且简单的语言。这主要是因为它对语法限制的门槛较低,并且会代表您处理内存问题。您只需编写代码,让它处理繁琐的工作。不过,对于高性能 Web 应用(例如 HTML5 游戏),GC 通常会占用极其重要的帧速率,从而降低最终用户的体验。通过一些精心设计的插桩和采用对象池,您可以减轻帧速率的负担,并将时间用于更出色的事物。

源代码

网络上有很多对象池实现,因此我不会再向您介绍另一种实现。不过,我会为您介绍以下几种方法,每种方法都有特定的实现细微之处;鉴于每种应用用例可能都有特定的实现需求,这一点非常重要。

参考