一、内存生命周期
在JS中,当我们创建变量、函数或者其他任何你想创建的,JS引擎就会为此分配内存,并且释放它当不再需要的时候。
分配内存是在内存中保留空间的过程,当释放内存的时候清空空间,并准备其他的用途。
每一次我们分配一个变量和创建一个函数时,内存的工作都会经历以下三个相同的步骤:
- 分配内存
JS会帮我们做好:JS会为我们需要用到的对象(Object)创建内存。
- 使用内存
使用内存是我们在执行代码时做的事情:读写内存不过是从内存中写入或者写出变量。
- 释放内存
这一步也是JS引擎替我们做的。一旦分配的内存被释放掉了,就会被用于新的目的。
‘Objects’
在内存管理的上下文中不仅仅指的是JS对象也指的是函数和函数作用域。
二、栈内存和堆内存
我们知道任何我们在JS中定义的东西,就会先分配内存,当不再需要的时候释放它。
有一个问题就是:他们是在哪里存储的?
JS引擎有两个地方存储数据:内存堆和栈堆。
堆和栈是JS引擎用于不同目的的两种数据结构。
2.1 栈:静态内存分配
栈是JS用于来存储静态数据的一种数据结构。静态数据是一种JS在编译期间就知道大小的数据。在JS中,这包含原始值(strings, numbers, booleans, undefined, and null)和引用,指向对象和函数。
由于引擎知道大小不会改变,JS会为每一个值分配一个固定大小的内存空间。
在执行之前分配内存的过程被认为是静态内存分配。
由于引擎为这些值分配了固定大小的内存,就会对原始值有一个大小限制。
对这些值的限制和整个栈的不同是取决于浏览器的。
2.2 堆:动态内存分配
堆是另外一个空间是JS用于存储对象和函数的地方。
与栈不同,JS引擎不会为这些对象分配固定的内存空间。相反,会根据需要分配更多的空间。
这种分配内存的方式也叫做动态内存分配。
栈 | 堆 |
---|---|
原始值和引用。 在编译期间大小是已知的。 分配固定的内存。 |
对象和函数。 在运行期间大小是已知的。 对每一个对象没有限制。 |
2.3 示例
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
原始值是不可变得,这也就是为什么JS会创建一个新的值,而不是改变原始值。
三、JS中的引用
所有的变量首先指向栈。如果它是一个非原始值,栈中就会包含一个指向堆中对象的引用。
内存堆不是按照特定的方式排列的,这也是为什么我们需要在栈中存放一个引用。可以把栈中的引用比作一个地址,堆中的对象比作房子,地址是属于这个房子的。
JS在堆中存储对象和函数。原始值和引用存储在栈中。
在这张图片上,我们可以观察到JS针对不同的值存储的区别。person
和newPerson
指向同一个对象。
3.1 示例
const person = {
name: 'John',
age: 24,
};
四、垃圾回收
与内存分配类似,JS引擎也给我们把这步做了。更准确的说,是垃圾回收器处理的。
一旦JS引擎识别出来已分配的变量和函数不再需要了,就会释放掉占据的内存空间。
主要的问题是否一些内存仍然还需要是个不可判定的问题,这意味着没有一种算法可以在当内存变的没用的时候,
可以在准备的时间收集到所有不在需要的内存。
很多的算法也只是提供了一个约数。
4.1 引用计数垃圾回收
引用计数是比较容易的评估。他会收集没有引用指向的对象。
https://felixgerschau.com/video/stack-heap-gc-animation.mp4
循环
这个算法有一个问题就是他不考虑循环引用。这是当一个或者多个对象互相引用彼此,但是不再通过代码互相触达。
let son = {
name: 'John',
};
let dad = {
name: 'Johnson',
}
son.dad = dad;
dad.son = son;
son = null;
dad = null;
由于son
和dad
互相引用彼此,这个算法不会释放掉已分配的内存。不再有任何方式可以触达这两个对象。
把他们设置为null
也不会使得引用计数算法识别出不再被使用,因为它们都有引入的引用。
4.2 标记清除算法
标记清除算法有解决循环依赖的方案。不是仅仅通过对已有的对象进行计数,他会检测是否可以通过根对象进行触达。
在浏览器中的根对象是window
对象,在NodeJs中的根对象是global
。
这个算法会把不再触达的对象标记为垃圾,之后就会回收它。根对象永远不会被收集。
这种方式下,循环依赖不再是问题。在之前的例子上,son
和dad
对象都不会被触达。这样,两者都会被标记为垃圾并被回收。
4.3 权衡
自动垃圾回收可以使得开发者聚焦在构建应用上而不是花时间在内存管理上。但仍然有一些权衡需要了解。
4.4 内存使用
考虑到算法不能准确的知道哪块内存已经不再需要,JS应用可能会比真实需要更多的内存。
即使对象被标记为垃圾,这也有垃圾回收器来决定什么时候是否进行收集。
如果你需要你的应用尽可能的内存利用率高,你可以考虑低级语言。但也会有一些成本在其中。
4.5 性能
这种关于垃圾回收的算法是定期运行来清理无用的对象。
问题是,作为开发者,并不能准确的知道什么时候将会运行。
收集一些垃圾或者定期收集内存可能会影响性能,是由于他需要一定的计算能力消耗。然而这种影响可能对于用户和开发者并不明显。
五、内存泄露
5.1 全局变量
在全局变量里存储数据可能是最常见的内存泄露类型。
在浏览器里,举个例子,如果你使用var
而不是const
或者let
,亦或者没有给变量任何的关键字,JS引擎就会把变量关联到window
对象上。
同样的事情也会发生在函数上,当用function
关键字定义的函数。
user = getUser();
var secondUser = getUser();
function getUser() {
return 'user';
}
user
,secondUser
和getUser
会被关联到window
对象上。
这发生在定义在全局作用域下的的变量和函数。
为了避免这些可以在严格模式下运行代码。
除了无意识的添加了变量到根对象上,有很多情况下你可能是有意识的去做。
你当然可以利用全局变量,但是当你不再需要他们的时候要释放空间。
为了释放内存,你可以给你的全局变量设置为null
。
window.users = null;
5.2 遗漏计时器和回调
遗漏计时器和回调会导致应用的内存使用升高。尤其是在单页应用,你必须非常的小心当你动态的添加添加事件监听器和回调时。
遗漏计时器
const object = {};
const intervalId = setInterval(function() {
// everything used in here can't be collected
// until the interval is cleared
doSomething(object);
}, 2000);
上面的代码会每2s执行一次函数,如果你有这样的代码在你的项目里,你可能不需要它一直运行。
只要interval
没有被取消,那么在interval
中对象的引用就不会被垃圾回收。
确保清理这些interval
当不再需要的时候。
clearInterval(intervalId);
这在单页应用中是尤其重要的,甚至当你从页面导航离开的时候,仍然会运行在后台中。
遗漏回调
你给一个按钮增加了一个onclick
的监听器,之后再删除。
老的浏览器不会收集这些监听器,但是现在,这不再是一个问题了。
但是,这仍然是一个好主意当你不再需要他们的时候删除事件监听器。
const element = document.getElementById('button');
const onClick = () => alert('hi');
element.addEventListener('click', onClick);
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
5.3 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元素时,你可能想确保也从数组中删除这些元素。
然而,这些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元素会保持一个指向父节点的引用。你可能会阻止垃圾回收器收集元素的父节点和子节点。