JavaScript 引擎中的内存管理

简介

本文档描述了 QML 中 JavaScript 引擎的 动态 内存管理。这是一篇相当技术性、深入的描述。只有当你关心 QML 中 JavaScript 内存管理的确切特征时才需要阅读。特别是,如果你尝试为最大性能优化你的应用程序,这可能有所帮助。

注意:使用 Qt Quick Compiler 将你的 QML 代码编译为 C++,你可以避免 JavaScript 堆的大多数使用。生成的 C++ 代码使用熟悉的 C++ 栈和堆来存储对象和值。然而,JavaScript 宿主环境始终会使用一些 JavaScript 管理内存,无论你是否使用它。如果你使用无法编译到 C++ 的功能,引擎将回退到解释或 JIT 编译,并使用存储在 JavaScript 堆上的 JavaScript 对象。

基本原理

QML 中的 JavaScript 引擎有一个专门的内存管理器,从操作系统请求多个页面大小的地址空间。在 JavaScript 中创建的对象、字符串和其他管理值随后放置在此地址空间中,使用 JavaScript 引擎自己的分配方案。JavaScript 引擎不使用 C 库的 malloc() 和 free(),也不使用 C++ 的 new 和 delete 的默认实现来为 JavaScript 对象分配内存。

地址空间的请求通常在类 Unix 系统上使用 mmap(),在 Windows 上使用 VirtualAlloc()。这些原语有几个平台特定的实现。以这种方式保留的地址空间不会立即提交到物理内存。相反,操作系统注意到实际访问了内存的一页,然后才会提交。因此,这只是事实上的空闲空间。拥有大量的地址空间可以为 JavaScript 内存管理器提供它在 JavaScript 堆上以有效方式放置对象所需的影响力。此外,还有特定于平台的技术可以通知操作系统,尽管保留了地址空间的一部分,但暂时不需要将其映射到物理内存。操作系统可以按照需要对其进行取消提交,并将其用于其他任务。关键的是,大多数操作系统不对此类取消提交请求立即采取行动。它们只会在实际需要用于其他某项任务时才取消提交内存。在类 Unix 系统上,我们通常使用 madvise() 来实现这一点。Windows 有 VirtualFree() 的特定标志来执行等效的操作。

注意:有内存分析工具不理解这种机制并过度报告 JavaScript 内存使用。

所有存储在JavaScript堆上的值都受垃圾回收管理。当值超出作用域或以其他方式“丢弃”时,它们都不会立即“删除”。只有垃圾回收器可以从JavaScript堆中删除值并释放内存(有关如何工作的详细信息,请参阅下文中的垃圾回收)。

基于QObject的类型

基于QObject的类型,尤其是你可以将其表达为QML元素的任何内容,都是在C++堆上分配的。当从JavaScript访问QObject时,仅将指向指针的小型包装放在JavaScript堆上。但是,这样的包装可以拥有它指向的QObject。请参阅QJSEngine::ObjectOwnership。如果包装器拥有该对象,则包装器在垃圾回收时会被删除。你可以通过调用它的destroy()方法手动触发删除。destroy()在内部调用QObject::deleteLater。因此,它不会立即删除对象,而是等待下一次事件循环迭代。

对象在JavaScript堆上存储QML声明的属性。它们在它们所属的对象生存期内存活。之后,它们将在垃圾回收器下一次运行时被删除。

对象分配

在JavaScript中,任何结构化类型都是对象。这包括函数对象、数组、正则表达式、日期对象等等。QML有几种植入的对象类型,如上述提到的QObject包装器。每次创建对象时,内存管理器都会在JavaScript堆上为其定位一些存储空间。

JavaScript字符串同样是管理值,但它们的字符串数据不是分配在JavaScript堆上的。类似于QObject包装器,字符串的堆对象只是指向字符串数据指针的细包装。

为对象分配内存时,首先将对象的大小向上舍入到32字节对齐。地址空间中的每32字节块被称为“槽”。对于小于“巨大大小”阈值的对象,内存管理器会进行一系列尝试将其放入内存中

  • 内存管理器保留了一连串先前释放的堆块,称为“bin”,每个bin包含具有固定每bin大小的槽的堆块。如果所需大小的bin不为空,它会选择第一条记录并将其放置在那里。
  • 未使用过的内存通过缓冲区分配器进行管理。缓冲区指针指向占用地址空间之外的最后一个字节。如果有足够的未使用地址空间,则相应地增加缓冲区,并将对象放置在未使用空间中。
  • 还有一个单独的bin用于存储之前释放的大于上述特定大小的不同大小的堆块。内存管理器遍历这个列表,并尝试找到一个可以分割以适应新对象的部分。
  • 内存管理器搜索大于要分配的对象的特定大小bin的列表,并尝试分割其中的一个。
  • 最后,如果以上所有方法都无效,内存管理器会预留更多地址空间,并使用缓冲区分配器分配对象。

大型对象由自己的分配器处理。对于这些对象,从操作系统获得一个或多个单独的内存页,并单独管理。

此外,内存管理器从操作系统获得的每个新的地址空间块都包含一个包含每个槽一系列标志的头

  • 对象:对象占据的第一个槽以这个位标记。
  • extends:用这个位标记对象上进一步占据的任何槽位。
  • mark:当垃圾回收器运行时,如果对象仍在使用中,则设置此位。

内部类

为了最小化存储对象持有的成员所需元数据的存储量,JavaScript 引擎为每个对象分配一个“内部类”。其他 JavaScript 引擎称此为“隐藏类”或“形状”。内部类是去重的,并保存在一个树中。如果向对象添加一个属性,将检查当前内部类的子类以查看是否之前已经出现了相同的对象布局。如果是这样,我们就可以立即使用生成的内部类。否则,我们必须创建一个新的。

内部类存储在 JavaScript 堆的单独部分中,这部分的其他部分与上面的通用对象分配方式相同。这是因为内部类必须在使用它们的对象被收集时保持存活。然后,在单独的遍历中收集内部类。

尽管实际上存储在内部类中的属性属性不在 JavaScript 堆上保留,而是使用 new 和 delete 进行管理。

垃圾回收

JavaScript 引擎使用的垃圾回收器是一个非移动的、停止世界的 Mark 和 Sweep 设计。在 mark 阶段,我们遍历所有已知的地方,其中可以找到对象的活引用。特别是

  • JavaScript 全局变量
  • QML 和 JavaScript 编译单元不可删除的部分
  • JavaScript 栈
  • 持久性值存储。这是 QJSValue 和类似类保持对 JavaScript 对象引用的地方。

对于在那些地方找到的任何对象,都会递归地设置它引用的任何内容的标记位。

sweep 阶段,垃圾回收器随后遍历整个堆,释放之前未标记的任何对象。结果释放的内存被分类到要用于进一步分配的存储桶中。如果一个地址空间块完全为空,它将被解除提交,但是地址空间被保留(参见上面的 基本原理)。如果内存使用再次增长,则再次使用相同的地址空间。

垃圾回收器可以通过调用 gc() 函数手动触发,或者通过考虑以下方面的启发式方法触发

  • JavaScript 堆上由对象管理的内存数量,但不是直接分配在 JavaScript 堆上的,例如字符串和内部类成员数据。维护了一个动态的阈值。如果超出,则运行垃圾回收器,并将阈值增加。如果管理的其他外部内存远远低于阈值,则降低阈值。
  • 保留的总地址空间。只有在至少保留了一些地址空间之后,才会考虑在 JavaScript 堆上的内部内存分配。
  • 自从最后一次垃圾回收器运行以来的额外地址空间保留。如果地址空间的数量在最后一位垃圾回收器运行后超过了使用内存的两倍,则再次运行垃圾回收器。

分析内存使用

为了监视地址空间和其中分配的对象数的开发,最好使用专门的工具。QML Profiler 提供了有助于此的可视化。更通用的工具可能看不到在它保管的地址空间内 JavaScript 内存管理器所做的操作,甚至可能没有注意到地址空间的一部分没有被提交到物理内存。

调试内存使用的一种方法是使用日志分类 qt.qml.gc.statisticsqt.qml.gc.allocatorStats。如果启用 Debug 级别,垃圾回收器在每次运行时都会打印一些信息。

  • 保留了多少总地址空间
  • 垃圾回收前后内存使用量如何
  • 迄今为止分配了多少个各种大小的对象

Debug 级别的 qt.qml.gc.allocatorStats 打印更详细的统计信息,包括垃圾回收器何时被触发、标记和清除阶段的计时以及按字节和地址空间块分拆的内存使用情况的详细分析。

© 2024 Qt 公司有限公司。本文件所包含的文档贡献属于其各自的拥有者。本文件所提供的文档是根据自由软件基金会发布的 GNU 自由文档许可证版本 1.3 的条款许可的。Qt 和相应的徽标是芬兰的 Qt 公司及其全球子公司和附属公司的商标。所有其他商标均为其各自所有者的财产。