Day26 X Memory Management In JavaScript

https://ithelp.ithome.com.tw/upload/images/20211011/20113277L199xab77Z.png

如果你是写 C/C++ 的开发者,应该对记忆体管理并不陌生,如果你是後端开发者,应该会常常注意服务器有没有发生 Memory Leak 与 Memory 使用量的状况。然而在前端开发中,因为浏览器可以迅速启动与关闭的特性,再加上 JavaScript 的 Garbage Collection 垃圾回收机制,常常让前端开发者忽略了 JavaScript 的记忆体管理机制与 Memory Leak 带来的危险性,有时应用的效能瓶颈可能就因此产生了。

今天想与各位读者分享 JavaScript 的记忆体管理机制,知道不同的资料结构在 JS 中是如何储存的。接着会看看 JavaScript 的 Garbage Collection 机制与它的限制,希望在经过今天的内容後,我们除了能够知道 JavaScript 的记忆体管理机制以外,也能尽量避免写出会造成记忆体用量大增甚至造成 Memory Leak 的程序码,进而避免网站的效能产生瓶颈。

(今天的内容会以 Chrome 与 Node.js 使用的 JavaScript 引擎 V8 为例,不同的 JavaScript Engine 可能机制上会有些许不同。)

本篇文章的 Medium 好读版

在 JavaScript 中,资料是如何储存的?

记忆体的生命周期

首先要先来谈谈记忆体的生命周期,这个观念无论使用的是哪种程序语言概念都是差不多的。

  1. 分配程序需要用到的记忆体空间
  2. 使用分配到的记忆体空间(读写操作)
  3. 当不会再使用时要释放被配置的记忆体空间

Stack & Heap

JS 引擎又会将记忆体分为两个区块

  • 程序码空间
  • Stack & Heap (数据空间)

我们知道 JavaScript 主要有 7 种资料型态:

  • string
  • number
  • boolean
  • null
  • undefined
  • symbol
  • object

这些数据资料会储存在 Stack & Heap 之中,而程序码空间则会储存一些非数据的资料,举例来说

const it_home = 'ironman';

“ironman” 这个 string 会被储存到 Heap & Stack 的记忆体里,而变数 it_home 则会被存放到程序码空间的记忆体里。

而今天主要要介绍的是「Heap & Stack」的部分。

数据空间又可以分为 stack 记忆体与 heap 记忆体,要注意这里的 stack 与 heap 并不是指资料结构的 stack & heap,而是指记忆体的空间。熟悉 JS 的读者应该知道数据又分成两种类型,Primitive Type 与 Reference Type,比较简单类型的 Primitive Type 会被放在 stack 里,比较复杂类型的 Reference Type 则会把资料存在 heap 中,再把资料在 heap 的记忆体位址记录到 stack 里,为了快速理解,我们直接看一段 code。

function ironman(){
    let one = "铁人赛";
    let two = one;
    let three = { author: "Kyle Mo"};
    let four = three;
}
ironman();

当执行一段 JavaScript 的程序码时需要先经过编译,并创建所谓的执行环境(Execution Context), 接着再按照顺序执行程序码。

当 ironman function 执行完最後一行即将离开 function 时,记忆体的状况会是这样

可以发现 Object 类型的数据实际上是存在 Heap 里,Stack 中存的只是物件在 Heap 中的记忆体位置而已,而变数 four = three 这段 code 实际上是把 Three 指向的物件在 Heap 中的记忆体位置指派给 Four 变数,所以它们实际上指向的是同一个物件,这也是身为 JS 开发者应该十分熟悉的一个特性。

为什麽不把所有数据存到 Stack 里就好?

原因是 JS Engine 是透过 stack 来维护 Execution Context 的切换状态,如果 Stack 太过肥大,会影响 Context Switch 的执行效率,连带影响到整个程序执行的效率。以上面的例子来说,当 ironman 这个 function 执行完毕後,JS Engine 会执行环境切换,将指针移到下一层的 Execution Context,也就是 Global Execution Context,然後回收 ironman function 的 执行环境与 stack memory。

/* 更新 */
社群上有大大指出字串型别与一些数字在 compile 的时候没有办法知道确切大小为何,所以应该不会是存在 stack 中。

关於这点我查询了一些文章,发现这似乎不是一个很单纯是或否的二选一问题,实际上可能得考虑 compiler 的实作方式,例如这篇文章所提及的。

又例如这篇文章它的续集透过观察 Bytecode 而得出一些字串与数字会有「constant pool」的概念,可以共用同一个记忆体位置。

所以目前结论是 JS 在 V8 引擎中:

  • string: 存在 Heap 里,且 V8 会 maintain 一个字串的 hashmap,如果是相同字串,就会引用相同的记忆体位置。
  • number: 某些类型例如 smallint 会存在 Stack 中,其他类型则存在 Heap 里。

详细内容可以参考这篇文章

虽然我在某篇 Stack Overflow 的讨论串中看到一句话「 For the JS programmer, worrying about stacks and heaps is somewhere between meaningless and distracting. It’s more important to understand the behavior of various types of values.」

但为了避免传递错误观念给读者,未来如果有新的结论,会再更新在文章中? 最後感谢社群大大的指正!

JavaScript 的垃圾回收机制

当数据不会再被程序使用时,就会变成所谓的垃圾数据(好像在骂人?),而记忆体的空间是有限的,所以理想上必须针对这些垃圾数据进行回收,挪出记忆体空间以供未来储存数据使用。

如果曾经写过 C/C++ 的读者应该写过一些需要自己管理记忆体的分配与回收的程序码,例如

char* ironman =  (char*)malloc(1024);
free(ironman);
ironman = NULL;

不过 JavaScript 这门程序语言有一个叫做 「Garbage Collector」的系统,Garbage Collector (简称 GC)的工作是「追踪记忆体分配的使用情况,以便自动释放一些不再使用的记忆体空间」。这个 GC 的机制方便归方便,却让许多 JavaScript 开发者产生「写 JS 时可以不须理会记忆体管理」的错误认知。

根据 MDN 文件,有 GC 机制的存在仍然不能不管记忆体管理的原因在於 GC 只是「尽量」做到自动释放记忆体空间,因为判断记忆体空间是否要继续使用,这件事是「不可判定(undecidable)」的,也就是不能单纯透过演算法来解决。

所以我认为了解 GC 基本的运作方式是很重要的,有了基本的观念才能避免 memory leak 的发生,让应用的效能不会因为记忆体空间不足而出现瓶颈甚至崩溃。

GC 的工作流程

首先需要先厘清一下,刚刚有提到在执行执行环境被回收时,该执行环境的 Stack 空间也会被回收(Stack 空间由 OS 管理,背後的实作机制我们先不讨论),那各位读者可能会发现一个问题,如果是物件的话,Stack 中存的是 Heap 空间的 address,所以就算 Stack 被回收,存在 Heap 空间的数据依然存在,这时就需要靠 GC 来判断 Heap 空间中哪些数据是用不到且需要被回收的,接下来就一起来看看 Chrome 的 V8 引擎的垃圾回收机制是如何运作的。

其实 Garbage Collection 的演算法有非常多种,但目前还没有出现所谓完美的 GC 演算法,依据不同的执行环境、语言,只能尽量找出「最适合」的 GC 演算法,以尽量达到最好的回收效果。

在 V8 引擎中,heap 又被分为两个区域 — New SpaceOld Space

New Space 中存放的是存活时间较短的物件,这里垃圾回收的速度会比较快,不过空间却比较小,大概只有 1–8 MB 左右,存在 New Space 中的物件也被称作 Young Generation。Old Space 中存放的是存活时间较久的物件,这些物件是在 New Space 中经过几次 GC Cycle 并成功存活後才被移到 Old Space,在 Old Space 做垃圾回收的效率比较差,因此它执行 GC 的频率相较於 New Space 会比较低,而在 Old Space 中的物件也被称作 Old Generation

在 V8 中,分别对 Young Generation 与 Old Generation 实作了不同的 GC 演算法

  • Young Generation: Scavenge collection
  • Old Generation: Mark-Sweep collection

Scavenge 演算法

Scavenge 演算法将 Young Generation 再分为「物件区域」与「空闲区域」。

新存入记忆体的物件会被放到物件区域,当物件区域快要 overflow 时,就得执行一次 GC。要做 GC 时,得先标记出哪些物件是应该要被回收的垃圾,标记出垃圾後才会正式进入记忆体清理阶段,Garbage Collector 会把「仍然存活的物件」Copy 到空闲区域中并且排序。如果有使用过电脑的「磁碟重组」功能,应该知道它的原理是把一些不再使用的空间清除,并将碎片化的空间连接在一起。上面 GC 这段 Copy & Sort 的操作其实就跟磁碟重组类似是一种整理记忆体空间的行为。

Copy & Sort 後垃圾回收器会再将物件区域与空闲区域的角色翻转,这样就顺利完成了 Young Generation 垃圾回收的操作,并且这样清除与翻转角色的机制是可以一直重复执行下去的。

从 Scavenge 演算法我们可以得知两件事:

  • 每次执行 Young Generation 的垃圾回收都需要执行 Copy & Sort 这些相当耗时的操作,因此为了效能,通常 Young Generation 的空间会分配的比较小,这是我们在先前就提过的。

  • 接续上面那点,因为 Young Generation 被分配的空间比较小,物件区域很容易被占满并必须执行垃圾回收,这对效能可能是个影响,因此 JS 通常会将经历两次 GC 仍然存活的物件移动到 Old Generation。

Mark-Sweep 演算法

在 Old Generation,主要会有两种物件

  • 从 Young Generation 转移过来的物件
  • 占用记忆体空间较大的物件有机会直接被送到 Old Generation

所…所以呢?

因为 Old Generation 物件占用记忆体的空间通常较大,执行 Scavenge 演算法的 Copy & Paste 是很没有效率的,同时还得切分出一半的空间用来转换作为,对於本身记忆体空间比较大的 Old Generation 来说浪费了更多的空间,种种原因影响之下,在 V8 引擎的 Old Generation 中通常会采用另外一种演算法 — 「Mark-Sweep」来进行垃圾回收。

Mark-Sweep GC 演算法分为「标记」与「清除」两个步骤,标记就是纸从根元素开始递回的寻访这组根元素,在这个过程中,能够被造访的元素就是仍然需要存活的物件,而没有被造访的元素则被判定为垃圾数据,应该要被 GC 给清除。

在标记(Mark)完成後下一个阶段就是把标记为垃圾的物件给清除(Sweep)。

Mark-Compact 演算法

上面的 Mark-Sweep 演算法有一个缺点,就是容易让记忆体产生不连续且碎片化的空间,碎片过多会导致需要较大空间的物件没办法被分配到足够的连续记忆体。为了解决这个问题,另外一种被称作 Mark-Compact 的演算法诞生了。这个演算法在 Mark 阶段与 Mark-Sweep 基本上一致,然而在清理过程会将存活的物件往记忆体的其中一端移动,整理出足够的连续记忆体空间。

Garbage Collection 可以 Stop The World !?

在前端的世界里,Garbage Collection 也是由浏览器的 Main Thread 来负责的,不过 JavaScript 会受到 Single Thread 的限制,这意味着在做垃圾回收时 Main Thread 是不能够做其他事的,必须等到回收任务完毕才能继续执行 Script,这个特性也被称作 「Stop The World」。

这看起来不是那麽理想,因为在 Old Generation 的 GC 是比较缓慢的,万一 GC 需要耗时几百毫秒,也会对页面效能造成重大的影响。

V8 实作了一种叫做 Incremental Marking 的演算法,透过交替执行 GC 与 Script 的方式来解决使用者感觉到页面卡顿的问题。

Prevent Memory Leak In JavaScript

Memory Leak 可以说是工程师的公敌,不管前端後端甚至系统工程师在开发时都会尽量避免 Memory Leak 的发生。

先来看看 Memory Leak 的定义

记忆体流失是在电脑科学中,由於疏忽或错误造成程序未能释放已经不再使用的记忆体,从而造成了记忆体的浪费。严重的话会可能会导致程序效能变慢甚至 crash。

像是 C/C++ 这类的语言需要开发者自己手动管理记忆体的释放,而 Java 或 JavaScript 这类有垃圾回收机制的语言则不用手动释放记忆体,但是不要以为这样就安全了,在开发时有些写法会造成垃圾回收机制没办法正确判断记忆体已经不再被使用了,而无法被自动会收,造成所谓的 Memory Leak。

在 JavaScript 中,遵守某些 Best Practices 或是避免一些写法可以尽量避免 Memory Leak 的发生。

Event Listener

在前端开发中,Event Listener 是很常见的功能,前端开发者要特别注意事件监听器是不是会重复产生新的监听器还有当监听器用不到的时候是不是有正确移除。

熟悉 React 的读者一定看过这个写法

就是为了确保事件监听器在不需要时可以被正确移除。

不当存取全域变数

例如说以下这段简单的 Express Web Server 的程序

const express = require('express');
const app = express();
// 全域变数
const requestStatusCollection = [];
app.get('/ironman', (req, res) => {
    requestStatusCollection.push(req.status);
    
    return res.send({});
})
app.listen(3000,() => {
    console.log('server listening on port 3000...');
})

requestStatusCollection 是一个全域的阵列变数,虽然每个 endpoint 被呼叫後就会离开 handler,但全域变数却不会被移除,当成长到一定的量时服务器很可能会因为记忆体使用量过多却没有正确释放而影响效能。

当然也不是说都不能用全域变数,例如有些 Cache 就会使用 memory 的 data structure 实作,不过通常会搭配例如 LRU Cache 的资料结构来控制记忆体的用量。

另外因为 hoisting 的特性,JavaScript 有些写法也会产生不预期的全域变数

// 假设以下 function 都是 global 的
// author 会被 hoist 成一个全域变数
function ironman() {
    author = "Kyle Mo";
}
// 这种写法在 non strict mode 下也会变成全域变数
function hello_it_home() {
    this.author = "Kyle Mo";
}
// 这种情况下就算是 strict mode author 也会变成全域变数
const hello_ironman = () => {
    this.author = "Kyle Mo";
}

Out of DOM references

在不使用前端框架的状况下,有时候可能会把 DOM Node 存在像物件这样的资料结构中

const elementsMap = {
    button: document.getElementById('btn'),
    image: document.getElementById('img'),
};

有时候会有 remove DOM element 的需求

function removeButton() {
    document.body.removeChild(document.getElementById('btn'));
}

你可能会觉得在 removeChild 後这个 DOM Element 所占用的记忆体空间已经被清除了,然而实际上因为 elementsMap 这个物件还存在对 btn 这个 element 的 reference,所以 GC 并不会清除它的记忆体空间。

Old Browser & Defective Browser Extension

一些比较旧的浏览器例如 IE 的 GC 演算法比较不精确,没办法解决像是 circular reference 等问题,因此比较容易造成 Memory Leak。此外一些有缺陷的浏览器扩充套件也是造成 Memory Leak 的可能原因之一。

Debug Memory Usage

身为前端开发者,应该要学会好好利用浏览器 Devtool 提供的种种功能,以 Chrome 来说就有提供 memory tab 让开发者可以观测应用的记忆体使用量,碍於篇幅就不多做介绍,建议各位读者可以去玩玩,也推荐阅读这篇文章,看看实际在专案开发上是如何找出潜在 Memory Leak 的问题并尝试解决。

不同的写法,也许记忆体使用量会差很多

这里有一个用 React 撰写的简易 Demo

首先在 global 建立一个拥有一千万个 items 的阵列,并分别实作两个按钮:cheap Loop 与 expensive loop。cheap loop 是利用回圈更改 array item 的属性值,expensive loop 则是每一次回圈都重新指派一个新的物件给 array 中的 item,实际上最终这两种方式跑出来的 array 应该要是长一样的,但这两种方式的效能却有极大的不同。

可以看到在点击 cheap loop 的时候页面基本上是平顺的,但点击 expensive loop 後页面很明显直接卡顿住了。

当然这跟 Single Thread 的特性有关,expensiveLoop 相较於 cheapLoop 也是一个较耗费 CPU 的操作,这点可以从 performance tab 观察出来

可以看到 CPU 在跑 expensiveLoop 後是爆量升高的,再来看看 heap 记忆体空间的 snapshot 对比

snapshot1 是点击任何按钮前的 heap snapshot 状况
snapshot2 则是点了 cheapLoop 按钮後的 snapshot 状况
snapshot3 则是点了 expensiveLoop 按钮後的 snapshot 状况

可以发现点了 expensiveLoop 後的 heap 记忆体用量变成了原本状况的将近 5 倍左右!虽然後续有机会被 GC 回收,但因为 GC 是自己运作的,开发者没有控制它的权力,因此我们也不能保证未来记忆体会被顺利回收。

可见在开发时除了注意能不能完成需求以外,也要留意是不是一个好的写法或是有没有更好的解决方案,不管是 CPU 的消耗还是记忆体的使用量,如果能尽量避免就该避免!

Demo Source Code

(其实 immutable 的写法在 JS 中很常见,Immutable 的写法的确是在记忆体新增一个物件,理论上会比较没那麽有效率,但一般使用情景应该都没什麽问题,变垃圾的物件自然会被 GC 清掉,上述范例是因为一次爆量(一千万次回圈)新增物件导致记忆体用量暴增,理论上未来 GC 也会做清理,但在JS 中开发者对 GC 没有控制权,那个 snapshot 是马上做完操作时纪录的,所以会显示记忆体爆量成长,平常开发正常使用 immutable 的写法倒是不用太担心喔!)

本日小结

了解记忆体管理的机制严格来说不是一种效能优化的技巧,而是一种「避免效能出现瓶颈」的一个重要观念,今天的内容不深,却是我认为前端开发者或是 JS 开发者一定要了解的记忆体管理机制,希望各位有所收获!

References 与图片来源

https://linyencheng.github.io/2019/10/01/js-memory-leak/
https://blog.poetries.top/browser-working-principle/guide/part3/lesson13.html#%E5%89%AF%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8
https://blog.risingstack.com/node-js-at-scale-node-js-garbage-collection/


<<:  [Day27]程序菜鸟自学C++资料结构演算法 – 堆积排序法(Heap sort)

>>:  DAY 26 首页文章

[DAY 18]让BOT 24小时在线(GCP版本)

前几天有写一篇用replit让bot24小时在线的文章 但测试几天後发现bot执行的速度明显变慢个2...

[Python] 关键字yield和return究竟有什麽不同?

学习Scrapy的过程中碰到 yeild 这个关键字,我使用Python快半年了,还真的是第一次遇到...

[Day-26] R语言 - 分群应用(五) 分群预测 - 资料分群 ( data clustering in R.Studio )

您的订阅是我制作影片的动力 订阅点这里~ 影片程序码(延续昨天) #步骤二: 资料分群,哪个演算法?...

AI - 海关图图片侦测判别

1.建立资料库 ----- 建立资料库 CREATE DATABASE [PicTest] ON P...

css margin

今天来说如何设定区块间的距离,需要用到margin这个语法 先创造出两个黑色方块与一个粉色方块来观察...