要执行JavaScript,浏览器需要一点内存,但是您需要在某个地方存储为所有用户操作创建的对象,原语和函数。 因此,浏览器首先分配所需的RAM数量,并且当不使用对象时,它将独立清理它。
从理论上讲,这听起来不错。 实际上,用户会从YouTube,社交网络中打开20个标签页,阅读某些内容,然后工作,浏览器会吞噬内存,例如悍马H2-汽油。 垃圾收集器就像这个带有拖把的怪物一样,遍历整个内存并增加了混乱,一切都变慢了并崩溃了。

为了防止这种情况发生,并且我们的站点和应用程序的性能不受影响,前端开发人员应该知道垃圾如何影响应用程序,浏览器如何收集垃圾并优化内存工作,以及它们与苛刻的现实有何不同。 这只是
Andrei Roenko( 弗拉金企鹅 )在
Frontend Conf 2018上的报道。
我们每天都使用垃圾收集器(而不是在家中-在前端开发中),但是我们并没有真正考虑它到底是什么,它花了我们多少钱以及它有什么机会和局限性。
如果垃圾回收确实可以在JavaScript中运行,则大多数npm模块在安装后会立即删除自己。
但是,事实并非如此,我们将讨论什么-关于组装不必要的对象。
演讲者简介 :
Andrei Roenko已经开发
了Yandex.Map API ,
该API进入前端已经有六年了,他喜欢创建自己的高级抽象并从陌生人那里跌落到地面。
为什么需要垃圾收集?
考虑Yandex.Maps的示例。 Yandex.Maps是一项庞大而复杂的服务,它使用大量JS和几乎所有现有的浏览器API(多媒体应用除外),平均会话时间为5-10分钟。 丰富的JavaScript创建了许多对象。 拖动地图,添加组织,搜索结果以及每秒发生的许多其他事件,会产生大量的对象。 加上这个React,对象变得更多。
但是,JS对象在地图上仅占30–40 Mb。 对于长时间的Yandex.Maps会话和新对象的持续分配,这还不够。
对象数量少的原因是垃圾回收器已成功收集了这些对象,并重新使用了内存。
今天我们将从四个方面讨论垃圾收集:
- 理论 让我们从她开始讲相同的语言并互相理解。
- 严酷的现实。 最终,计算机执行的机器代码并不是我们所熟悉的所有抽象。 让我们尝试弄清垃圾收集是如何在低级进行的。
- 浏览器现实。 让我们看看如何在现代引擎和浏览器中实现垃圾回收,以及我们可以从中得出什么结论。
- 日常生活 -让我们谈谈在日常生活中获得的知识的实际应用。
所有语句均以如何以及如何做的示例进行备份。
为什么知道这一切?
垃圾收集对我们来说是无形的事情,但是,知道如何安排垃圾收集,您将:
- 了解您正在使用的工具,这对您的工作很有用。
- 了解在何处优化已发布的应用程序以及如何设计将来的应用程序,以使其更好,更快地工作。
- 知道如何不犯常见错误,并停止浪费资源进行无用和有害的“优化”。
理论
乔尔·斯波斯基曾经说过:
所有非平凡的抽象都是泄漏的。
垃圾收集器是从各个方面进行修补的一个重要的非平凡抽象。 幸运的是,它很少流动。
让我们从一个理论开始,但不要无聊的定义。 让我们以简单的代码为例来分析收集器的工作:
window.Foo = class Foo { constructor() { this.x = { y: 'y' }; } work(name) { let z = 'z'; return function () { console.log(name, this.xy, z); this.x = null; }.bind(this); } };
- 代码中有一个类 。
- 该类具有构造函数 。
- 工作方法返回一个相关的函数。
- 在函数内部,使用了此函数以及闭包中的几个变量。
让我们看看如果以这种方式运行它,该代码将如何运行:
var foo = new Foo();
让我们更详细地分析代码及其组件,并从类开始。
类声明

我们可以假设ECMAScript 2015中的类只是函数的语法糖。 所有功能都有:
- 函数[[Prototype]]是函数的真实原型。
- Foo.prototype是新创建对象的原型。
- Foo.prototype通过构造函数字段具有指向构造函数的链接。 这是一个对象,因此它继承自Object.prototype 。
- work方法是一个单独的函数,与构造函数类似,它具有一个链接,因为它们都是函数。 他还可以设置原型并通过新方法调用它,但是很少有人使用此行为。
原型会在电路上占用很多空间,因此请记住它们是存在的,但是为了简单起见会删除它们。
创建一个类对象

- 我们将类放在窗口中,因为默认情况下类不会到达那里。
- 创建一个类对象。
- 创建对象会自动在Foo.prototype中公开类对象的原型。 因此,当您尝试在对象上调用work方法时,它将知道它是什么样的工作。
- 我们的构造函数从对象中使用字符串在对象中创建字段x 。
这是发生了什么:

该方法返回一个绑定函数-这是JS中的一个特殊“魔术”对象,它由一个绑定this和一个必须调用的函数组成。 相关函数也有一个原型和另一个原型,但是我们对闭包感兴趣。 根据规范,闭包存储在环境中。 您很可能更熟悉Scope一词,但
在规范中该字段称为Environment 。

环境存储对LexicalEnvironment的引用。 这是一个复杂的对象,比幻灯片上的对象还要复杂;它存储了指向可以从函数访问的所有内容的链接。 例如,window,Foo,name和z。 它还会存储指向您未明确使用的链接。 例如,您可以使用eval并意外使用未使用的对象,但是JS应该不会损坏。
因此,我们构建了所有对象,现在我们将销毁所有对象。
删除指向对象的链接
让我们从删除对象的链接开始,图中的链接以红色突出显示。

我们删除并什么也没有发生,因为从
窗口到对象都有通过
绑定函数的路径。

这将我们推向一个典型的错误。
常见错误-忘记订阅
externalElement.addEventListener('click', () => { if (this.shouldDoSomethingOnClick) { this.doSomething(); } })
订阅时发生:使用
此功能,通过绑定或箭头功能显式地使用; 在闭包中使用一些东西。 然后您忘记取消订阅,对象的寿命或电路中的内容与订阅的寿命相同。 例如,如果这是您没有接触过的DOM元素,那么很可能是直到页面寿命结束的时间。
解决这些问题:
- 取消订阅。
- 考虑订阅的生命周期以及拥有者。
- 如果由于某种原因您无法退订,则使链接为空(无论为空),或清除对象的所有字段。 如果您的对象泄漏,它会很小,这并不可惜。
- 使用WeakMap,也许在某些情况下会有所帮助。
删除班级参考
继续尝试删除该类突出显示的红色链接。

我们删除了链接,没有任何改变。 原因是可以通过BoundThis访问该类,其中有指向原型的链接,而在原型中有指向构造函数的链接。
典型的错误无用的工作
为什么需要所有这些示范? 因为当人们接受建议来从字面上使链接无效并通常使所有内容都无效时,该问题存在另一面。
destroy() { this._x = null; this._y = null;
这是一项毫无价值的工作。 如果对象仅包含对其他对象的引用,并且那里没有资源,则不需要destroy()。 失去对对象的引用就足够了,他将自己死。
没有普遍的建议。 必要时,请作废,否则请不要作废。 调零不是错误,而只是无用的工作。
来吧 调用绑定函数方法,它将删除从[object Foo]到[object Object]的链接。 这将导致以下事实:位于蓝色矩形中的对象将出现在图中。

这些对象是JS垃圾。 他过得很好。 但是,有无法收集的垃圾。
垃圾不会
在许多浏览器API中,您可以创建和销毁对象。 如果未破坏该对象,则没有收集器可以组装该对象。
具有创建/删除对功能的对象:
- createObjectURL(),revokeObjectURL();
- WebGL:创建/删除程序/着色器/缓冲区/纹理/等;
- ImageBitmap.close();
- indexDb.close()。
例如,如果您忘记从200 MB的视频中删除ObjectURL,那么这200 MB的内容将保留在内存中,直到页面寿命结束甚至更长,因为这些标签之间存在数据交换。 类似地,在WebGL,indexDb和其他具有类似资源的浏览器API中。
幸运的是,在我们的示例中,蓝色矩形仅包含JavaScript对象,因此这只是可以删除的垃圾。
下一步是从左到右清除最后一个链接。 这是对我们收到的方法的参考,一个相关的功能。

删除后,我们将没有左右链接吗? 实际上,关闭仍然存在链接。

重要的是,没有从左到右的链接,因此,除了窗口之外的所有东西都是垃圾,它会死掉。
重要说明 :垃圾中有循环引用,即互相引用的对象。 此类链接的存在不会影响任何内容,因为垃圾收集器不会收集单个对象,而是会收集整个垃圾。

我们看了看这些示例,现在在直觉上了解了什么是垃圾,但是让我们对这个概念进行完整的定义。
垃圾就是一切都不是活物。
一切都变得很清楚。 但是什么是活物?
活动对象是可以通过根对象的链接到达的对象。出现两个新概念:“跟随链接”和“根对象”。 我们已经知道的一个根对象是window,所以让我们从链接开始。
跟随链接意味着什么?
有许多相互关联并相互引用的对象。 我们将从根对象开始沿着它们摆动。
我们初始化第一步,然后根据以下算法进行操作:假设波峰上的所有东西都是活物体,然后看看它们指的是什么。

我们初始化第一步。 然后,我们将根据以下算法进行操作:假设波峰上所有黄色的物体都是活物体,让我们看看它们指的是什么。
他们所指的是,我们将迎来新的浪潮:

完成并重新开始:
- 我们正在复兴。
- 我们看看他们指的是什么。
- 创建一个新的波峰,为对象设置动画。
- 我们看看他们指的是什么。

注意到一个箭头指向一个已经存在的物体,我们什么也不做。 进一步根据该算法,直到要走动的对象用完为止。 然后我们说我们找到了所有有生命的物体,其他所有东西都是垃圾。

此过程称为
标记 。
根对象是什么意思?
- 窗
- 几乎所有的浏览器API。
- 所有的承诺。
- Microtask和Macrotask中的所有内容。
- 变异观察者,RAF,空闲回调。 无法删除从RAF中获得的所有内容,因为如果删除RAF中使用的对象,则可能会出错。
组装可以随时发生。 每次出现括号或功能时,都会创建一个新对象。 可能没有足够的内存,收集器将免费寻找:
function foo (a, b, c) { function bar (x, y, z) { const x = {};
在这种情况下,根对象将是调用堆栈中的所有对象。 例如,如果您在X处停下来并删除Y所指的内容,则您的应用程序将崩溃。 JS不允许我们这样轻巧,因此您不能从Y中删除对象。
如果上一部分看起来很复杂,那么它将更加困难。
严酷的现实
让我们谈谈用物理媒体处理铁的机器世界。
内存是一个仅包含数字的大型数组,例如:new Uint32Array(16 * 2 ** 30)。
让我们在内存中创建对象,然后从左到右添加它们。 我们创建一个,第二个,第三个-它们都是不同的大小。 我们一路走来。

在第七个对象处,该位置已结束,因为我们有2个自由正方形,但我们需要5个。
在这里可以做什么? 第一种选择是崩溃。 在2018年的院子里,每个人都拥有最新的MacBook和16 GB的RAM。 没有记忆就没有任何情况!
但是,让事情顺其自然是一个坏主意,因为在网络上这会导致类似的屏幕:

这不是我们希望从程序中获得的行为,但总的来说这是有效的。 有一个收集器类别,称为
No-op 。
无操作收集器
优点:
- 收集器非常简单。
- 根本没有垃圾收集。
- 无需编写或考虑内存。
缺点:
对于前端,无操作收集器无关紧要,但在后端使用。 例如,在平衡器后面有几台服务器,该应用程序将获得32 GB的RAM,然后将其完全杀死。 它更简单,只有在内存不足时重新启动,才能提高性能。
在网络上这是不可能的,您必须清理它。
搜索和删除垃圾
我们开始用垃圾清理。 我们已经知道该怎么做。 垃圾-先前方案中的对象C和F,因为您不能从根对象沿箭头方向到达它们。
我们将这些垃圾拿走,喂给垃圾爱好者,您就完成了。

清洁后,由于内存中留有孔,因此无法解决问题。 请注意,有7个免费方块,但我们仍然无法分配其中5个。 发生碎片,组装结束。 这种带有空洞的算法称为“
标记和扫描” 。
扫一扫
优点:
- 一个非常简单的算法。 如果您开始学习垃圾收集器,将是第一个了解的对象。
- 它的工作与垃圾的数量成比例,但只有在垃圾很少时才能应付。
- 如果您只有活物,那么他不会浪费时间,什么也不做。
缺点:
- 它需要复杂的逻辑来搜索可用空间,因为当内存中有很多空洞时,您必须尝试每个对象中的一个对象以了解其是否适合。
- 碎片化内存。 可能会发生这样的情况:使用200 MB的可用内存将内存分成小块,并且如上面的示例所示,对象没有固态的内存。
我们正在寻找其他想法。 如果您查看图片并思考,首先想到的就是将所有内容向左移动。 然后在右侧将有一个大而自由的零件,我们的物体将平稳地放入其中。
有这样一种算法,它称为
Mark and Compact 。
标记紧凑
优点:
- 碎片整理内存。
- 它与活动物体的数量成比例,这意味着几乎没有碎片时可以使用它。
缺点:
- 工作和实施困难。
- 移动对象。 我们移动了对象,将其复制,现在它在另一个位置,整个操作非常昂贵。
- 根据实现的不同,整个内存需要2-3次通过-算法很慢。
在这里,我们有另一个想法。
垃圾收集不是免费的
在仍在开发中的高性能API(如WebGL,WebAudio和WebGPU)中,对象的创建和删除是在不同的阶段进行的。 编写这些规范是为了避免垃圾收集处于过程中。 而且,那里甚至没有Promise,而是pull()-您只问每个帧:“是否发生了什么?”。
半空间又名Lisp 2
我想谈谈另一个收藏家。 如果您不释放内存,而是将所有活动对象复制到另一个地方,该怎么办。
让我们尝试复制根对象“ as is”,它指向某个地方。

然后其他所有人。

上方的内存中没有碎片或孔。 一切似乎都很好,但是出现了两个问题:
- 复制对象-我们有两个绿色对象和两个蓝色对象。 使用哪一个?
- 来自新对象的链接指向旧对象,而不是彼此。
通过链接,一切都可以通过特殊的算法“魔术”解决,并且我们可以通过删除下面的所有内容来应对对象的重复。

结果,我们有自由空间,并且只有上面正常顺序的生物。 该算法称为
Semispace ,
Lisp 2或简称为“副本收集器”。
优点:
- 碎片整理内存。
- 很简单
- 可以与旁路阶段结合使用。
- 它随着时间的流逝与生物的数量成比例。
- 有大量垃圾时效果很好。 如果您有2 GB的内存和3个对象,那么您将仅绕过3个对象,而剩余的2 GB似乎已消失。
缺点:
- 内存消耗翻倍。 您使用的内存是必需的两倍。
- 移动对象也不是很便宜的操作。
注意:垃圾收集器可以移动对象。
在网络上,这是无关紧要的,但是在Node.js上甚至很多。 如果使用C ++编写扩展名,那么该语言将不了解所有这些信息,因此这里有称为handle的双链接,看起来像这样:v8 :: Local <v8 :: String>。
因此,如果您要为Node.js编写插件,那么该信息将派上用场。
我们在表中总结了不同的算法及其优缺点。 它也有一个Eden算法,但稍后会介绍。

我真的想要一个没有缺点的算法,但这不是。 因此,我们善加利用所有优势:我们同时使用多种算法。 在一块内存中,我们用一种算法收集垃圾,而在另一种算法中收集垃圾。
在这种情况下如何理解算法的有效性?
我们可以利用60年代聪明的丈夫的知识来研究所有程序并意识到:
世代假说薄弱:大多数物体都死于年轻。
这些他们想说的是,所有程序都只会产生垃圾。 为了尝试使用知识,我们将介绍所谓的“代代相传”。
代组装
我们创建了两个没有任何连接的内存:左边是伊甸园,右边是慢速Mark and Sweep。 在伊甸园中,我们创建对象。 很多对象。

当伊甸园说已满时,我们开始在其中进行垃圾收集。 我们找到活动对象并将其复制到另一个收集器。

伊甸园本身已被完全清理,我们可以进一步向其中添加对象。

依靠世代的假设,我们决定对象c,g,i最有可能生存很长时间,并且我们可以减少检查它们的频率。 了解了这个假设,您可以编写欺骗收集器的程序。 可以这样做,但是我不建议您这样做,因为它几乎总是会导致不良后果。 如果创建了长期存在的垃圾,收集器将开始认为不需要收集它。
作弊的经典示例是LRU缓存。 一个对象在高速缓存中放置了很长时间,收集器查看了该对象,并认为它尚未收集,因为该对象将生存很长时间。 然后,一个新对象进入缓存,并且将一个大的旧对象推出缓存,不再可能立即组装该大对象。
现在我们知道如何收集。 谈谈何时收集。
什么时候收集?
最简单的选择是当我们
只是停止所有操作 ,开始构建,然后再次开始JS工作时。

在现代计算机中,有多个执行线程。 在Web上,Web Worker对此很熟悉。 为什么不采用
组装流程并使其并行化呢 ? 同时执行多个小操作将比执行一个大操作快。

另一个想法是仔细制作当前状态的快照,并
与JS并行构建 。

如果您对此感兴趣,那么我建议您阅读:
浏览器现实
让我们继续介绍浏览器如何使用我们所谈论的一切。
物联网引擎
让我们从浏览器而不是物联网引擎开始:JerryScript和Duktape。 他们使用Mark'n'sweep和Stop the world算法。
物联网引擎在微控制器上工作,这意味着:语言速度慢; 第二吊; 碎片化 和所有这一切都带照明的茶壶:)
如果您使用JavaScript编写物联网,请在评论中告诉我们? 有什么要说的吗
我们将不理会物联网引擎,我们对:
- V8。
- 蜘蛛猴 实际上,他没有徽标。 自制徽标:)
- WebKit使用的JavaScriptCore。
- Edge中使用的ChakraCore。

所有引擎都差不多,因此我们将以最著名的V8为例。
V8
- 几乎所有服务器端JavaScript,因为它是Node.js。
- 几乎80%的客户端JavaScript。
- 最善于交际的开发人员,有许多最容易阅读的信息和良好的源代码。
V8使用分代装配。

唯一的区别是我们曾经有两个收集器,现在有三个:
- 在伊甸园中创建一个对象。
- 在伊甸园的某个时候,有太多的垃圾,该对象被转移到Semispace。
- 该对象还很年轻,当收集器意识到它太旧且无聊时,会将其扔到Mark and Sweep中,在其中垃圾收集极为罕见。
您可以在
内存跟踪中清楚地看到它的外观。

几个大浪与小浪很明显。 小型程序集是小型程序集,大型程序集是大型程序集。
根据世代的假设,我们存在的意义是产生垃圾,所以下一个错误是害怕产生垃圾。
确实是垃圾时可以创建垃圾。 , , , .
mark
V8 .

Stop the world, , JS, .
?
1 3%, .
3% = 1/33 GameDev. GameDev 3% 1 , . GameDev .
const pool = [new Bullet(), new Bullet(), ]; function getFromPool() { const bullet = pool.find(x => !x.inUse); bullet.isUse = true; return bullet; } function returnToPool(bullet) { bullet.inUse = false; }
, , 10 000 .
— . , . , .
: Chromium
, , , Chromium.
> performance.memory MemoryInfo { totalJSHeapSize: 10000000, usedJSHeapSize: 10000000, jsHeapSizeLimit: 2330000000 }
Chromium
performance.memory , , Chromium .
: Chromium 2 JavaScript.
, .
: Node
Node.js
process.memoryUsage , .
> process.memoryUsage() { rss: 22839296, heapTotal: 10207232, heapUsed: 5967968, external: 12829 }
, - , . . .
— , .
proposal , .
Node.js, c
node-weak , , .
let cached = new WeakRef(myJson);
, , - JS. , , , .
WebAssembly , . , , , .
: v8.dev JS.
?
DevTools :
Performance Memory . Chromium, , Firefox Safari .
Performance
Trace, «Memory» Performance, JS .

JS V8 , . . , GC 30 1200 JS, 1/40.
Memory
.

.

, . , , , V8 , . , .
, , Q ( compiled code) — React . , ?
, , , .
, .

, , , . , — 4 . , .

React, - : . , JSX.
Performance Memory , :
- Chromium: about:tracing.
- Firefox: about:memory about:performance, .
- Node — trace-gc, —expose-gc, require('trace_events'). trace_events .
总结
- , , , .
- .
- . , ?
- , - .
- SPA, , 1 , .
- , - .
:
flapenguin.me ,
Twitter ,
GitHub .
- ++ . YouTube-
.
, 2018 , . Frontend Conf 2018.
, :)