查看原文
其他

【第2616期】解释JavaScript的内存管理

CoyPan 前端早读课 2022-05-18

前言

常复习常新。今日早读文章由腾讯@CoyPan翻译授权,公号:符合预期的CoyPan分享。

@CoyPan,一名符合预期的前端工程师

正文从这开始~~

大多数时候,作为一个JavaScript开发人员,您可能不知道任何关于内存管理的知识。毕竟,JavaScript引擎会为您处理这些问题。

不过,在某个时刻,您会遇到一些问题,比如内存泄漏,只有知道内存分配是如何工作的,才能解决这些问题。

在本文中,我将向你介绍内存分配和垃圾回收的工作原理,以及如何避免一些常见的内存泄漏。

内存生命周期

在JavaScript中,当我们创建变量、函数或任何你能想到的东西时,JS引擎会为此分配内存,并在不再需要时释放它。

分配内存是在内存中占用空间的过程,而释放内存是释放空间,准备用于其他用途。

每当我们分配一个变量或创建一个函数时,它的内存总是经过以下相同的阶段:

分配内存

JavaScript为我们处理这个问题:它为我们创建的对象分配我们需要的内存。

使用内存

使用内存是我们在代码中显式地做的事情:读写内存只不过是读写变量。

释放内存

这个步骤也由JavaScript引擎处理。一旦分配的内存被释放,它就可以用于新的用途。

内存管理上下文中的“对象”不仅包括JS对象,还包括函数和函数作用域。

内存堆和堆栈

我们现在知道,对于我们在JavaScript中定义的所有内容,引擎会分配内存并在不再需要时释放内存。

我想到的下一个问题是:这个数据要放在哪里?

JavaScript引擎有两个地方可以存储数据:内存堆和堆栈。

堆和堆栈是引擎用于不同目的的两种数据结构。

堆栈:静态内存分配

堆栈是JavaScript用来存储静态数据的数据结构。静态数据是引擎在编译时知道大小的数据。在JavaScript中,这包括原始值(字符串、数字、布尔值、未定义和null)和指向对象和函数的引用。

由于引擎知道大小不会改变,它将为每个值分配固定数量的内存。

在执行之前分配内存的过程称为静态内存分配。

因为引擎为这些值分配了固定数量的内存,所以原始值的大小是有限制的。

这些值和整个堆栈的限制因浏览器而异。

堆:动态内存分配

堆是存储数据的不同空间,JavaScript在其中存储对象和函数。

与堆栈不同,引擎不会为这些对象分配固定数量的内存。相反,将根据需要分配更多空间。

以这种方式分配内存也称为动态内存分配。

总结一下,

堆栈:原始值和引用;大小在编译时已知;分配固定数量的内存

堆:对象和函数;大小在运行时已知;每个对象没有限制

例子

我们来看几个例子:

const person = {
name: 'John',
age: 24,
};

JS在堆中为这个对象分配内存。实际值仍然是原始值,这就是为什么它们存储在堆栈中。

const hobbies = ['hiking', 'reading'];

数组也是对象,这就是它们存储在堆中的原因。

let name='John'//为字符串分配内存

const age=24//为数字分配内存

name='John Doe'//为新字符串分配内存

const firstName = name.slice(0,4); //为新字符串分配内存

原始值是不可变的,这意味着JavaScript不会更改原始值,而是创建一个新值。

JS中的引用

所有变量首先指向堆栈。如果它是非基元值,则堆栈包含对堆中对象的引用。

堆的内存没有以任何特定的方式排序,这就是为什么我们需要在堆栈中保留对它的引用。您可以将引用视为地址,而堆中的对象则视为这些地址所属的房屋。

记住JavaScript在堆中存储对象和函数。原始值和引用存储在堆栈中。

在这幅图中,我们可以观察到不同的值是如何存储的。注意person和newPerson如何指向同一个对象。

举例说明:

const person = {
name: 'John',
age: 24,
};

这将在堆中创建一个新对象,并在堆栈中创建对它的引用。

垃圾回收

我们现在知道JavaScript是如何为所有类型的对象分配内存的,但是如果我们还记得内存的生命周期,就缺少最后一步:释放内存。

就像内存分配一样,JavaScript引擎也为我们处理这一步。更具体地说,垃圾收集器负责处理这个问题。

一旦JavaScript引擎识别出某个给定的变量或函数不再需要,它就会释放它所占用的内存。

主要的问题是,是否仍然需要一些内存是一个不可解问题,这意味着不可能有一个算法能够在内存过时时收集所有不再需要的内存。

有些算法对这个问题提供了很好的近似值。在本节中,我将讨论最常用的方法:引用计数垃圾回收和标记和清除算法。

引用计数垃圾回收

这是最简单的近似值。它收集没有被引用的对象。

让我们看看下面的例子。

请注意,在最后一帧中,只有hobbies才保留在堆中,因为它是最终有引用的对象。

这个算法的问题是它没有考虑循环引用。当一个或多个对象相互引用,但不能再通过代码访问它们时,就会发生这种情况。

let son = {
name: 'John',
};

let dad = {
name: 'Johnson',
}

son.dad = dad;
dad.son = son;

son = null;
dad = null;

因为子对象和父对象相互引用,所以算法不会释放分配的内存。我们再也无法接近这两个对象了。

将它们设置为null不会使引用计数算法认识到它们不能再使用,因为它们都有传入的引用。

清除标记算法

标记和扫描算法有一个循环依赖的解决方案。它不是简单地计算对给定对象的引用,而是检测它们是否可以从根对象访问。

浏览器中的根是window对象,而在NodeJS中是global对象。

该算法将不可访问的对象标记为垃圾,然后对其进行清理(收集)。不会收集根对象。

这样,循环依赖就不再是问题了。在前面的示例中,不能从根访问dad或son对象。因此,它们都将被标记为垃圾并被收集。

自2012年以来,该算法在所有现代浏览器中都得到了实现。改进的只是性能和实现,而不是算法的核心思想本身。

权衡

自动垃圾回收使我们能够专注于构建应用程序,而不是浪费时间进行内存管理。然而,我们需要注意一些权衡。

内存使用

由于算法无法知道何时不再需要内存,JavaScript应用程序可能会使用比实际需要更多的内存。

即使对象被标记为垃圾,也要由垃圾收集器决定何时以及是否收集分配的内存。

如果你需要你的应用程序尽可能的节省内存,你最好使用较低级别的语言。但请记住,这是有其自身的一套权衡。

性能

为我们收集垃圾的算法通常定期运行以清理未使用的对象。

问题是,我们,开发者,不知道什么时候会发生。收集大量垃圾或频繁收集垃圾可能会影响性能,因为这样做需要一定的计算能力。

然而,对于用户或开发人员来说,影响通常是不明显的。

内存泄露

有了关于内存管理的所有知识,让我们来看看最常见的内存泄漏。

你会发现,如果你了解幕后的情况,这些都是很容易避免的。

全局变量

在全局变量中存储数据可能是最常见的内存泄漏类型。

在浏览器的JavaScript中,如果省略var、const或let,变量将附加到window对象。

users = getUsers();

通过在严格模式下运行代码来避免这种情况。

除了意外地将变量添加到根目录之外,在许多情况下,您可能会故意这样做。

您当然可以使用全局变量,但请确保在不再需要数据时释放空间。

要释放内存,请将全局变量指定为null。

window.users = null;
被遗忘的定时器和回调函数

忘记计时器和回调会增加应用程序的内存使用率。特别是在单页应用程序(spa)中,动态添加事件侦听器和回调时必须小心。

被遗忘的定时器
const object = {};
const intervalId = setInterval(function() {
// everything used in here can't be collected
// until the interval is cleared
doSomething(object);
}, 2000);

上面的代码每2秒运行一次函数。如果您的项目中有这样的代码,您可能不需要一直运行此代码。

只要interval没有取消,interval中引用的对象就不会被垃圾回收。

一旦不再需要,一定要清除interval。

clearInterval(intervalId);

这在Spa中尤其重要。即使离开需要此interval的页面,它仍将在后台运行。

被遗忘的回调函数

假设您将onclick侦听器添加到按钮中,稍后将删除该侦听器。

旧的浏览器无法回收侦听器,但现在,这不再是一个问题。

不过,当您不再需要事件侦听器时,最好删除它们:

const element = document.getElementById('button');
const onClick = () => alert('hi');

element.addEventListener('click', onClick);

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
DOM外引用

这种内存泄漏与前面的类似:在JavaScript中存储DOM元素时会发生这种情况。

const elements = [];
const element = document.getElementById('button');
elements.push(element);

function removeAllElements() {
elements.forEach((item) => {
document.body.removeChild(document.getElementById(item.id))
});
}

当您删除这些元素中的任何一个时,您可能需要确保也从数组中删除这个元素。

否则,无法收集这些DOM元素。

const elements = [];
const element = document.getElementById('button');
elements.push(element);

function removeAllElements() {
elements.forEach((item, index) => {
document.body.removeChild(document.getElementById(item.id));
++ elements.splice(index, 1); // 增加这一行
});
}

由于每个DOM元素也保留对其父节点的引用,因此将阻止垃圾回收器收集元素的父节点和子节点。

结论

在本文中,我总结了JavaScript内存管理的核心概念。

写这篇文章帮助我理清了一些我不完全理解的概念,我希望这篇文章能很好地概述JavaScript中内存管理的工作原理。

关于本文
译者:@CoyPan
译文:https://mp.weixin.qq.com/s/22qAyL8NzYqfS4Ly-upfhQ
作者:@Felix
原文:https://felixgerschau.com/javascript-memory-management/

相关阅读,欢迎自荐投稿,前端早读课等你来。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存