本文已经过原作者Ahmad shaded 授权翻译。
大多数时候,我们在不了解有关内存管理的知识下也只开发,因为 JS 引擎会为我们处理这个问题。不过,有时候我们会遇到内存泄漏之类的问题,这个只有知道内存分配是怎样工作的,我们才能解决这些问题。
在本文中,主要介绍内存分配和垃圾回收的工作原理以及如何避免一些常见的内存泄漏问题。
缓存( Memory)生命周期
在 JS 中,当我们创建变量、函数或任何对象时,J S引擎会为此分配内存,并在不再需要时释放它。
分配内存是在内存中保留空间的过程,而释放内存则释放空间,准备用于其他目的。
每次我们分配一个变量或创建一个函数时,该变量的存储会经历以下相同的阶段:
分配内存
JS 会为我们处理这个问题:它分配我们创建对象所需的内存。
使用内存
使用内存是我们在代码中显式地做的事情:对内存的读写其实就是对变量的读写。
释放内存
此步骤也由 JS 引擎处理,释放分配的内存后,就可以将其用于新用途。
内存管理上下文中的“对象”不仅包括JS对象,还包括函数和函数作用域。
内存堆和堆栈
现在我们知道,对于我们在 JS 中定义的所有内容,引擎都会分配内存并在不再需要内存时将其释放。
我想到的下一个问题是:这些东西将被储存在哪里?
JS 引擎在两个地方可以存储数据:内存堆和堆栈。堆和堆栈是引擎是用于不同目的的两个数据结构。
堆栈:静态内存分配
堆栈是 JS 用于存储静态数据的数据结构。静态数据是引擎在编译时能知道大小的数据。在 JS 中,包括指向对象和函数的原始值(strings,number,boolean,undefined和null)和引用类型。
由于引擎知道大小不会改变,因此它将为每个值分配固定数量的内存。
在执行之前立即分配内存的过程称为静态内存分配。这些值和整个堆栈的限制取决于浏览器。
堆:动态内存分配
堆是另一个存储数据的空间,JS 在其中存储对象和函数。
与堆栈不同,JS 引擎不会为这些对象分配固定数量的内存,而根据需要分配空间。这种分配内存的方式也称为动态内存分配。
下面将对这两个存储的特性进行比较:
堆栈 | 堆 |
---|---|
存放基本类型和引用 大小在编译时已知 分配固定数量的内存 |
对象和函数 在运行时才知道大小 没怎么限制 |
事例
来几个事例,加强一下映像。
- 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); // 为新字符串分配内存
始值是不可变的,所以 JS 不会更改原始值,而是创建一个新值。
JavaScript 中的引用
所有变量首先指向堆栈。如果是非原始值,则堆栈包含对堆中对象的引用。
堆的内存没有按特定的方式排序,所以我们需要在堆栈中保留对其的引用。我们可以将引用视为地址,并将堆中的对象视为这些地址所属的房屋。
请记住,JS 将对象和函数存储在堆中。基本类型和引用存储在堆栈中。
这张照片中,我们可以观察到如何存储不同的值。注意person和newPerson都如何指向同一对象。
事例
- const person = {
- name: 'John',
- age: 24,
- };
这将在堆中创建一个新对象,并在堆栈中创建对该对象的引用。
垃圾回收
现在,我们知道 JS 如何为各种对象分配内存,但是在内存生命周期,还有最后一步:释放内存。
就像内存分配一样,JavaScript引擎也为我们处理这一步骤。更具体地说,垃圾收集器负责此工作。
一旦 JS 引擎识别变量或函数不在被需要时,它就会释放它所占用的内存。
这样做的主要问题是,是否仍然需要一些内存是一个无法确定的问题,这意味着不可能有一种算法能够在不再需要那一刻立即收集不再需要的所有内存。
一些算法可以很好地解决这个问题。我将在本节中讨论最常用的方法:引用计数和标记清除算法。
引用计数
当声明了一个变量并将一个引用类型值赋值该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另外一个变量,则该值得引用次数加1。相反,如果包含对这个值引用的变量又取 得了另外一个值,则这个值的引用次数减1。
当这个值的引用次数变成 0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那 些引用次数为零的值所占用的内存。
我们看下面的例子。
请注意,在最后一帧中,只有hobbies留在堆中的,因为最后引用的是对象。
周期数
引用计数算法的问题在于它不考虑循环引用。当一个或多个对象互相引用但无法再通过代码访问它们时,就会发生这种情况。
- let son = {
- name: 'John',
- };
- let dad = {
- name: 'Johnson',
- }
- son.dad = dad;
- dad.son = son;
- son = null;
- dad = null;
由于父对象相互引用,因此该算法不会释放分配的内存,我们再也无法访问这两个对象。
它们设置为null不会使引用计数算法识别出它们不再被使用,因为它们都有传入的引用。
标记清除
标记清除算法对循环依赖性有解决方案。它检测到是否可以从root 对象访问它们,而不是简单地计算对给定对象的引用。
浏览器的root是window 对象,而NodeJS中的root是global。
该算法将无法访问的对象标记为垃圾,然后对其进行扫描(收集)。根对象将永远不会被收集。
这样,循环依赖关系就不再是问题了。在前面的示例中,dad对象和son对象都不能从根访问。因此,它们都将被标记为垃圾并被收集。
自2012年以来,该算法已在所有现代浏览器中实现。仅对性能和实现进行了改进,算法的核心思想还是一样的。
折衷
自动垃圾收集使我们可以专注于构建应用程序,而不用浪费时间进行内存管理。但是,我们需要权衡取舍。
内存使用
由于算法无法确切知道什么时候不再需要内存,JS 应用程序可能会使用比实际需要更多的内存。
即使将对象标记为垃圾,也要由垃圾收集器来决定何时以及是否将收集分配的内存。
如果你希望应用程序尽可能提高内存效率,那么最好使用低级语言。但是请记住,这需要权衡取舍。
性能
收集垃圾的算法通常会定期运行以清理未使用的对象。
问题是我们开发人员不知道何时会回收。收集大量垃圾或频繁收集垃圾可能会影响性能。然而,用户或开发人员通常不会注意到这种影响。
内存泄漏
在全局变量中存储数据,最常见内存问题可能是内存泄漏。
在浏览器的 JS 中,如果省略var,const或let,则变量会被加到window对象中。
- users = getUsers();
在严格模式下可以避免这种情况。
除了意外地将变量添加到根目录之外,在许多情况下,我们需要这样来使用全局变量,但是一旦不需要时,要记得手动的把它释放了。
释放它很简单,把 null 给它就行了。
- window.users = null;
被遗忘的计时器和回调
忘记计时器和回调可以使我们的应用程序的内存使用量增加。特别是在单页应用程序(SPA)中,在动态添加事件侦听器和回调时必须小心。
被遗忘的计时器
- const object = {};
- const intervalId = setInterval(function() {
- // 这里使用的所有东西都无法收集直到清除`setInterval`
- doSomething(object);
- }, 2000);
上面的代码每2秒运行一次该函数。如果我们的项目中有这样的代码,很有可能不需要一直运行它。
只要setInterval没有被取消,则其中的引用对象就不会被垃圾回收。
确保在不再需要时清除它。
- clearInterval(intervalId);
被遗忘的回调
假设我们向按钮添加了onclick侦听器,之后该按钮将被删除。旧的浏览器无法收集侦听器,但是如今,这不再是问题。
不过,当我们不再需要事件侦听器时,删除它们仍然是一个好的做法。
- const element = document.getElementById('button');
- const onClick = () => alert('hi');
- element.addEventListener('click', onClick);
- element.removeEventListener('click', onClick);
- element.parentNode.removeChild(element);
脱离DOM引用
内存泄漏与前面的内存泄漏类似:它发生在用 JS 存储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元素也保留对其父节点的引用,因此可以防止垃圾收集器收集元素的父元素和子元素。
总结
在本文中,我们总结了 JS 中内存管理的核心概念。写这篇文章可以帮助我们理清一些我们不完全理解的概念。
希望这篇对你有所帮助,我们下期再见,记得三连哦!
作者:Ahmad shaded 译者:前端小智 来源:felixgerschau
原文:https://felixgerschau.com/javascript-memory-management/
本文转载自微信公众号「大迁世界」,可以通过以下二维码关注。转载本文请联系大迁世界公众号。