该文章是以国外一篇文章,关于JS 内存管理(JavaScript's Memory Management Explained)为基础。同时加上其他资料的所编写的。
如果想直接根据原文学习,可以忽略此文。但是不要忘记点赞+关注。
如果你觉得可以,请多点赞,鼓励我写出更精彩的文章🙏。
如果你感觉有问题,也欢迎在评论区评论,三人行,必有我师焉
该文章为JS如何在浏览器中运行系列
概要
- 3.JS中的引用
- 4.垃圾回收
- 5.内存泄漏
内存的生命周期
在JS中,无论新建一个变量,函数或者其他数据结构,JS引擎会默默的为我们做许多事-为对应的数据结构分配(allocate
)内存空间并且在该数据不在被需要的时候回收(deallocate/release
)它。
如果大家在对底层语言(C/C++)比较熟悉的话,在C++中利用标准库,allocate / deallocate 就可以实现内存的手动分配和回收
分配内存是预留内存的过程,而回收内存是将被占用的内存回收,为其他操作提供空间。
就像人有生老病死,内存也有对应的生命周期。每当赋值变量或者创建函数,内存都会经历如下的过程:
- 分配内存
JS引擎
为我们分配我们需要操作对象的对应空间 - 使用内存
对内存的读写,只不过是对变量的读写 - 回收内存
JS引擎
通过特定的算法判断内存是否需要释放,而被释放的内存用于为其他操作提供空间。
在内存管理器中的对象不仅仅包括JS对象,而且还囊括了函数和函数作用域。 JS中一切皆对象
堆和栈
我们从上文得知,JS引擎
分配空间并在该空间不被引用的时候将其回收。
那这些被分配的空间具体被存放在哪里呢?
JS引擎
有两个地方用于存放数据:堆(memory heap)和栈(stack)。
堆
和栈
是JS引擎存放数据的两个不同的数据结构。
栈:静态内存分配
上图,所有的值都被存放到 stack
中-->由于他们的类型都是原始类型(primitive)
stack
是JS用于存放静态数据的数据结构。静态数据是一种JS引擎在编译阶段能准确知道该数据大小的数据类型。在JS中,静态数据包括原始类型(string
/number
/boolean
/undefined
/null
)和引用类型(指向对象和函数的指针
)。
由于JS引擎知道它们的大小不会发生更改,所以每次都会为其分配指定大小的内存空间。
在代码执行之前分配内存的过程被称为 静态内存分配。
由于JS引擎为这些数据分配定值的内存空间,所以在stack
存储的数据是有内存上限的。而该上限由不同浏览器各自决定。
堆:动态内存分配
heap
是JS用于存放对象和函数的地方。
不像stack
,JS引擎不会为这些对象分配定值的内存空间。相反,这些空间是按需分配的。
该分配内存的方式被称为动态内存分配。
为了便于区分stack
和heap
各自的区别,绘制如下的表格:
区别 | Stack | Heap |
存储类型 | 原始类型和引用类型 | 对象和函数 |
内存是否定值 | 在编译阶段已经确定内存大小 | 在运行阶段确定大小 |
存储数据是否存在内存上限 | 是(不同浏览器规则不同) | 不存在内存上限 |
示例
const person = { name: 'John', age: 24, }; 复制代码
JS为这个对象在heap
中分配空间。然而其属性的值为原始类型,所以属性值被存储在stack
中。
const hobbies = ['hiking', 'reading']; 复制代码
数组也属于对象,所以它也是被存储在heap
中。
let name = 'John'; // 为string 分配内存 const age = 24; // 为数字分配内存 name = 'John Doe'; // 重新分配内存 const firstName = name.slice(0,4); // 重新分配内存 复制代码
原始数据是不可变(immutable)的。为了将原来的值进行替换,JS会重新创建一个新的值。
JS中的引用
所有变量都在stack
中存在指定的信息。 在上文中我们得知,stack
中存储两种类型的值- 原始类型和引用类型。 原始类型我们不用过多解释,而引用类型需要着重解释一下。
由于对象和函数存放在heap
中,而heap
是一个杂乱无章的数据结构,没有指定的顺序,所以JS解释器在从上到下编译代码的时候,就会按照数据出现的顺序,依次按照数据类型存放到指定的位置。而在发现某个数据是非原始类型,就会在stack
中存储其在heap
中存储的引用地址。
垃圾回收
通过上文的学习,我们已经知道了JS引擎是如何存储不同的数据,但是凡事都是有头有尾的,既然存在内存的分配,那势必就会存在内存的回收。
和内存分配一样,内存回收的工作JS也为我们代劳了。并且还派专人(GC (garbage collector))全权负责此事。
一旦JS引擎发现变量或者函数不在被引用,GC
将其占用的空间释放(deallocate/release
)掉。
而回收内存最主要的问题是,内存是否被引用是一个不可预知的事,这也意味着没有一种算法能够在内存不被占用的时候,将所有的内存回收。
下面我们讨论一些比较典型的垃圾回收算法。
引用计数
这是一种最简单的方式。它通过回收那些没有指针指向的对象。
通过上面的一系列操作,虽然将person
和newPerson
的引用都给置为null
,但是对象中hobbies
的还是被其他变量所引用。
循环引用
针对引用计数
这种GC方式,有一种情况是无法处理的-循环引用。当一个或者多个对象互相引用,但是这些对象已经处于孤立状态。此时,引用计数就手足无措了。
let son = { name: 'John', }; let dad = { name: 'Johnson', } son.dad = dad; dad.son = son; son = null; dad = null; 复制代码
虽然在不使用son
和dad
的时候,将它们都置为null
。这只是将它们与stack
中的引用脱离的关系,但是在heap
中,他们还彼此引用。
son
和dad
对象互相引用,而引用计数的算法在这两个对象没有被其他对象引用的时候是无法释放内存的。
标记清除
标记清除
算法能够很好的解决循环引用
的痛点。不同于引用计数
通过计算对象引用个数的方式来决定是否进行垃圾回收,它采用了一种通过从根对象开始遍历,如果某个对象遍历不到,那就需要被GC释放对应的内存。
在浏览器中,这个根对象
是window
对象,而在NodeJS中,是global
对象。
该算法通过 标记那些不能被访问到的对象,作为GC的目标。 根元素不参与标记过程!
内存泄漏
通过上文的介绍和对已有概念的掌握,我们来分析一些常规的可能造成内存泄漏的原因。
将变量挂载到全局变量
将数据肆无忌惮的存储在全局变量上,这是一种很常见的内存泄漏。
在浏览器环境中,如果在定义一个变量的时候,缺省了var
/const
/let
,此时定义的变量就会被挂载到window
对象上。
users = getUsers(); 复制代码
我们可以通过以strict mode来运行代码。
如果在某些情况下,逼不得已需要将变量挂载到全局变量上,你需要在该变量使命完成的时候,手动将其置为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); 复制代码
上面的代码,每2m执行一次,定时器中的变量在定时器没有关闭的时候,是一直无法被GC的。
所以,在不使用定时器的时候,需要将其销毁。
clearInterval(intervalId); 复制代码
清除回调函数
const element = document.getElementById('button'); const onClick = () => alert('hi'); element.addEventListener('click', onClick); element.removeEventListener('click', onClick); element.parentNode.removeChild(element); 复制代码
清除DOM 引用
将DOM元素存储到JS中,可能也会导致内存泄漏。
当你想通过数组来销毁元素,这种情况是无法触发GC操作的
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); }); } 复制代码