JavaScript的内存管理
大多数情况下,作为JavaScript开发者,你可能可以在不需要知道任何有关内存管理的情况下就做得很好。毕竟JS引擎帮你把这事都干了。
但另一方面,你也会碰到一些只有知道内存分配运作原理才能解决的问题,比如内存泄漏。
在这篇文章中,我会介绍内存分配和垃圾回收机制,以及如何避免一些常见的内存泄漏。
JavaScript在浏览器是如何运作的?
本文是我的系列文章(解释JavaScript在浏览器是如何工作的)的第二部分。为了能在邮箱获取我最新的文字,请订阅我的简讯。
Part 1: JavaScript Event Loop And Call Stack Explained
Part 2: JavaScript's Memory Management: Heap And Garbage Collection Explained
内存生命周期
在JavaScript中,当我们创建变量、函数或者任何你能想到的,JS引擎都会为它分配内存,当它不会再被用到的时候就会被释放掉。
在内存中,分配内存是保留空间的过程,而释放内存则是释放空间,释放出来的空间准备用于其他目的。
我们每次声明一个变量或创造一个函数,该变量的存储通常会经历以下相同的阶段:
- Allocate 分配内存
JavaScript为我们解决了这个问题:它为我们创建的将会用到的对象分配内存。
- Use 使用内存
使用内存是我们在代码中明确进行的工作:对内存的读写不过是对变量的读写。
- Release 释放内存
这一步也是JS引擎处理的。一旦分配的内存被释放,它就可以被用到新的地方去了。
内存管理中“Objects”不止包含JS对象,也包括函数和函数作用域。
内存堆和栈
我们现在知道在JavaScript中定义的任何东西,JS引擎都会为它分配内存并在不需要的时候将其释放掉。
下一个我想到的问题是:存储到哪里?
JS引擎有两个地方可以存储数据:内存堆和栈。
堆和栈是引擎用于不同目的的两种数据结构。
栈:静态内存分配
所有的值都被存储在栈中因为它们都包含原始值
栈是一种JavaScript用来存储静态数据(static data)的数据结构。静态数据是指引擎在编译阶段就知道大小的数据。在JS中,这包括了原始值(string、number、boolean、undefined、null、symbol[1])和指向对象、函数的引用。
由于引擎知道大小不会变,所以它会给每个值分配固定数量的内存。
在执行之前立即分配内存的过程被称为静态内存分配。
因为引擎给这些值分配了固定数量的内存,所以原始值的大小是有限制的。
这些值和整个栈的限制取决于浏览器。
堆:动态内存分配
堆是用于存储数据的不同空间,也是JavaScript存储对象和函数的地方。
不同于栈,引擎不会给这些对象对分配固定数量的内存。相反,将根据需要分配更多空间。
这种分配方式称为动态内存分配。
这里列出了两种存储的并排比较的特征:
栈Stack | 堆Heap |
---|---|
原始值和引用 | 对象和函数 |
编译时知道大小 | 运行时知道大小 |
分配固定量空间 | 每个对象没有 |
例子
让我们看一些代码示例。在标题中我提到的分配的内容:
const person = {
name: 'John',
age: 24
};
JS在堆中为这个对象分配内存。实际值仍然是原始值,这就是它们被存储在栈中的原因。
const hobbies = ['hiking', 'reading];
数组也是对象,这就是它们被存储在堆中的原因。
let name = 'John'; // allocates memory for a string
const age = 24; // allocates memory for a number
name = 'John Doe'; // allocates memory for a new string
const firstName = name.slice(0,4); // allocates memory for a new string
原始值是不可改变的,这意味着JavaScript不是更改原来的值,而是创建一个新值。
JavaScript引用
所有变量首先指向栈。如果它是一个非原始值,栈就会包含一个指向堆中对象的引用。
堆内存不是按特定方法排序的,这就是我们需要在栈中保留对其引用的原因。你可以视引用为地址,视堆中的对象为这些地址所属的房屋。
记住JavaScript在堆中存储对象和函数,在栈中存储原始值和引用。
这张图中,我们可以观察到不同变量存储的区别。注意 person 和 newPerson 指向同一个对象
例子
const person = {
name: 'John',
age: 24,
};
这会创建在堆中创建一个对象,在栈中创建一个指向它的引用。
引用是JavaScript工作原理的核心概念。在此处进行更多详细的讨论将会超出本文范围,但如果你想了解有关它的更多信息,在评论中说明并订阅我的简讯。
垃圾回收
现在,我们知道了JavaScript如何给不同对象分配内存,但如果我们还记得内存生命周期,还差最后一步:释放内存。
和内存分配一样,JavaScript引擎也为我们处理了这一步。更确切地说,垃圾回收器为我们做了这件事。
一旦JavaScript引擎识别出一个给定的变量或函数不会再被用到,它就会释放该变量或函数占用的内存。
其中最大的问题就是,某些内存是否仍被需要是一个无法确定的问题。这意味着不可能有一个算法能在它过时的那一刻立即收集不再需要的内存。
一些算法为该问题提供了类似的不错的解决方法。我将会在本节中讨论最常用的方法:引用计数垃圾收集和清除标记算法。
引用计数
这是最简单的类似解决方案。它收集那些引用为0的对象。
让我们来看看下面的例子。线代表引用。
注意如何做到最后一帧中只有 hobbies 保留在堆中的,因为最后它依旧是含有引用的对象。
循环
这个算法的问题是没有考虑循环引用。当一个或多个对象互相引用但无法再通过代码访问它们时,该问题就出现了。
let son = {
name: 'John',
};
let dad = {
name: 'Johnson',
}
son.dad = dad;
dad.son = son;
son = null;
dad = null;
因为 son 和 dad 对象互相引用,所以该算法不会释放已分配的内存。我们也无法再访问这两个对象。
把它们设置为 null 不会让引用计数算法认为它们不再需要,因为它们都有传入的引用。
标记清除算法
标记清除算法解决了循环依赖问题。它检测是否可以从根对象访问它们,而不是简单的计算给定对象的引用。
根对象在浏览器中指 window ,在NodeJS中指 global
该算法将无法**访问的对象标记为垃圾,然后清理它们。根对象永远不会被收集。
在这种情况下,循环依赖不再是问题。在之前的例子中,无论是dad 还是 son对象都不能从根对象访问了,所以它们两个都将被标记为垃圾并收集。
2012年以来,该算法已在所有现代浏览器中实现。仅在性能和实现方面进行了改进,而没有改进算法的核心思想本身。
权衡取舍
自动垃圾回收允许我们专注于构建应用而不用在内存管理上浪费时间。但是我们仍注意一些权衡取舍。
内存使用
鉴于算法无法确定何时不再需要确切的内存,JavaScript应用程序可能会使用比它们实际需要更多的内存。
即使对象被标记为垃圾,何时及是否将收集分配的内存也是由垃圾收集器决定的。
如果你希望应用程序尽可能提高内存效率,那最好使用低级语言。但记住这需要权衡取舍。
性能
该垃圾回收算法为了清理未使用对象通常会周期性执行。
问题是我们开发人员无法确切得知道这会何时发生。收集大量垃圾或频繁收集垃圾可能会性能,因为这样做需要一定数量的计算能力。
但是,这种影响对使用者或开发者而言通常忽略不计。
内存泄漏
有了内存管理的知识,我们来看看最常见的内存泄漏。
如果了解幕后情况我们就可以轻松避免这些。
全局变量
存储全局变量可能是最常见的内存泄漏类型。
在浏览器下的JavaScript中,如果没有使用 var const let ,变量就会被赋到window 对象上。
users = getUsers();
在严格模式下运行代码可以避免这种情况。
除了意外地添加变量到根对象上,在许多情况下你可能会故意这样做。
你当然可以使用全局变量,但确保不再需要这些数据时就释放掉空间。
可以将全局变量赋值为null 从而释放内存。
window.users = null;
被遗忘的定时器和回调
忘掉定时器和回调会增加应用程序的内存使用。特别是在单页应用(SPAs)中,当动态添加了事件监听器和回调时必须小心。
遗忘的定时器
const object = {};
const intervalId = setInterval(function() {
// everything used in here can't be collected until the interval is cleared
// 这里的任何东西都不会被回收直到该定时器被清除
doSomething(object);
}, 2000);
上述代码每2秒运行一次函数。如果你的项目中有类似代码,可能不需要一直运行它。
只要该定时器不被清除,里面引用到的对象就不会被垃圾回收。
当它不再需要的时候,确保清除掉该定时器。
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参考
这种内存泄漏和上一个相似:在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保持一致。
因为每个DOM元素也保留着对它父节点的引用,所以你将阻止垃圾回收器收集该元素的父节点和子节点们。
结论
本文,我总结了JavaScript内存管理的核心概念。
写这篇文章帮助我梳理了一些之前没有完全理解的概念,我希望这可以很好的概述JavaScript内存管理的工作原理。