0
点赞
收藏
分享

微信扫一扫

#yyds干货盘点#——JavaScript 内部原理:垃圾收集

垃圾收集 (GC) 对所有编程语言来说都是一个非常重要的过程,无论是手动完成(在 C 等低级语言中)还是自动完成。

奇怪的是,我们大多数人几乎都不会停下来思考 JavaScript——它是一种编程语言,因此需要 GC——是如何做到的。

与大多数高级语言一样,JavaScript 将其对象和值分配到内存中,并在不再需要时释放它们。

但是,怎么做?它在内部是如何工作的?

好吧,本文旨在解决语言的这一特殊方面。那我们走吧!


JavaScript 内存生命周期

首先,让我们澄清一下,本文的目标是 JavaScript 如何处理 Web 浏览器上的 GC。

几乎每种编程语言的内存生命周期如下:

#yyds干货盘点#——JavaScript 内部原理:垃圾收集_应用程序

语言的记忆生命周期。

不同之处在于他们执行此操作的方式(即他们使用什么算法)以及必须如何处理每个阶段(手动或自动)。

在 JavaScript 中,分配和释放阶段是自动的。但是,这并不意味着开发人员应该只关心可用内存的使用。

诸如无限循环、糟糕实现的递归和回调地狱之类的东西可能会立即淹没您的内存并导致内存泄漏。

所以,是的,你编码的方式——因此,分配/释放内存槽——对于避免这种情况发生也非常重要。

回到循环。

JavaScript 几乎就是这样工作的。它在创建新变量时分配空间:

var bar = "bar";

并且当不再使用内存时,考虑到变量范围方面的语言限制,内存将被释放。

但是,JavaScript 如何知道不再使用的内存呢?通过它的垃圾收集器。


垃圾收集策略

​JavaScript 使用两种著名的策略来执行 GC:引用计数技术和 Mark-and-sweep 算法。

​引用计数​​方法以其多功能性而闻名。您可以计算指向每个已分配资源的引用数,无论是一堆文件、套接字还是内存插槽。

它认为内存中的每个分配对象都将包含一个附加到它的

考虑以下示例:

计数字段(用作引用)。每当对象不再有指向它的引用时,它就会被自动收集。

var bar = {
name: "bar",
};
bar = "";

这里创建了两个对象:

很简单,不是吗?现在,假设您的代码演变为以下内容:

​bar​​​和​​name​​​。由于​​bar​​​在最后一行接收到一个新值,因此​​name​​可以进行垃圾收集。

很简单,不是吗?现在,假设您的代码演变为以下内容:

var bar = {
name: "bar",
};
var bar = "foo";

function check() {
var bar = {};
var foo = {};
bar.name = foo;
foo.name = bar;

return true;
}
check();

就其对象而言,JavaScript 是一种基于引用的语言,这意味着对象名称指向内存中的实例化值。不仅如此,子类的对象/变量会被他们的父类自动引用。

在上面的示例中,我们创建了一个循环。函数内部​​bar​​​是​​check​​​引用​​foo​​,反之亦然。

通常,当一个函数完成执行时,它的内部元素会被垃圾回收。但是,在这种情况下,GC 无法执行此操作,因为对象仍然相互引用。

这就是第二个 JavaScript GC 参与者出现的地方:mark-and-sweep算法。

该算法的工作原理是从 JavaScript 的顶级对象(即​​root​​的全局对象)中搜索无法访问的对象。

​bar​​采用前一个对象的以下表示:

#yyds干货盘点#——JavaScript 内部原理:垃圾收集_node.js_02

JavaScript 如何跟踪它的对象。

如您所见,JavaScript 可以轻松追踪​​name​​对象,因为它的层次结构定义明确。

那么,当以下代码片段运行时会发生什么?

var bar = "foo";

​干得好:

#yyds干货盘点#——JavaScript 内部原理:垃圾收集_node.js_03

不再可达对象。

看?我们不能再从根跟踪对象了。

该过程的其余部分非常直观:算法将执行几次,从根到底部对象(以及它们各自的层次结构)标记- 被忽略 - 所有可访问的对象,最后从内存中清除的过程,那些不是。就像name对象一样。

这实际上很有意义,不是吗?

这个过程通过一些只有 JavaScript 的 GC 知道的内部条件一遍又一遍地重复,这对于大多数 GC 来说都是常见的。


Node.js 垃圾回收

在我们深入了解 Node.js 如何执行垃圾收集的细节之前,我们需要了解集合中的两个特殊参与者:

堆是指专用于存储引用类型的内存部分。引用类型是包括对象、字符串、闭包等在内的所有内容。

因此,每当您看到在 JavaScript 中创建的对象时,该对象都会被放置在堆上:

堆栈

const myCat = new Cat("Joshua");

同时,堆栈是包含对在堆上创建的那些对象的引用的地方。例如,函数参数是堆栈中存在引用的好例子:

function Cat(name) {
this.name = name;
}

综上所述,Node.js 背后的 JavaScript 引擎​​V8​​是如何执行 GC 的?

堆分为两个主要部分,称为新空间旧空间

#yyds干货盘点#——JavaScript 内部原理:垃圾收集_javascript_04

新空间与旧空间。

新空间是分配新对象和变量的内存区域,因此对于 GC 来说更快,因为一切都是新鲜的。顾名思义,生活在这里的对象属于年轻一代。

旧空间是新空间中没有收集到的物品,经过一段时间后会前往的地方。他们被称为老一代。它还在这里存储了其他类型的对象,例如太大的对象和 V8 编译的代码,但我们不会关注它们。

Node.js 将尽其所能避免 GC 进入旧空间,因为这样做的成本更高。这就是为什么只有高达 20% 的对象从年轻代迁移到老年代。这也是为什么我们有两种不同的算法来处理每一代的原因:

  • Scavenge:这个垃圾收集器通过每次运行时清理一小部分内存来处理年轻代。它超级快,非常适合年轻一代的性质。
  • Mark-and-Sweep:我们已经认识这个人了。由于速度较慢,因此它是老一代的完美选择。


识别 Node.js 中的内存泄漏

​了解 JavaScript 如何在 Node.js 中处理内存的一个好方法是通过一个经典的内存泄漏示例。请记住,当所有 GC 策略由于失去与根对象的连接而未能找到对象时,就会发生内存泄漏。​除此之外,当一个对象总是被其他对象引用并且同时大小继续增长时,我们也可能会发生泄漏。

​例如,假设您有一个手动创建的简单 Node.js 服务器,并且您想要存储来自所有请求的一些重要数据,如下所示:

const http = require("http");

const ml_Var = [];
const server = http.createServer((req, res) => {
let chunk = JSON.stringify({ url: req.url, now: new Date() });
ml_Var.push(chunk);

res.writeHead(200);
res.end(JSON.stringify(ml_Var));
});

const PORT = process.env.PORT || 3000;
server.listen(PORT);

因此,我们正在根据我们的请求创建手动审核日志。该变量

这样的对象可能会成为您的应用程序中的一个大问题,特别是因为其他开发人员可以在您无法监控的其他地方将项目添加到数组中。

为了模拟这个场景,我们将使用 Google Chrome DevTools。等等,但这是一个 Node.js 应用程序……对吧?是的,因为 Chrome 和 Node.js 都使用相同的 JavaScript 引擎 (V8),DevTools 可以理解如何调试和内存检查这两个领域。不是很棒吗?

您需要做的就是使用​​--inspect​​标志启动 Node.js 服务器:

​ml_Var​​是我们代码中的危险点,因为它是一个全局变量,因此将一直存在于内存中,直到服务器关闭(这可能需要很长时间)。

node --inspect index.js

​之后,您可能会看到以下输出:

Debugger listening on ws://127.0.0.1:9229/16ee16bb-f142-4836-b9cf-859799ce8ced
For help, see: https://nodejs.org/en/docs/inspector

现在,前往您的 Chrome(或 Chromium)浏览器并输入​​chrome://inspect​​地址。可能会出现以下屏幕:

#yyds干货盘点#——JavaScript 内部原理:垃圾收集_node.js_05

Google Chrome DevTools 远程目标。

在“远程目标”部分,有一个“检查”链接。当您单击它时,DevTools 扩展可能会打开您的 Node.js 应用程序的直接会话。您还可以查看日志、来源、执行 CPU 分析和内存分析。

如果您前往内存选项卡,您会在页面底部看到一个“拍摄快照”按钮。单击它,DevTools 将生成我们当前正在运行的应用程序的堆快照配置文件(内存转储)。由于目标是比较泄漏发生前后的内存,这是我们在这个过程中的第一步。

但是,在我们进行其他内存转储之前,我们需要一个辅助工具来帮助进行基准测试。换句话说,我们需要向应用程序施加许多请求以验证内存泄漏。siege.js​​是​​完美的工具。

Siege 是一个 Node.js 基准测试工具,它简化了针对端点运行数百或数千个请求的任务。

首先,我们需要运行​​npm install siege --save​​命令来安装它,然后,创建另一个名为benchmark.js的 JavaScript 文件并添加以下内容:

const siege = require("siege");

siege().on(3000).for(2000).times.get("/").attack();

在这里,我们要求

伟大的!现在,我们可以转到其他堆快照。运行基准文件:

siege.js在位于端口 3000 下的根端点上运行总共 2000 个请求。就这么简单!

node benchmark.js

等到它完成。它将产生以下输出:

GET:/
done:2000
200 OK: 2000
rps: 1709
response: 5ms(min) 23ms(max) 9ms(avg)

返回 DevTools 并再次点击“拍摄快照”按钮。为了安全起见,让我们再次重复该过程,直到我们有 3 个快照。这将有助于微调整体内存分析。

#yyds干货盘点#——JavaScript 内部原理:垃圾收集_应用程序_06

开发工具结果。

这里有几点需要澄清:

  • 头部快照列表。选择第三个与第二个进行比较。
  • 我们需要选择“Comparison”来启用 DevTools 的比较功能。
  • 选择您要比较的快照。
  • 在内存中创建的构造函数列表。“#New”列将显示从上一个快照到当前快照创建的新对象的数量。注意每个字符串的内容,它们对应于我们创建的 JSON 请求日志。
  • “对象”部分带来了创建每个对象的堆栈的详细信息。对于 JSON 字符串,​​ml_Var​​是创建它们的上下文。

有趣的是,2014 年的字符串对象是从一个快照创建到另一个快照。2k 指的是我们引入的请求日志,另外 14 个是由 Node.js 自己创建和管理的字符串。

在我们的示例中,只有 3 次执行导致内存中有 4k 个新对象。想象一下在生产中运行的真实应用程序中的这种情况。很快,内存就会泄漏,直到一无所有。

现在您已经确定了泄漏,解决方案非常简单。只需确保将这些日志存储​​到文件​​、外部服务(如 Splunk)甚至数据库中。


举报

相关推荐

0 条评论